Aspire 13.3 introduces WithBrowserLogs, an extension that attaches a tracked Chromium browser to an endpoint-capable resource. Console logs and network activity stream back into the Aspire dashboard alongside your backend traces. For anyone debugging a frontend that talks to a distributed backend, this is the hop you’ve been making manually for years.
The hop you’ve been making
Picture the standard debugging move. A user reports a 500 on submit. You start the AppHost. You open the dashboard. You see backend traces. You also need the frontend’s perspective: the request that fired, what status it got, what console errors appeared. So you open a second browser. You open DevTools in that browser. You hit submit. You alt-tab between the dashboard (backend traces) and DevTools (frontend logs and network), correlating manually by timestamp and URL.
That correlation is what WithBrowserLogs automates. The browser becomes a child resource of your frontend service, Aspire launches it, and console + network feed into the same dashboard you’re already in.
The shape of the API
WithBrowserLogs is a single extension method with three optional positional arguments, browser, profile, userDataMode. The default call has no arguments; everything else can be set later from the dashboard via the Configure tracked browser command, or from configuration under Aspire:Hosting:BrowserLogs (globally) or Aspire:Hosting:BrowserLogs:{ResourceName} (per-resource).
#pragma warning disable ASPIREBROWSERLOGS001
var web = builder.AddProject("order-web", "order-web/order-web.csproj")
.WithHttpEndpoint(port: 5050, name: "http")
.WithBrowserLogs(); // optional: browser, profile, userDataMode
#pragma warning restore ASPIREBROWSERLOGS001
The signature itself, from Aspire.Hosting:
public static IResourceBuilder<T> WithBrowserLogs<T>(
this IResourceBuilder<T> builder,
string? browser = null,
string? profile = null,
BrowserUserDataMode? userDataMode = null)
where T : IResourceWithEndpoints;
browser accepts a logical name ("msedge", "chrome") or an explicit executable path. BrowserUserDataMode is Shared or Isolated; both modes use persistent Aspire-managed user-data directories under ~/.local/share/aspire/browser-data/, the user’s real browser profile is never touched.
A child resource, <parent>-browser-logs, appears in the dashboard. It is not started until you click Open tracked browser; that command launches the local Chromium-family browser (Edge on my Linux box, /usr/bin/microsoft-edge-stable), navigates it to the parent endpoint, and starts streaming.


What actually flows back, verified by reading the running session’s Console logs pane:
- Console events.
console.log,console.warn,console.error,console.debug, captured via CDPRuntime.consoleAPICalledand rendered as[console.log] …/[console.debug] …lines in the child resource’s pane. - Uncaught exceptions. Stack traces from
setTimeoutcallbacks land as[log.error]lines, including failed-resource entries from the browser’s own loader (e.g. a missing favicon). - Network requests.
[network.document]for the main document,[network.other]for asset andfetchcalls. Method, URL, status, duration, byte counts. No header filter knob ships in the API; the noise comes through and you scroll past it. - Session lifecycle. Open / attach / target events: which executable was launched, the CDP websocket URL, the target session ID, profile resolution.
Real browser, real CDP, not a headless DOM emulator. JavaScript runs, the network actually goes, CSS actually loads. If the bug only reproduces in a real engine, this catches it.


