-
Notifications
You must be signed in to change notification settings - Fork 65
Add Blazor WASM telemetry docs and post-deployment observability guidance #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
28889dc
23efef3
a88f5f8
a44b510
2fdf287
3aa68ca
aa6e085
aaa9d61
b5e26e6
2edce45
a04eb99
eef8311
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alistairmatthews I apologize. I took another look and I think we might need to rethink this. In WebAssembly we are enabling the .NET OTEL telemetry SDK as part of other changes. I started looking at this from the general point of Blazor (the js bit is still useful for blazor with enhanced nav) and thinking that the OTEL telemetry on JS will add additional information, but after looking at it in more detail, I think it's mostly going to do the same thing as microsoft/aspire#15691 which is the "blessed" approach for .NET and would end up causing issues (essentially duplicating logs and traces). Based on that, I think we might want to rework the approach here. I apologies, I should have realized earlier. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| --- | ||
| title: Blazor WebAssembly integration | ||
| description: Learn how to integrate browser telemetry in a Blazor WebAssembly app using JavaScript interop. | ||
| --- | ||
|
|
||
| import LearnMore from '@components/LearnMore.astro'; | ||
| import { Aside } from '@astrojs/starlight/components'; | ||
|
|
||
| Blazor WebAssembly (WASM) apps run entirely in the browser using a .NET runtime compiled to WebAssembly. Like other browser apps, Blazor WASM apps use the [JavaScript OTEL SDK](https://opentelemetry.io/docs/languages/js/getting-started/browser/) to send telemetry to the Aspire dashboard via JavaScript interop. | ||
|
|
||
| ## Provide OTEL configuration to the browser using a CORS proxy | ||
|
|
||
| Blazor WASM apps can't read server-side environment variables directly. When the app is hosted by an ASP.NET Core server (for example, a Blazor Web App or hosted Blazor WASM project), expose the OTEL configuration through a server-side CORS proxy. Because the browser sends telemetry to the same-origin proxy rather than directly to the dashboard, no CORS configuration is required on the dashboard and no sensitive values reach the browser. | ||
|
|
||
| Add a lightweight configuration endpoint that returns only the proxy path: | ||
|
|
||
| ```csharp title="C# — Program.cs (Server)" | ||
| // Register an HttpClient for forwarding OTEL traffic | ||
| builder.Services.AddHttpClient("otel-proxy"); | ||
|
|
||
| // Configuration endpoint — returns the proxy URL only (no API key) | ||
| app.MapGet("/api/telemetry-config", () => new | ||
| { | ||
| Endpoint = "/api/otel-proxy" | ||
| }); | ||
|
|
||
| // CORS proxy endpoint — forwards OTEL traffic with the API key added server-side | ||
| app.MapPost("/api/otel-proxy/{**path}", async ( | ||
| string path, | ||
| HttpContext context, | ||
| IHttpClientFactory httpClientFactory, | ||
| IConfiguration config) => | ||
| { | ||
| var dashboardEndpoint = config.GetValue<string>("OTEL_EXPORTER_OTLP_ENDPOINT"); | ||
| if (string.IsNullOrEmpty(dashboardEndpoint)) | ||
| return Results.NotFound(); | ||
|
|
||
| var client = httpClientFactory.CreateClient("otel-proxy"); | ||
|
|
||
| using var requestBody = new StreamContent(context.Request.Body); | ||
| requestBody.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue | ||
| .Parse(context.Request.ContentType ?? "application/x-protobuf"); | ||
|
|
||
| using var request = new HttpRequestMessage( | ||
| HttpMethod.Post, | ||
| $"{dashboardEndpoint.TrimEnd('/')}/{path}") | ||
| { | ||
| Content = requestBody | ||
| }; | ||
|
|
||
| // Attach OTLP API key server-side — never sent to the browser | ||
| var headersEnv = config.GetValue<string>("OTEL_EXPORTER_OTLP_HEADERS") ?? string.Empty; | ||
| foreach (var header in headersEnv.Split(',', StringSplitOptions.RemoveEmptyEntries)) | ||
| { | ||
| var parts = header.Split('=', 2); | ||
| if (parts.Length == 2) | ||
| request.Headers.TryAddWithoutValidation(parts[0].Trim(), parts[1].Trim()); | ||
| } | ||
|
|
||
| var response = await client.SendAsync(request); | ||
| context.Response.StatusCode = (int)response.StatusCode; | ||
| await response.Content.CopyToAsync(context.Response.Body); | ||
| return Results.Empty; | ||
| }); | ||
| ``` | ||
|
|
||
| The browser fetches this endpoint and receives only the proxy URL. In the Blazor WASM app, call `initializeTelemetry` with the proxy URL so the JavaScript OTEL SDK sends data to the same-origin proxy rather than the dashboard directly. For more details, see [Initialize OTEL from a JavaScript initializer](#initialize-otel-from-a-javascript-initializer) below. | ||
|
|
||
| <Aside type="tip"> | ||
| Because the browser only ever communicates with the same-origin proxy, CORS does not need to be enabled on the Aspire dashboard and the OTLP API key remains entirely server-side. | ||
| </Aside> | ||
|
|
||
| ## Initialize OTEL from a JavaScript initializer | ||
|
|
||
| Blazor WASM supports [JavaScript initializers](https://learn.microsoft.com/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-9.0#javascript-initializers) that run automatically during the Blazor startup lifecycle. You can use these initializers to initialize OTEL telemetry. Create a file named `{AssemblyName}.lib.module.js` in the `wwwroot` folder of your Blazor WASM project, replacing `{AssemblyName}` with your project's assembly name, then export an `afterWebAssemblyStarted` function: | ||
|
|
||
| ```javascript title="JavaScript — {YourApp}.lib.module.js (Client wwwroot)" | ||
| export async function afterWebAssemblyStarted(blazor) { | ||
| const response = await fetch('/api/telemetry-config'); | ||
| if (!response.ok) return; | ||
|
|
||
| const config = await response.json(); | ||
| if (config.endpoint) { | ||
| initializeTelemetry(config.endpoint, config.headers, config.resourceAttributes); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| <Aside type="tip"> | ||
| The file name must match your Blazor WASM project's assembly name. For example, if your project assembly is named `MyApp.Client`, use `MyApp.Client.lib.module.js`. | ||
| </Aside> | ||
|
|
||
| Blazor automatically discovers and executes the initializer during startup — no Razor component or `IJSRuntime` injection is needed. Because `afterWebAssemblyStarted` fires after the Blazor runtime has loaded but before components render, telemetry is active from the very first component lifecycle. | ||
|
|
||
| ## Backend-to-frontend trace correlation | ||
|
|
||
| To correlate browser spans with server-side traces, include the current trace context in the server-rendered HTML. When the Blazor WASM app is hosted within a server-rendered page, such as a Blazor Web App with prerendering, the server can write the `traceparent` meta tag during prerender: | ||
|
|
||
| ```razor title="Razor — App.razor (Server prerender)" | ||
| @using System.Diagnostics | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| @if (Activity.Current is { } currentActivity) | ||
| { | ||
| <meta name="traceparent" content="@currentActivity.Id" /> | ||
| } | ||
| <!-- Other elements omitted for brevity... --> | ||
| </head> | ||
| ``` | ||
|
|
||
| The JavaScript OTEL SDK reads this `traceparent` value automatically when `DocumentLoadInstrumentation` is registered, linking the browser spans to the originating server trace. | ||
|
|
||
| ## Authenticated OTEL proxy | ||
|
|
||
| When the Aspire dashboard's OTLP API key must not be exposed to the browser, route telemetry through a server-side proxy endpoint. The browser sends telemetry to the proxy, and the proxy forwards it to the dashboard with the API key included as a server-side secret: | ||
|
|
||
| ```csharp title="C# — Program.cs (Server proxy endpoint)" | ||
| // Register an HttpClient for the OTEL proxy | ||
| builder.Services.AddHttpClient("otel-proxy"); | ||
|
|
||
| // ... | ||
|
|
||
| app.MapPost("/api/telemetry/{**path}", async ( | ||
| string path, | ||
| HttpContext context, | ||
| IHttpClientFactory httpClientFactory) => | ||
| { | ||
| var dashboardEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT"); | ||
| if (string.IsNullOrEmpty(dashboardEndpoint)) | ||
| { | ||
| return Results.NotFound(); | ||
| } | ||
|
|
||
| var client = httpClientFactory.CreateClient("otel-proxy"); | ||
|
|
||
| using var requestBody = new StreamContent(context.Request.Body); | ||
| requestBody.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue | ||
| .Parse(context.Request.ContentType ?? "application/x-protobuf"); | ||
|
|
||
| using var request = new HttpRequestMessage( | ||
| HttpMethod.Post, | ||
| $"{dashboardEndpoint.TrimEnd('/')}/{path}") | ||
| { | ||
| Content = requestBody | ||
| }; | ||
|
|
||
| // Copy OTLP API key from server environment to the forwarded request | ||
| var headersEnv = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_HEADERS") ?? string.Empty; | ||
| foreach (var header in headersEnv.Split(',', StringSplitOptions.RemoveEmptyEntries)) | ||
| { | ||
| var parts = header.Split('=', 2); | ||
| if (parts.Length == 2) | ||
| { | ||
| request.Headers.TryAddWithoutValidation(parts[0].Trim(), parts[1].Trim()); | ||
| } | ||
| } | ||
|
|
||
| var response = await client.SendAsync(request); | ||
|
|
||
| context.Response.StatusCode = (int)response.StatusCode; | ||
| await response.Content.CopyToAsync(context.Response.Body); | ||
| return Results.Empty; | ||
| }); | ||
| ``` | ||
|
|
||
| Configure the JavaScript OTEL SDK in the Blazor WASM app to point to the proxy endpoint instead of the dashboard directly: | ||
|
|
||
| ```javascript title="JavaScript — telemetry.js (Client)" | ||
| export function initializeTelemetry(resourceAttributes) { | ||
| const otlpOptions = { | ||
| url: '/api/telemetry/v1/traces' // Proxy endpoint, same origin - no CORS needed | ||
| }; | ||
| // ... rest of SDK initialization | ||
| } | ||
| ``` | ||
|
|
||
| This pattern eliminates the need for CORS configuration on the dashboard because the browser communicates only with the same-origin server. The API key stays on the server and is never visible to the browser. | ||
|
|
||
| ## See also | ||
|
|
||
| - [Enable browser telemetry overview](/dashboard/enable-browser-telemetry/) | ||
| - [Browser app configuration](/dashboard/enable-browser-telemetry/browser-app-configuration/) | ||
| - [Aspire dashboard configuration](/dashboard/configuration/) | ||
| - [Standalone Aspire dashboard](/dashboard/standalone/) | ||
| - [Telemetry after deployment](/dashboard/telemetry-after-deployment/) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| --- | ||
| title: Browser app configuration | ||
| description: Learn how to configure the JavaScript OTEL SDK in your browser app to send telemetry to the Aspire dashboard. | ||
| --- | ||
|
|
||
| import LearnMore from '@components/LearnMore.astro'; | ||
| import { Aside } from '@astrojs/starlight/components'; | ||
|
|
||
| A browser app uses the [JavaScript OTEL SDK](https://opentelemetry.io/docs/languages/js/getting-started/browser/) to send telemetry to the dashboard. Successfully sending telemetry to the dashboard requires the SDK to be correctly configured. | ||
|
|
||
| Before configuring the browser app, ensure the dashboard is configured with an [OTLP HTTP endpoint and CORS](/dashboard/enable-browser-telemetry/). For Blazor WebAssembly apps, see [Blazor WebAssembly integration](/dashboard/enable-browser-telemetry/blazor-webassembly/). | ||
|
|
||
| ## OTLP exporter | ||
|
|
||
| OTLP exporters must be included in the browser app and configured with the SDK. For example, exporting distributed tracing with OTLP uses the [@opentelemetry/exporter-trace-otlp-proto](https://www.npmjs.com/package/@opentelemetry/exporter-trace-otlp-proto) package. | ||
|
|
||
| When OTLP is added to the SDK, OTLP options must be specified. OTLP options includes: | ||
|
|
||
| - `url`: The address that HTTP OTLP requests are made to. The address should be the dashboard HTTP OTLP endpoint and the path to the OTLP HTTP API. For example, `https://localhost:4318/v1/traces` for the trace OTLP exporter. If the browser app is launched by the AppHost then the HTTP OTLP endpoint is available from the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable. | ||
|
|
||
| - `headers`: The headers sent with requests. If OTLP endpoint API key authentication is enabled the `x-otlp-api-key` header must be sent with OTLP requests. If the browser app is launched by the AppHost then the API key is available from the `OTEL_EXPORTER_OTLP_HEADERS` environment variable. | ||
|
|
||
| ## Browser metadata | ||
|
|
||
| When a browser app is configured to collect distributed traces, the browser app can set the trace parent a browser's spans using the `meta` element in the HTML. The value of the `name="traceparent"` meta element should correspond to the current trace. | ||
|
|
||
| In a .NET app, for example, the trace parent value would likely be assigned from the `Activity.Current` and passing its `Activity.Id` value as the `content`. For example, consider the following Razor code: | ||
|
|
||
| ```razor | ||
| <head> | ||
| @if (Activity.Current is { } currentActivity) | ||
| { | ||
| <meta name="traceparent" content="@currentActivity.Id" /> | ||
| } | ||
| <!-- Other elements omitted for brevity... --> | ||
| </head> | ||
| ``` | ||
|
|
||
| The preceding code sets the `traceparent` meta element to the current activity ID. | ||
|
|
||
| ## Example browser telemetry code | ||
|
|
||
| The following JavaScript code demonstrates the initialization of the OpenTelemetry JavaScript SDK and the sending of telemetry data to the dashboard: | ||
|
|
||
| ```javascript | ||
| import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; | ||
| import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; | ||
| import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; | ||
| import { registerInstrumentations } from '@opentelemetry/instrumentation'; | ||
| import { resourceFromAttributes } from '@opentelemetry/resources'; | ||
| import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; | ||
| import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; | ||
| import { ZoneContextManager } from '@opentelemetry/context-zone'; | ||
|
|
||
| export function initializeTelemetry(otlpUrl, headers, resourceAttributes) { | ||
| const otlpOptions = { | ||
| url: `${otlpUrl}/v1/traces`, | ||
| headers: parseDelimitedValues(headers) | ||
| }; | ||
|
|
||
| const attributes = parseDelimitedValues(resourceAttributes); | ||
| attributes[SemanticResourceAttributes.SERVICE_NAME] = 'browser'; | ||
|
|
||
| const provider = new WebTracerProvider({ | ||
| resource: resourceFromAttributes(attributes), | ||
| }); | ||
| provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); | ||
| provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter(otlpOptions))); | ||
|
|
||
| provider.register({ | ||
| // Prefer ZoneContextManager: supports asynchronous operations | ||
| contextManager: new ZoneContextManager(), | ||
| }); | ||
|
|
||
| // Registering instrumentations | ||
| registerInstrumentations({ | ||
| instrumentations: [new DocumentLoadInstrumentation()], | ||
| }); | ||
| } | ||
|
|
||
| function parseDelimitedValues(s) { | ||
| const headers = s.split(','); // Split by comma | ||
| const result = {}; | ||
|
|
||
| headers.forEach(header => { | ||
| const [key, value] = header.split('='); // Split by equal sign | ||
| result[key.trim()] = value.trim(); // Add to the object, trimming spaces | ||
| }); | ||
|
|
||
| return result; | ||
| } | ||
| ``` | ||
|
|
||
| The preceding JavaScript code defines an `initializeTelemetry` function that expects the OTLP endpoint URL, the headers, and the resource attributes. These parameters are provided by the consuming browser app that pulls them from the environment variables set by the app host. Consider the following Razor code: | ||
|
|
||
| ```razor {32-39} | ||
| @using System.Diagnostics | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>@ViewData["Title"] - BrowserTelemetry</title> | ||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" | ||
| integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> | ||
| <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> | ||
|
|
||
| @if (Activity.Current is { } currentActivity) | ||
| { | ||
| <meta name="traceparent" content="@currentActivity.Id" /> | ||
| } | ||
| </head> | ||
| <body> | ||
| <header> | ||
| <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3"> | ||
| <div class="container"> | ||
| <a class="navbar-brand" asp-area="" asp-page="/Index">BrowserTelemetry</a> | ||
| </div> | ||
| </nav> | ||
| </header> | ||
| <div class="container"> | ||
| <main role="main" class="pb-3"> | ||
| @RenderBody() | ||
| </main> | ||
| </div> | ||
| @await RenderSectionAsync("Scripts", required: false) | ||
| <script src="scripts/bundle.js"></script> | ||
| @if (Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") is { Length: > 0 } endpointUrl) | ||
| { | ||
| var headers = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_HEADERS"); | ||
| var attributes = Environment.GetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES"); | ||
| <script> | ||
| BrowserTelemetry.initializeTelemetry('@endpointUrl', '@headers', '@attributes'); | ||
| </script> | ||
| } | ||
| </body> | ||
| </html> | ||
| ``` | ||
|
|
||
| <Aside type="tip"> | ||
| The bundling and minification of the JavaScript code is beyond the scope of this article. | ||
| </Aside> | ||
|
|
||
| For the complete working example of how to configure the JavaScript OTEL SDK to send telemetry to the dashboard, see the [browser telemetry sample](https://github.com/microsoft/aspire/tree/main/playground/BrowserTelemetry). | ||
|
|
||
| ## See also | ||
|
|
||
| - [Enable browser telemetry overview](/dashboard/enable-browser-telemetry/) | ||
| - [Blazor WebAssembly integration](/dashboard/enable-browser-telemetry/blazor-webassembly/) | ||
| - [Aspire dashboard configuration](/dashboard/configuration/) | ||
| - [Standalone Aspire dashboard](/dashboard/standalone/) | ||
| - [Telemetry after deployment](/dashboard/telemetry-after-deployment/) |
Uh oh!
There was an error while loading. Please reload this page.