Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.

var builder = DistributedApplication.CreateBuilder(args);
var browser = OperatingSystem.IsWindows() ? "msedge" : "chrome";

builder.AddProject<Projects.BrowserTelemetry_Web>("web")
.WithExternalHttpEndpoints()
.WithReplicas(2);
.WithBrowserLogs(browser);

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
@page
<div class="container">
<p>Hello world</p>
<div class="row g-4">
<div class="col-lg-8">
<h1 class="display-6">Browser logs demo</h1>
<p class="lead">
Use the <strong>web-browser-logs</strong> resource in the dashboard to open a tracked browser session, then
use the buttons below to emit browser-side console logs, network requests, and unhandled failures.
</p>
</div>
<div class="col-lg-4">
<div class="alert alert-secondary mb-0" id="browser-log-status">
Waiting for browser interaction.
</div>
</div>
</div>

<div class="row row-cols-1 row-cols-md-2 g-3 mt-1">
<div class="col">
<button class="btn btn-primary w-100" id="emit-console-log" type="button">Emit console.log</button>
</div>
<div class="col">
<button class="btn btn-warning w-100" id="emit-console-warn" type="button">Emit console.warn</button>
</div>
<div class="col">
<button class="btn btn-danger w-100" id="emit-console-error" type="button">Emit console.error</button>
</div>
<div class="col">
<button class="btn btn-dark w-100" id="emit-unhandled-exception" type="button">Throw unhandled exception</button>
</div>
<div class="col">
<button class="btn btn-outline-danger w-100" id="emit-unhandled-rejection" type="button">Reject unhandled promise</button>
</div>
<div class="col">
<button class="btn btn-outline-primary w-100" id="emit-successful-fetch" type="button">Fetch successful request</button>
</div>
<div class="col">
<button class="btn btn-outline-secondary w-100" id="emit-failing-fetch" type="button">Fetch failing request</button>
</div>
</div>
</div>
98 changes: 94 additions & 4 deletions playground/BrowserTelemetry/BrowserTelemetry.Web/Scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,104 @@ export function initializeTelemetry(otlpUrl, headers, resourceAttributes) {
});
}

document.addEventListener('DOMContentLoaded', () => {
wireButton('emit-console-log', () => {
setStatus('Emitted console.log.');
console.log('BrowserTelemetry demo: console.log fired from the tracked browser.');
});

wireButton('emit-console-warn', () => {
setStatus('Emitted console.warn.');
console.warn('BrowserTelemetry demo: console.warn fired from the tracked browser.');
});

wireButton('emit-console-error', () => {
setStatus('Emitted console.error.');
console.error('BrowserTelemetry demo: console.error fired from the tracked browser.');
});

wireButton('emit-unhandled-exception', () => {
setStatus('Scheduling an unhandled exception.');
window.setTimeout(() => {
throw new Error('BrowserTelemetry demo: unhandled exception from tracked browser.');
}, 50);
});

wireButton('emit-unhandled-rejection', () => {
setStatus('Scheduling an unhandled promise rejection.');
Promise.reject(new Error('BrowserTelemetry demo: unhandled promise rejection from tracked browser.'));
});

wireButton('emit-successful-fetch', async () => {
setStatus('Issuing a successful fetch request.');
const response = await fetch(`${window.location.pathname}?browserNetworkSuccess=${Date.now()}`, {
cache: 'no-store',
headers: {
'x-browser-telemetry-demo': 'success'
}
});

setStatus(`Successful fetch completed with status ${response.status}.`);
console.info(`BrowserTelemetry demo: successful fetch completed with status ${response.status}.`);
});

wireButton('emit-failing-fetch', async () => {
setStatus('Issuing a failing fetch request.');

try {
await fetch(`https://127.0.0.1:1/browser-network-failure?ts=${Date.now()}`, {
cache: 'no-store'
});
} catch (error) {
setStatus('Failing fetch triggered.');
console.warn('BrowserTelemetry demo: failing fetch triggered for tracked browser network capture.', error);
}
});

window.setTimeout(() => {
setStatus('Tracked browser demo ready.');
console.info('BrowserTelemetry demo: page loaded and ready for tracked browser logging.');
}, 250);
});