What’s actually new versus Playwright in a test
If you’ve used Playwright for end-to-end tests, the technical mechanism is familiar, CDP-driven Chromium with log piping. The novelty is not the technology, it’s the seam.
Playwright runs in tests. You write a script, you assert outcomes, you get a pass/fail. The browser is a means to an end. WithBrowserLogs runs during normal aspire run. There is no test, no assertion. The browser is part of your dev loop, like the database container is part of your dev loop. You poke at the app like you would in any browser, and the dashboard records what happened.
Two different jobs. The 13.3 feature isn’t replacing your Playwright suite. It’s removing the alt-tab between dashboard and DevTools.
Where this becomes worth the setup
A few real scenarios where I’d reach for this on day one:
1. Correlating a frontend 500 with a backend trace.
The browser logs an error from /api/orders POST → 500. The dashboard logs the corresponding backend trace. Both with timestamps inside the same dashboard. The correlation that previously required a screenshot and a Slack thread takes one scroll.
2. Catching CSP and CORS errors during refactors.
These show up in the browser console and nowhere else. Adding a header on the API or moving an asset to a different origin can quietly break the frontend with no backend signal at all. With WithBrowserLogs, a deploy-time CSP violation shows up next to your API request log.
3. Reviewing a teammate’s local repro.
aspire run boots their AppHost; the tracked browser logs are part of the dashboard state. Less “screenshot the console for me” and more “I see your browser logs.”
Caveats and a couple of risks
Logs leaking sensitive data. If your frontend logs the bearer token to console during dev (don’t, but it happens), it now shows up in the dashboard log pane and in any export of dashboard state. Treat browser logs with the same scrutiny as backend logs, assume they’re visible to anyone with dashboard access.
Confusing test scenarios. A tracked browser running concurrent with a Playwright test against the same AppHost can produce two browsers hitting the same dev server, which makes log noise harder to triage. Disable WithBrowserLogs in the test profile, or use a different launch profile altogether.
Three first-party commands: the body shape, written by the framework
WithBrowserLogs ships three first-party commands on the child resource. You don’t write any of them, but they’re worth reading because they’re the canonical body-shape example: a WithCommand registration that returns CommandResults.Success("summary", new CommandResultData { Value = …, Format = … }), with UpdateState gating availability and a structured payload for the body. The rest of this series unpacks each piece of that recipe; the framework’s own commands here are the reference implementation to keep your own up against.


1. Open tracked browser: the lifecycle command
Click it on the child resource; Aspire launches a Chromium-family browser (Edge or Chrome) against the parent’s endpoint and starts streaming.


2. Capture screenshot: the canonical body-shape command
This is the one that justifies the entire series. Like Open tracked browser, you don’t write it, but it’s a textbook CommandResults.Success("summary", new CommandResultData { ... }) call with DisplayImmediately = true, and pressing it produces this dialog, the typed JSON record body rendered in-place, with the success toast top-right:


Three things, in order of how surprising they are.
The result is a typed record, not a string. BrowserLogsScreenshotCommandResult is a real C# record type, serialized to JSON with Format = CommandResultFormat.Json. This is the structured-results pitch from earlier in the series in production: a script piping aspire command run order-web-browser-logs capture-screenshot --json gets a parseable object, filePath, sizeBytes, mimeType, targetUrl, sessionId, browser, browserExecutable, processId, not a markdown table it has to scrape. The same payload renders in the dashboard’s notification panel for humans, but the bytes that leave the AppHost are designed for both readers.
DisplayImmediately = true. Without it, the result lands in the notification bell and waits for the developer to open the panel. With it, the dashboard pops the result in a dialog the moment the command completes. Use it for commands whose output the developer needs to act on now, a screenshot path you’re about to drag into a ticket, a one-time secret, the URL of a freshly-provisioned resource. Don’t use it for periodic results; it gets annoying fast. The screenshot command is exactly the right call site: you ran it because you wanted the file, so showing you the file path the moment it’s ready is what you want.
The summary string is the human form, the body is the machine form. "Captured screenshot to '/path/to/file.png'." is the one-line bell entry you read in the dashboard timeline. The JSON body is what a script consumes. This split, short prose summary, structured payload, is the symmetry to copy whenever a command’s output has both human and machine readers.
3. Configure tracked browser: the IInteractionService command
Click it on the child resource and the dashboard prompts for the four knobs that otherwise live as positional arguments on WithBrowserLogs(browser, profile, mode):


