Skip to content

Blazor Web App

A Blazor Web App has two projects and up to four ways of rendering a page - Server, WebAssembly, Auto and static SSR. Belemetry is set up the same way for all of them: one registration in each project, <BelemetryScript /> in the document head, and <BelemetryInitializer /> in Routes.razor. What differs between the render modes is where the .NET half runs, and that is decided for you.

Install

One package, referenced by the project that renders App.razor (the server) and by the client project when there is one. It brings the browser script with it as a static web asset, at _content/Bit.Belemetry/bit-belemetry.js.

dotnet add package Bit.Belemetry

Register in both projects

Call AddBitBelemetryServices in the server's Program.cs and in the client's. Every component is instantiated twice - once on the server for the prerender pass and again in the browser - so whatever a page injects (IBelemetry, IBelemetryConsent) has to resolve in both containers. The call is idempotent and follows the Options pattern: every configure callback, across calls, runs in order on the same BelemetryOptions, which makes a shared configuration method the natural way to keep the two in step.

// In the client project, so both Program.cs files can call it.
using Bit.Belemetry;

public static class AppTelemetry
{
    public static void Configure(BelemetryOptions options)
    {
        options.ServiceName = "MyApp";
        options.AddOtlpExporter("https://otel.example.com");

        options.Consent.Default = BelemetryFeatures.None;   // opt-in until the user decides

        options.Traces.Sources.Add("MyApp*");
        options.Metrics.Meters.Add("MyApp");
    }
}
// Server/Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

builder.Services.AddBitBelemetryServices(options =>
{
    AppTelemetry.Configure(options);
    options.Environment = builder.Environment.EnvironmentName;
});
// Client/Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddBitBelemetryServices(options =>
{
    AppTelemetry.Configure(options);
    options.Environment = builder.HostEnvironment.Environment;
});

await builder.Build().RunAsync();

Each container has its own job:

ContainerWhat Belemetry does there
Server Builds the browser script's configuration from its options and writes it into the page with <BelemetryScript /> - so the exporters, the consent defaults and every options.Browser.* setting the browser sees come from the server registration. Resolves IBelemetry and IBelemetryConsent during prerendering and for interactive Server circuits. Collects the server's own .NET telemetry only if you opt in (below).
Client Runs the .NET pipeline in the browser: the WebAssembly app's ILogger, Activity and Meter records are collected and exported from there, and flushed with keepalive when the page unloads. Its metrics rely on System.Diagnostics.Metrics, which the WebAssembly SDK turns off by default and the Bit.Belemetry package turns back on (MetricsSupport).

The script in App.razor

<BelemetryScript /> renders the configuration as an inline JSON element and the module after it. Because it is static markup in the head, the script starts before Blazor boots - it sees the initial page view, the web vitals and errors thrown while the runtime downloads - and it also covers static SSR pages that never become interactive.

@* Server/Components/App.razor *@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <base href="/" />
    <BelemetryScript />
    <HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
    <Routes @rendermode="InteractiveAuto" />
    <script src="_framework/blazor.web.js"></script>
</body>
</html>

With MapStaticAssets and fingerprinted assets, pass the fingerprinted path: <BelemetryScript Src="@Assets["_content/Bit.Belemetry/bit-belemetry.js"]" />. Under a Content Security Policy, pass Nonce; it is added to both script elements.

The initializer in Routes.razor

<BelemetryInitializer /> connects the script to .NET once the app is interactive: consent and the session flow both ways, browser records can be relayed to .NET, and the WebAssembly pipeline gets its unload flush. It renders nothing. Place it once, in Routes.razor or the main layout, inside the interactive part of the app.

@* Client/Routes.razor *@
<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
    </Found>
</Router>

<BelemetryInitializer />

Per render mode

The setup above does not change between render modes. What changes is where .NET telemetry is collected and how IBelemetry calls travel:

Render mode Browser instrumentation .NET logs, traces, metrics IBelemetry and consent
InteractiveWebAssembly The script, from the head In the browser, exported by the client registration Through the WebAssembly pipeline; consent is app-wide
InteractiveServer The script, from the head Opt-in: the server pipeline Routed through the circuit's browser, so that user's consent applies
InteractiveAuto The script, from the head - one script for the whole visit On the server while a page runs on a circuit (opt-in), in the browser once Auto switches to WebAssembly As Server, then as WebAssembly
Static SSR The script, from the head Opt-in: the server pipeline No interactivity: consent is given in the browser, through window.BitBelemetry