function parseDelimitedValues(s) {
const headers = s.split(','); // Split by comma
const o = {};
if (!s || !s.trim()) {
return o;
}

s.split(',').forEach(header => {
const separatorIndex = header.indexOf('=');
if (separatorIndex === -1) {
return;
}

const key = header.slice(0, separatorIndex).trim();
if (!key) {
return;
}

headers.forEach(header => {
const [key, value] = header.split('='); // Split by equal sign
o[key.trim()] = value.trim(); // Add to the object, trimming spaces
const value = header.slice(separatorIndex + 1).trim();
o[key] = value;
});

return o;
}

function wireButton(id, callback) {
const button = document.getElementById(id);
if (!button) {
return;
}

button.addEventListener('click', callback);
}

function setStatus(message) {
const status = document.getElementById('browser-log-status');
if (!status) {
return;
}

status.textContent = message;
}
173 changes: 173 additions & 0 deletions src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Resources;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Aspire.Hosting;

/// <summary>
/// Extension methods for adding tracked browser log resources to browser-based application resources.
/// </summary>
public static class BrowserLogsBuilderExtensions
{
internal const string BrowserResourceType = "BrowserLogs";
internal const string BrowserPropertyName = "Browser";
internal const string BrowserExecutablePropertyName = "Browser executable";
internal const string TargetUrlPropertyName = "Target URL";
internal const string ActiveSessionsPropertyName = "Active sessions";
internal const string ActiveSessionCountPropertyName = "Active session count";
internal const string TotalSessionsLaunchedPropertyName = "Total sessions launched";
internal const string LastSessionPropertyName = "Last session";
internal const string OpenTrackedBrowserCommandName = "open-tracked-browser";

/// <summary>
/// Adds a child resource that can open the application's primary browser endpoint in a tracked browser session and
/// surface browser console output in the dashboard console logs.
/// </summary>
/// <typeparam name="T">The type of resource being configured.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="browser">
/// The browser to launch. Defaults to <c>"msedge"</c>. Supported values include logical browser names such as
/// <c>"msedge"</c> and <c>"chrome"</c>, or an explicit browser executable path.
/// </param>
/// <returns>A reference to the original <see cref="IResourceBuilder{T}"/> for further chaining.</returns>
/// <remarks>
/// <para>
/// This method adds a child browser logs resource beneath the parent resource represented by <paramref name="builder"/>.
/// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to
/// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's
/// console log stream.
/// </para>
/// <para>
/// The tracked browser session uses the <a href="https://chromedevtools.github.io/devtools-protocol/">Chrome DevTools
/// Protocol (CDP)</a> to subscribe to browser runtime, log, page, and network events.
/// </para>
/// <para>
/// The parent resource must expose at least one HTTP or HTTPS endpoint. HTTPS endpoints are preferred over HTTP
/// endpoints when selecting the browser target URL.
/// </para>
/// </remarks>
/// <example>
/// Add tracked browser logs for a web front end:
/// <code>
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddProject&lt;Projects.WebFrontend&gt;("web")
/// .WithExternalHttpEndpoints()
/// .WithBrowserLogs();
/// </code>
/// </example>
[AspireExport(Description = "Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.")]
public static IResourceBuilder<T> WithBrowserLogs<T>(this IResourceBuilder<T> builder, string browser = "msedge")
where T : IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(browser);

builder.ApplicationBuilder.Services.TryAddSingleton<IBrowserLogsSessionManager, BrowserLogsSessionManager>();

var parentResource = builder.Resource;
var browserLogsResource = new BrowserLogsResource($"{parentResource.Name}-browser-logs", parentResource, browser);

builder.ApplicationBuilder.AddResource(browserLogsResource)
.WithParentRelationship(parentResource)
.ExcludeFromManifest()
.WithIconName("GlobeDesktop")
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = BrowserResourceType,
CreationTimeStamp = DateTime.UtcNow,
State = KnownResourceStates.NotStarted,
Properties =
[
new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, parentResource.Name),
new ResourcePropertySnapshot(BrowserPropertyName, browser),
new ResourcePropertySnapshot(ActiveSessionCountPropertyName, 0),
new ResourcePropertySnapshot(ActiveSessionsPropertyName, "None"),
new ResourcePropertySnapshot(TotalSessionsLaunchedPropertyName, 0)
]
})
.WithCommand(
OpenTrackedBrowserCommandName,
CommandStrings.OpenTrackedBrowserName,
async context =>
{
try
{
var url = ResolveBrowserUrl(parentResource);
var sessionManager = context.ServiceProvider.GetRequiredService<IBrowserLogsSessionManager>();
await sessionManager.StartSessionAsync(browserLogsResource, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false);
return CommandResults.Success();
}
catch (Exception ex)
{
return CommandResults.Failure(ex.Message);
}
},
new CommandOptions
{
Description = CommandStrings.OpenTrackedBrowserDescription,
IconName = "Open",
IconVariant = IconVariant.Regular,
IsHighlighted = true,
UpdateState = context =>
{
var childState = context.ResourceSnapshot.State?.Text;
if (childState == KnownResourceStates.Starting)
{
return ResourceCommandState.Disabled;
}

var resourceNotifications = context.ServiceProvider.GetRequiredService<ResourceNotificationService>();
foreach (var resourceName in parentResource.GetResolvedResourceNames())
{
if (resourceNotifications.TryGetCurrentState(resourceName, out var resourceEvent))
{
var parentState = resourceEvent.Snapshot.State?.Text;
if (parentState == KnownResourceStates.Running || parentState == KnownResourceStates.RuntimeUnhealthy)
{
return ResourceCommandState.Enabled;
}
}
}

return ResourceCommandState.Disabled;
}
});

builder.OnBeforeResourceStarted((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()))
.OnResourceReady((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()))
.OnResourceStopped((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()));

return builder;

Task RefreshBrowserLogsResourceAsync(ResourceNotificationService notifications) =>
notifications.PublishUpdateAsync(browserLogsResource, snapshot => snapshot);

static Uri ResolveBrowserUrl(T resource)
{
EndpointAnnotation? endpointAnnotation = null;
if (resource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
{
endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https")
?? endpoints.FirstOrDefault(e => e.UriScheme == "http");
}

if (endpointAnnotation is null)
{
throw new InvalidOperationException($"Resource '{resource.Name}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to.");
}

var endpointReference = resource.GetEndpoint(endpointAnnotation.Name);
if (!endpointReference.IsAllocated)
{
throw new InvalidOperationException($"Endpoint '{endpointAnnotation.Name}' for resource '{resource.Name}' has not been allocated yet.");
}

return new Uri(endpointReference.Url, UriKind.Absolute);
}
}
}
Loading
Loading