- Scope. Apply to this resource only, or to every
WithBrowserLogsresource in the AppHost. Resource-specific values override the global default. - Browser. Edge, Chrome, or Chromium, picked from what Aspire’s browser hive has on disk.
- User data mode. Shared (one Aspire-managed user-data dir reused across every AppHost on the machine, so cookies and storage persist between projects) or Isolated (a separate Aspire-managed dir per AppHost, so projects don’t share state). Either way, your real Edge/Chrome profile is untouched.
- Profile. Which profile inside the chosen user-data hive to launch with.
- Save to AppHost user secrets. A checkbox that persists the selection so the next
aspire runstarts with it pre-applied.
It’s the canonical example of a command that pairs a body-shape result with IInteractionService for input: the framework prompts the developer, applies the change at runtime, and (optionally) writes it back into user secrets so it survives a restart. Same recipe as the custom command in the next section, just pointing at configuration instead of event capture.
An aspiring future: auto-filing a GitHub issue from WithBrowserLogs
This whole section is highly experimental. GitHub doesn’t support “open this URL with a body and four pre-attached screenshots” today, there’s no API, no querystring, no first-party way. The only way to get screenshots inline is to drive the new-issue page with Playwright, paste each image as a synthetic
ClipboardEvent, and trust that the dashboard’s tracked-browser CDP port and GitHub’s textarea selectors don’t move. Any of those can break. Treat this as a thought experiment about whatWithBrowserLogsplus a custom command could compose into, an aspiring future, not a recipe to copy into production.
A real bug report wants more than a screenshot. It wants the timeline: which /api/* call returned 500, what console errors fired, what the page logged just before it broke. So you’d think the obvious extension is a WithCommand that writes a HAR file.
It isn’t, and the reason is worth understanding before writing code. The CDP envelopes that carry request/response headers and bodies (BrowserLogsRequestWillBeSent, BrowserLogsResponseReceived, …) are internal types in Aspire.Hosting.dll. Only one public surface emits anything from them: ResourceLoggerService, and what it emits is friendly text:
[network.document] GET http://localhost:5050/ -> 200 OK (146 ms, 201 B)
[console.log] browser-logs demo loaded
[log.error] Failed to load resource: ... (http://localhost:5050/favicon.ico)
Captured browser screenshot artifact '/.../screenshot/20260503T191200Z.png' (44886 bytes)
Method, URL, status, total ms, byte count. Console level + text. Screenshot artifact path. That’s the public ceiling. Headers, request bodies, response payloads, per-phase timings, gone. You can’t build a spec-compliant HAR from this. What you can build is a bug bundle: a typed timeline of console + network events plus the screenshot artifacts the framework already wrote to disk. That’s still useful, arguably more useful than HAR for filing issues, since it composes with the screenshot command instead of duplicating its work.
So the custom command is create-github-issue, and it has three parts: a BrowserLogsCollector BackgroundService that subscribes to the child resource’s log stream and parses lines into typed events; a WithCommand that snapshots the buffer, writes JSON to disk, renders a chronological markdown timeline, and spawns a Chromium window pointed at GitHub’s new-issue page; and a tiny Playwright script that fills the body and pastes the screenshots inline at the right positions.
The collector
sealed record BugBundleNetworkEntry(string Kind, string Method, string Url, int Status,
string StatusText, int DurationMs, int Bytes, DateTimeOffset Timestamp);
sealed record BugBundleConsoleEntry(string Level, string Text, DateTimeOffset Timestamp);
sealed record BugBundleErrorEntry(string Text, DateTimeOffset Timestamp);
sealed record BugBundleScreenshot(string Path, long SizeBytes, DateTimeOffset Timestamp);
sealed record BugBundleSnapshot(
DateTimeOffset ExportedAt, string ResourceName, int WindowMinutes,
IReadOnlyList<BugBundleNetworkEntry> Network,
IReadOnlyList<BugBundleConsoleEntry> Console,
IReadOnlyList<BugBundleErrorEntry> Errors,
IReadOnlyList<BugBundleScreenshot> Screenshots);
sealed class BrowserLogsCollector(ResourceLoggerService loggers) : BackgroundService
{
public string TargetResource { get; init; } = "logs-demo-browser-logs";
private readonly object _gate = new();
private readonly List<BugBundleNetworkEntry> _network = new();
private readonly List<BugBundleConsoleEntry> _console = new();
private readonly List<BugBundleErrorEntry> _errors = new();
private readonly List<BugBundleScreenshot> _screenshots = new();
private static readonly Regex LinePrefix = new(
@"^(?<ts>\S+Z)\s+(?:\[session-\d+\]\s+)?(?<rest>.*)$");
private static readonly Regex Network = new(
@"^\[network\.(?<kind>[^\]]+)\]\s+(?<method>\S+)\s+(?<url>\S+)\s+->\s+(?<status>\d+)\s+(?<statusText>.+?)\s+\((?<ms>\d+)\s+ms,\s+(?<bytes>\d+)\s+B\)$");
private static readonly Regex Console = new(
@"^\[console\.(?<level>[^\]]+)\]\s+(?<text>.*)$");
private static readonly Regex LogError = new(
@"^\[log\.error\]\s+(?<text>.*)$");
private static readonly Regex Screenshot = new(
@"Captured browser screenshot artifact '(?<path>[^']+)'\s+\((?<bytes>\d+)\s+bytes\)");
protected override async Task ExecuteAsync(CancellationToken ct)
{
try
{
await foreach (var batch in loggers.WatchAsync(TargetResource).WithCancellation(ct))
foreach (var entry in batch) Ingest(entry.Content);
}
catch (OperationCanceledException) { }
}
void Ingest(string raw)
{
var m = LinePrefix.Match(raw);
if (!m.Success || !DateTimeOffset.TryParse(m.Groups["ts"].Value, out var ts)) return;
var rest = m.Groups["rest"].Value;
if (Network.Match(rest) is { Success: true } n)
lock (_gate) _network.Add(new(n.Groups["kind"].Value, n.Groups["method"].Value,
n.Groups["url"].Value, int.Parse(n.Groups["status"].Value),
n.Groups["statusText"].Value, int.Parse(n.Groups["ms"].Value),
int.Parse(n.Groups["bytes"].Value), ts));
else if (Console.Match(rest) is { Success: true } c)
lock (_gate) _console.Add(new(c.Groups["level"].Value, c.Groups["text"].Value, ts));
else if (LogError.Match(rest) is { Success: true } e)
lock (_gate) _errors.Add(new(e.Groups["text"].Value, ts));
else if (Screenshot.Match(rest) is { Success: true } s)
lock (_gate) _screenshots.Add(new(s.Groups["path"].Value,
long.Parse(s.Groups["bytes"].Value), ts));
}
public BugBundleSnapshot Snapshot(TimeSpan window)
{
var cutoff = DateTimeOffset.UtcNow - window;
lock (_gate)
return new(DateTimeOffset.UtcNow, TargetResource, (int)window.TotalMinutes,
_network.Where(x => x.Timestamp >= cutoff).ToArray(),
_console.Where(x => x.Timestamp >= cutoff).ToArray(),
_errors.Where(x => x.Timestamp >= cutoff).ToArray(),
_screenshots.Where(x => x.Timestamp >= cutoff).ToArray());
}
}
ResourceLoggerService.WatchAsync(name) is a public, supported subscription point. The collector parses each line via four regexes, one per kind of event the framework currently emits, and accumulates typed records keyed by their original timestamp. Snapshot(window) returns a frozen view of the rolling window for the command to serialise. Public APIs only; no reflection.
The command
builder.Services.AddSingleton<BrowserLogsCollector>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<BrowserLogsCollector>());
logsDemo.WithCommand(
name: "create-github-issue",
displayName: "Create GitHub issue (custom)",
executeCommand: async (ctx) =>
{
var collector = ctx.ServiceProvider.GetRequiredService<BrowserLogsCollector>();
var window = TimeSpan.FromMinutes(5);
var snapshot = collector.Snapshot(window);
var dir = "/tmp/aspire-shots-browser-logs/bug-bundles";
Directory.CreateDirectory(dir);
var path = Path.Combine(dir,
$"bug-bundle-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}.json");
var json = JsonSerializer.Serialize(snapshot,
new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, json, ctx.CancellationToken);
var summary =
$"Exported bug bundle to {path} ({snapshot.Network.Count} requests, " +
$"{snapshot.Console.Count} console events, {snapshot.Errors.Count} errors, " +
$"{snapshot.Screenshots.Count} screenshots).";
var markdown =
$"### Bug bundle, last {(int)window.TotalMinutes} min\n\n" +
$"[Open `{Path.GetFileName(path)}`](file://{path})\n\n" +
$"| Kind | Count |\n|------|------:|\n" +
$"| Network requests | {snapshot.Network.Count} |\n" +
$"| Console events | {snapshot.Console.Count} |\n" +
$"| Errors | {snapshot.Errors.Count} |\n" +
$"| Screenshots | {snapshot.Screenshots.Count} |\n";
return CommandResults.Success(summary, new CommandResultData
{
Value = markdown,
Format = CommandResultFormat.Markdown,
DisplayImmediately = true
});
},
commandOptions: new()
{
IconName = "DocumentArrowDown",
Description = "Custom command, captures the last 5 minutes of browser console + network events plus screenshots, then opens a Chromium window with a pre-filled GitHub issue and pastes the screenshots automatically. Built on top of WithBrowserLogs."
});
The command lives on the parent (the project, not the child browser-logs resource), with (custom) in the display name so a teammate spotting it in the menu doesn’t think it ships with WithBrowserLogs:


Pressing it pops the dialog the moment the command returns, DisplayImmediately = true. The body is markdown, not JSON, because the file is the artifact and the issue draft is the action: the dialog’s job is to confirm both happened and tell you what to do next. The bundle directory is a file:// link for inspection; the headline is “a Chromium window is opening, review and submit when ready.” The counts table is the at-a-glance “is this worth filing” check. The success toast top-right confirms the result for the bell:


The body the command renders is a markdown timeline with <!-- SCREENSHOT:NN --> markers placed at each screenshot’s timestamp, network calls, console lines, errors, and screenshot markers all sit on the same OrderBy(ts) axis, so it reads in real-world order. Markers are HTML comments, invisible in rendered markdown, easy to find by indexOf from the paste handler.
var orderedShots = snapshot.Screenshots
.Where(s => File.Exists(s.Path))
.OrderBy(s => s.Timestamp)
.ToList();
var shotIndexByPath = new Dictionary<string, int>();
for (var i = 0; i < orderedShots.Count; i++)
{
shotIndexByPath[orderedShots[i].Path] = i + 1;
}
var timeline = new List<(DateTimeOffset ts, string md)>();
foreach (var n in snapshot.Network)
timeline.Add((n.Timestamp,
$"- `{n.Timestamp:HH:mm:ss}` · **net** · `{n.Method} {n.Url}` → **{n.Status}** {n.StatusText} ({n.DurationMs} ms, {n.Bytes} B)"));
foreach (var c in snapshot.Console)
timeline.Add((c.Timestamp,
$"- `{c.Timestamp:HH:mm:ss}` · **console.{c.Level}** · {Escape(c.Text)}"));
foreach (var e in snapshot.Errors)
timeline.Add((e.Timestamp,
$"- `{e.Timestamp:HH:mm:ss}` · ❌ **error** · {Escape(e.Text)}"));
foreach (var s in orderedShots)
timeline.Add((s.Timestamp,
$"\n<!-- SCREENSHOT:{shotIndexByPath[s.Path]:D2} -->\n"));
var timelineMd = string.Join("\n", timeline.OrderBy(x => x.ts).Select(x => x.md));
The other half is a tiny Playwright script the command launches via setsid node file-issue.mjs. It connects to the already-running tracked browser over CDP, fills the issue title and body, then walks the screenshots and pastes each at the matching marker. GitHub’s new-issue editor accepts a synthetic ClipboardEvent whose clipboardData carries a File; selecting the marker first means the upload placeholder lands at the right offset:
for (let i = 0; i < shots.length; i++) {
const idx = String(i + 1).padStart(2, '0');
const marker = `<!-- SCREENSHOT:${idx} -->`;
const base64 = readFileSync(shots[i], 'base64');
await textarea.evaluate((el, { base64, name, marker }) => {
el.focus();
const pos = el.value.indexOf(marker);
if (pos >= 0) el.setSelectionRange(pos, pos + marker.length);
const bin = atob(base64);
const buf = new Uint8Array(bin.length);
for (let j = 0; j < bin.length; j++) buf[j] = bin.charCodeAt(j);
const file = new File([buf], name, { type: 'image/png' });
const dt = new DataTransfer();
dt.items.add(file);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, { base64, name: `screenshot-${idx}.png`, marker });
await page.waitForFunction(/* wait for upload to complete and  to appear */);
}
The result, after Playwright finishes pasting, is a fully-populated GitHub issue draft with the timeline interleaving network + console events and screenshots in chronological order, the same data the JSON file carries, but rendered for a human reviewer:


The point isn’t the recipe. It’s the question. Once WithBrowserLogs gives you a typed timeline of what the browser did, what’s the highest-value thing you can do with it before the human ever opens the dashboard? File the issue. Cut the ticket. Open the PR. The “right” answer for your team is probably none of those, but the shape of the move is the same: the bundle is the input, your team’s existing intake surface is the output, and the command body is just the glue. That’s the future worth aspiring to.
Outcome
The frontend stops being a dark room next to your well-instrumented backend. Console errors and API requests show up in the same dashboard as your backend traces, on the same timeline. The alt-tab between dashboard and DevTools turns into a scroll. Not because the technology is novel, Playwright has done CDP-driven Chromium for years, but because it removes a context switch you make a hundred times a week.