On a static SSR page there is no circuit and no WebAssembly runtime, so IBelemetryConsent has nobody to talk to. The script is still running, and a plain HTML banner can record the choice with its JavaScript API - the same bit-belemetry-consent entry the .NET API writes, so an interactive page later reads the same decision:

The server pipeline

On an ASP.NET Core host Belemetry's .NET pipeline is off by default - such hosts usually have an OpenTelemetry setup of their own - and the server registration only renders and serves the script. Turn it on to collect the server's logs, traces (including the Microsoft.AspNetCore request spans) and metrics with the same exporters. It is process-wide, serving every user at once, so it follows Server.Features - an operator decision - and not any one user's consent. With Server and Auto, this is also where an interactive circuit's ILogger output is collected.

builder.Services.AddBitBelemetryServices(options =>
{
    AppTelemetry.Configure(options);

    options.Server.CollectDotnetTelemetry = true;
    options.Server.Features = BelemetryFeatures.Errors | BelemetryFeatures.Logs
                            | BelemetryFeatures.Traces | BelemetryFeatures.Metrics;   // the default
});

Browser fetch spans add a traceparent to same-origin requests, so the server's request span becomes a child of the browser's span and the two land in one trace. More on the Server pipeline page.

What this site does

This documentation site is a Blazor Web App in the InteractiveWebAssembly render mode, prerendered, and it is set up exactly as above: AddBitBelemetryServices in both projects through a shared DemoTelemetry.ConfigureShared, <BelemetryScript /> in App.razor, <BelemetryInitializer /> in Routes.razor, and the server pipeline turned on. It exports OTLP/JSON to itself, and an in-process receiver hands each reader their own records back.

Answer the consent banner, then open Live telemetry: the page views, clicks, web vitals and .NET logs you produce on this site arrive there within a couple of seconds, with the demo's real configuration printed underneath.

API

BelemetryScript Component

Renders the browser script with its configuration inline, so it starts as early as possible - before Blazor boots, and on static SSR pages that never become interactive. Place it in the <head> of App.razor.

Name Type and default Description
Src string The script URL. Defaults to _content/Bit.Belemetry/bit-belemetry.js; set it to the fingerprinted asset path when using @Assets[...].
Nonce string A CSP nonce added to both script elements.

BelemetryInitializer Component

Connects the browser script and .NET once the app is interactive: starts the script if BelemetryScript has not, synchronizes consent and the user context, enables relaying (Hybrid) and the unload flush (WebAssembly). Place it once in Routes.razor or the main layout. Renders nothing.

Name Type and default Description
OnReady EventCallback Raised once the script is connected.

BelemetryServerOptions Class

The server side (Blazor Server, static SSR, the server half of Auto). By default Belemetry only serves the browser script there - an ASP.NET Core host usually has its own OpenTelemetry setup - and its .NET pipeline stays off.

Name Type and default Description
CollectDotnetTelemetry bool= false Whether the .NET pipeline (ILogger, Activity, Meter collection and export) also runs on the server. Defaults to false.
Features BelemetryFeatures= BelemetryFeatures.Errors | BelemetryFeatures.Logs | BelemetryFeatures.Traces | BelemetryFeatures.Metrics The features the server pipeline collects when BelemetryServerOptions.CollectDotnetTelemetry is on. Defaults to errors, logs, traces and metrics.
IgnoredRequestPaths List<string>= [] Path prefixes (case-insensitive) of requests whose Microsoft.AspNetCore server span is dropped, e.g. /health, /alive, /_framework, /_content and /_blazor for health probes and Blazor's own traffic. Matched against url.path (.NET 10) or, failing that, http.route, which on .NET 8 and 9 is all a request span carries: there, a request that matched no endpoint (a static file) cannot be matched either. The span is dropped when it ends, so a child it had (a database check behind /health) is still exported, with a parent that never arrives. Empty by default.
An unhandled error has occurred. Reload 🗙