Published on

Part 1 — Blazor Server Observability with OpenTelemetry and SigNoz

Authors
  • avatar
    Name
    Konrad Bartecki
    Twitter

Part 1 — Blazor Server Observability with OpenTelemetry and SigNoz

How to actually see what your Blazor Server app is doing in production -- live circuits, stranded sessions, exhausted sockets, and your own business metrics -- using only open-source, self-hosted tools.

Part 1 of 4 in a series on observing a .NET system with OpenTelemetry and SigNoz (series index). This post lays the foundation -- the shared AddObservability() bootstrap, the Collector, and the Compose file -- that Part 2 and Part 3 reuse.

Blazor Server is easy to build with and easy to get wrong in production. The UI runs in the browser, but the components run on your server: one live, stateful connection per open tab. Monitor it like a normal web app and you miss what actually takes it down -- too many open circuits, sessions that never end, and connection pools running dry.

This post shows you how to fix that. By the end you'll have a Blazor Server app reporting traces, metrics, and logs to SigNoz (a free, self-hosted observability backend), and you'll know how to measure the Blazor-specific things that matter. Every file you need is included at the end of the post -- there's no repo to clone.

What you'll learn

  • Why Blazor Server needs different monitoring than a normal web app
  • How to turn on OpenTelemetry with one method call
  • How to count active circuits and measure how long user sessions last
  • How to catch outbound socket / connection-pool exhaustion before it hurts
  • How to add your own business metrics and custom spans
  • How to read it all in SigNoz, with screenshots from the real app

The stack, in one line: your app → OpenTelemetry Collector → SigNoz. It is all open source and runs on your own machine.

First, three words you'll see a lot

  • Circuit -- the live link between one browser tab and your server. Open a tab and Blazor creates a circuit on the server: it holds that user's UI state in memory plus a WebSocket (SignalR) connection. Close the tab and the circuit is eventually torn down. One open tab = one circuit = server memory in use.
  • Signal -- the three kinds of telemetry: traces (what happened, as a timeline), metrics (numbers over time), and logs (text events). OpenTelemetry handles all three.
  • OTLP -- OpenTelemetry's wire protocol. Your app speaks OTLP to a Collector, and the Collector forwards it to a backend like SigNoz. Because the app only knows about the Collector, you can switch backends without touching code.

Why Blazor Server is different

A normal web app is stateless: a request comes in, you answer it, you forget it. You watch request rate, latency, and errors and you're done.

Blazor Server breaks that model in three ways:

  1. Circuits are long-lived and stateful. The unit of load isn't the request -- it's the concurrent circuit. A thousand idle tabs is a thousand live object graphs sitting in your server's memory.
  2. Work happens on the server. Every click, every render, every DOM update runs server-side CPU on your thread pool. A slow component is your server's problem, not the browser's.
  3. Sockets matter at both ends. Inbound, each circuit is a Kestrel/SignalR connection -- run out and Kestrel starts rejecting clients. Outbound, when your circuits call an API through HttpClient, a saturated connection pool makes every "fast" page hang.

So the signals we want are: how many circuits are open, how many are actually connected, how long sessions last, and the health of connections in and out -- plus the usual traces and logs to tie it together.

What we're building

A small Blazor Server storefront (blazor-frontend) that talks to a backend API, with every signal flowing to SigNoz:

 browser tab ──(circuit)──▶  blazor-frontend  ──HTTP──▶  backend-api  ──▶  database
                                   │
                                   └── traces + metrics + logs (OTLP)
                                                 ▼
                                    OpenTelemetry Collector ──▶ SigNoz (UI on :8080)
ThingWhere
Blazor apphttp://localhost:5080
Backend APIhttp://localhost:5081
Collector (OTLP)localhost:5317 (gRPC) / 5318 (HTTP)
SigNoz UIhttp://localhost:8080

Step 0 -- Prerequisites

  • .NET SDK 10
  • Docker + Docker Compose
  • ~4 GB of free memory for Docker if you run SigNoz locally

Step 1 -- Get it running and see your services

First start SigNoz (one-time, self-hosted), then start the app + collector, then make some activity. All the files referenced here are in the appendix at the end of this post.

# 1. Start SigNoz (self-hosted). This also creates the "signoz-net" docker network
#    and a collector listening on signoz-otel-collector:4317.
git clone -b main https://github.com/SigNoz/signoz.git
cd signoz/deploy/docker && docker compose up -d        # UI at http://localhost:8080
cd -

# 2. Start the app + the OpenTelemetry Collector (docker-compose.yml from the appendix)
docker compose up -d --build

# 3. Make some activity
open http://localhost:5080            # then click around in a couple of tabs
curl http://localhost:5081/api/products

Open http://localhost:8080, go to Services, and you should see your app listed:

SigNoz's Services page. Every app that reports traces shows up as a row with its p99 latency, error rate, and ops/sec, computed automatically. (This capture is from the full series; running only this post you'll see blazor-frontend and backend-api.)

Tip: loading the page over HTTP (e.g. curl) isn't enough to create a circuit -- a real Blazor circuit only forms when a browser connects over WebSocket. So to see circuit and session data, open http://localhost:5080 in a few real browser tabs and click around.

Step 2 -- Turn on OpenTelemetry with one method

Everything funnels through one helper, AddObservability (full source in the appendix, ObservabilityExtensions.cs). It wires up traces, metrics, and logs to the collector and is generic, so it works for web apps and background workers alike:

public static TBuilder AddObservability<TBuilder>(
    this TBuilder builder,
    string defaultServiceName,
    Action<ObservabilityOptions>? configure = null)
    where TBuilder : IHostApplicationBuilder

In the Blazor app's Program.cs you call it and add the Blazor-specific meters by name:

builder.AddObservability("blazor-frontend", options =>
{
    options.ActivitySources.Add(FrontendTelemetry.ActivitySourceName);
    options.Meters.Add(FrontendTelemetry.MeterName);

    // .NET 10 built-in Blazor Server metrics
    options.Meters.Add("Microsoft.AspNetCore.Components");
    options.Meters.Add("Microsoft.AspNetCore.Components.Lifecycle");
    options.Meters.Add("Microsoft.AspNetCore.Components.Server.Circuits");
    // the SignalR transport circuits ride on
    options.Meters.Add("Microsoft.AspNetCore.Http.Connections");
});

The first two lines register our own telemetry: FrontendTelemetry is a small class we build in Step 3 that holds the app's custom spans and metrics -- here we're just pointing OpenTelemetry at its ActivitySource and Meter. The remaining lines switch on the built-in Blazor and SignalR meters.

What that buys you, for free, by name:

  • Microsoft.AspNetCore.Components.Server.Circuits (new in .NET 10) → aspnetcore.components.circuit.active, …connected, …duration
  • Microsoft.AspNetCore.Http.Connectionssignalr.server.active_connections, …connection.duration
  • Plus the Kestrel, HTTP-client, and .NET runtime meters that AddObservability turns on by default.

Good to know: on .NET 8+, for the built-in framework meters (Kestrel, Hosting, HTTP client), AddMeter("<name>") is all you need -- the metrics are baked into the framework. The OpenTelemetry.Instrumentation.* packages are mainly about the tracing side (and enrichment); the raw built-in metrics don't need them.

That is the setup. Everything below measures the things unique to Blazor.

Step 3 -- Count your circuits and time your sessions

We want two numbers and one histogram:

  • Active circuits -- opened but not yet closed (= server memory in use).
  • Connected circuits -- those with a live SignalR connection right now. The gap between active and connected is your count of stranded sessions: a user closed their laptop, but the circuit is still alive on your server. A disconnected circuit lingers until the server reaps it, three minutes by default (CircuitOptions.DisconnectedCircuitRetentionPeriod). That retention window is the ceiling session-duration p95 climbs toward.
  • Session duration -- how long each circuit lived.

FrontendTelemetry creates the metric instruments once and exposes one method per thing we record. Here is its shape; the full version, with business counters and an ActivitySource for custom spans, is in the appendix:

public sealed class FrontendTelemetry
{
    private readonly Meter _meter = new("Blazor.Frontend", "1.0.0");
    private readonly UpDownCounter<long> _activeCircuits;     // frontend.circuits.active
    private readonly UpDownCounter<long> _connectedCircuits;  // frontend.circuits.connected
    private readonly Histogram<double>   _sessionDuration;    // frontend.circuit.session.duration (seconds)

    public FrontendTelemetry()
    {
        _activeCircuits    = _meter.CreateUpDownCounter<long>("frontend.circuits.active");
        _connectedCircuits = _meter.CreateUpDownCounter<long>("frontend.circuits.connected");
        _sessionDuration   = _meter.CreateHistogram<double>("frontend.circuit.session.duration", unit: "s");
    }

    public void CircuitOpened()  => _activeCircuits.Add(+1);     // a circuit opened
    public void ConnectionUp()   => _connectedCircuits.Add(+1);
    public void ConnectionDown() => _connectedCircuits.Add(-1);

    public void CircuitClosed(TimeSpan lifetime)                 // a circuit closed: one fewer, and how long it lived
    {
        _activeCircuits.Add(-1);
        _sessionDuration.Record(lifetime.TotalSeconds);
    }
}

A metric instrument is created once and lives for the whole process, so we register FrontendTelemetry as a singleton -- one shared set of instruments for the entire app:

builder.Services.AddSingleton<FrontendTelemetry>();   // one shared set of instruments

The appendix FrontendTelemetry also exposes the count as an ObservableGauge (frontend.circuits.active_gauge) -- the pull-based pattern next to the push-based UpDownCounter. For circuits you'd chart the UpDownCounter, so the rest of the post uses frontend.circuits.active.

To turn circuit lifecycle events into those metrics, register a CircuitHandler as scoped, so .NET creates one instance per circuit. Each handler holds its own session state, with no dictionaries to manage, and reports into the shared FrontendTelemetry:

// Scoped => one handler instance per circuit.
builder.Services.AddScoped<CircuitHandler, CircuitTrackingCircuitHandler>();

The handler (full source in the appendix, CircuitTrackingCircuitHandler.cs) reacts to the four lifecycle events. On open it starts a stopwatch and bumps the count; on close it records the session length:

public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct)
{
    _startTimestamp = Stopwatch.GetTimestamp();
    telemetry.CircuitOpened();          // active +1
    logger.LogInformation("Circuit {CircuitId} opened", circuit.Id);
    return Task.CompletedTask;
}

public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken ct)
{
    if (_connectionUp) { _connectionUp = false; telemetry.ConnectionDown(); }  // keep "connected" balanced
    var lifetime = Stopwatch.GetElapsedTime(_startTimestamp);
    telemetry.CircuitClosed(lifetime);  // active -1, record session duration
    return Task.CompletedTask;
}

Why a custom handler if .NET 10 already has built-in circuit metrics? Two reasons: it works on older .NET versions, and it lets you attach your tags (tenant, plan, A/B group) and define "session" however your business does. In this app both run side by side, so the custom frontend.circuits.active and the built-in aspnetcore.components.circuit.active should track each other -- a nice sanity check.

The one gotcha: connection up/down can fire several times for one circuit (reconnects), and a circuit can close while still "connected." The if (_connectionUp) guards keep the connected counter from drifting, so it always returns to zero when everyone has left.

Step 4 -- Catch socket / connection-pool exhaustion

The classic outbound failure: your app fires lots of concurrent HTTP calls through a connection pool that's too small. Requests queue for a free connection, latency climbs, and CPU sits idle. You cannot see it unless you measure the pool itself, not just request time.

The demo reproduces this on purpose. In Program.cs we register a named HttpClient ("constrained") pointed at the backend (backendBaseUrl is just the API's base URL) with a deliberately tiny pool:

builder.Services.AddHttpClient("constrained", c => c.BaseAddress = new Uri(backendBaseUrl))
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        MaxConnectionsPerServer = 2,   // only 2 connections allowed
    });

The Socket load test page (http://localhost:5080/loadtest) fires N concurrent requests through that client at a slow endpoint (full source in the appendix, LoadTestService.cs). With 30 requests and a pool of 2, only two run at a time and the other 28 queue. In SigNoz, three metrics tell the story:

  • http.client.open_connections (tagged http.connection.state = active|idle) -- pins at 2, the ceiling.
  • http.client.request.time_in_queue -- climbs as requests wait for a free connection.
  • http.client.active_requests -- the in-flight + queued backlog.

On the inbound side, the Kestrel meter gives you the server's view: kestrel.active_connections and -- the one that means you've hit a hard wall -- kestrel.rejected_connections. The app caps it at a low MaxConcurrentConnections of 20; flood the server past that and these two metrics show it:

Two SigNoz dashboard panels, captured during a connection flood. Left, kestrel.active_connections pins at the cap of 20; right, kestrel.rejected_connections spikes to ~13/s as every extra connection is refused, then both fall back to zero. A rising rejected-connections line is the clearest "turning users away" signal to alert on (Step 7).

You can confirm every one of these is being collected in the Metrics Explorer:

SigNoz's Metrics Explorer, which lists every metric being ingested. Use it to confirm the meters arrived: the built-in aspnetcore.components.* (circuits), http.client.* (sockets) and kestrel.*, alongside our custom frontend.* and backend.db.query.*.

Step 5 -- Add your own business metrics and spans

Frameworks tell you about plumbing; you still need business signals. FrontendTelemetry defines a couple of counters:

public void RecordProductView() => _productViews.Add(1);                       // frontend.product.views
public void RecordPurchaseClick(string outcome)                                // frontend.purchase.clicks
    => _purchaseClicks.Add(1, new TagList { { "outcome", outcome } });

These are recorded where the work happens -- in the typed BackendApiClient (full source in the appendix), which also wraps each call in a custom span so you get business context around the automatic HTTP span:

public async Task<IReadOnlyList<ProductDto>> GetProductsAsync(CancellationToken ct = default)
{
    using var activity = telemetry.StartActivity("frontend.load-products");
    var products = await httpClient.GetFromJsonAsync<List<ProductDto>>("/api/products", ct) ?? [];
    telemetry.RecordProductView();
    activity?.SetTag("app.product.count", products.Count);
    return products;
}

Because the call goes through an instrumented HttpClient, .NET automatically creates a client span underneath your frontend.load-products span and forwards the trace context to the backend. The result is a single end-to-end trace that starts at the GET /products request on the Blazor frontend and ends at the database:

A single trace opened in SigNoz, drawn as a waterfall. One click in the Blazor app becomes six nested spans across two services: GET /productsfrontend.load-products → the HttpClient GETbackend-apiproducts.list → the postgresql query.

Those counters are only useful once you can see them. A few minutes of real traffic later, here is a dashboard built entirely from the business instruments in this app (and the C# backend from Part 2):

A SigNoz dashboard built entirely from this app's own metrics. Five panels: product views and revenue over time, purchase clicks by outcome, backend purchases by success, and query latency p95 by query.name.

Building one of these panels takes about thirty seconds. Here is the editor for the "Purchase clicks by outcome" panel above:

SigNoz's panel editor -- how every chart above is built. Here frontend.purchase.clicks is aggregated as Rate (per-second) and Sum, grouped by outcome, the chip that splits the line into success and out_of_stock.

The same four moves build every panel:

  1. Dashboards → New dashboard → New panel → Time series.
  2. Pick the metric in the query builder, e.g. frontend.purchase.clicks. For a counter, leave the time aggregation on Rate.
  3. Group by a tag to split the line: add outcome for one series per outcome, and set the legend format to {{outcome}} so the legend reads success / out_of_stock instead of the full label set.
  4. For a histogram like frontend.circuit.session.duration, set the space aggregation to p95 to chart the session-duration percentile (that is the session panel in the dashboard further down). The same move works for any .bucket metric -- e.g. backend.db.query.duration grouped by query.name, once you add the C# backend in Part 2.

A panel can pull from any service, so one board can mix frontend.* and backend.* instruments.

Logs come for free from AddObservability, which configures the OpenTelemetry logging provider with structured properties and scopes. You get automatic correlation: any log written while a span is active is stamped with that span's trace_id and span_id.

So your circuit log lines --

Circuit abc123 opened
Circuit abc123 connection down (client disconnected)
Circuit abc123 closed after 142.0s

-- land in SigNoz already linked to the trace and span that produced them.

SigNoz's Logs Explorer, filtered to service.name = blazor-frontend. The circuit log lines tell the story at a glance: a closed after 305s row means one session held its circuit, and its memory, open for minutes. Each row carries a trace_id and CircuitId, so one click pivots to the trace.

See it all in SigNoz

With the stack up and a few tabs open at http://localhost:5080, the Services page already lists blazor-frontend with its RED metrics. The Blazor-specific story lives on a dashboard -- four panels, each built in the editor exactly like the business panel in Step 5 (pick the metric, set the aggregation, group by a tag):

  • Live circuits -- frontend.circuits.active overlaid with the built-in aspnetcore.components.circuit.active; they should track.
  • Active vs connected -- add frontend.circuits.connected; the gap between the lines is your stranded-session count.
  • Session duration p95 -- frontend.circuit.session.duration; a rising line means circuits, and memory, are held longer.
  • Outbound pool -- http.client.open_connections grouped by http.connection.state, plus http.client.request.time_in_queue; run /loadtest and watch queue time spike as the pool drains two at a time.

The Blazor dashboard: the four panels from this section side by side. Top-left, custom frontend.circuits.active overlaps the built-in metric so closely only one line shows; top-right, the active-vs-connected gap is your stranded sessions; bottom-left, session p95 climbs toward the reap ceiling; bottom-right, pool and backlog rise together under the load test.

The fifth view costs nothing extra: open any trace, jump to its logs, and the same trace_id is on both (Step 6).

Step 7 -- Alert before it hurts

A dashboard only helps when someone is looking at it; an alert tells you while you are doing something else. Every metric in this post is a candidate:

  • Live circuits climbing (frontend.circuits.active above a ceiling) -- a leak, a bot, or a launch you did not plan for.
  • Stranded sessions (active minus connected staying high) -- clients dropping but circuits not being reaped, quietly eating memory.
  • Pool pressure (http.client.request.time_in_queue p95 rising, or kestrel.rejected_connections above zero) -- you are about to start refusing work.

An alert in SigNoz is just a saved query plus a threshold, built in the same query builder as a dashboard panel. Go to Alerts → New Alert → Metric-based and fill in three sections:

SigNoz's alert builder -- the same query builder as a dashboard panel, plus a threshold. Here frontend.circuits.active is aggregated with Max (the peak, not the average), firing when it goes above 5 at least once in 5 minutes. The chart previews the query against the threshold line as you type.

The third section, Alert Configuration, is where you set the Severity (warning vs critical), a name, and the notification channels. Channels are a separate one-time setup under Alerts → Configuration -- point one at Slack, email, PagerDuty, or a webhook, and every rule can reuse it. Save, and the rule evaluates on its own. Here it is a minute later, firing because the count sat at 8 against the threshold of 5:

The alert's detail view after it triggered. frontend.circuits.active over 30 minutes against the dashed threshold at 5; the moment it crosses (here, eight held-open circuits) the rule flips to Firing and notifies your channels.

Cheat sheet -- the signals this app produces

You want to know…Look at
How many tabs/circuits are openfrontend.circuits.active / aspnetcore.components.circuit.active
How many users are actually connectedfrontend.circuits.connected / aspnetcore.components.circuit.connected
Stranded sessionsactive minus connected
How long sessions lastfrontend.circuit.session.duration (p50/p95)
Outbound pool exhaustionhttp.client.request.time_in_queue, http.client.open_connections
Inbound limits hitkestrel.rejected_connections
Business activityfrontend.product.views, frontend.purchase.clicks

Wrapping up

One AddObservability() call gave you traces, metrics, and logs. A CircuitHandler and a handful of instruments turned the Blazor-specific risks -- runaway circuits, stranded sessions, drained connection pools -- into numbers you can chart. Your own counters became a business dashboard, and a single threshold turned that dashboard into an alert that pages you before users notice. (One production note: this setup exports every trace, which is fine to start with; when volume grows you add a sampler, which Part 2 covers.)

That is the frontend covered. The same AddObservability() foundation carries straight into the rest of the system: Part 2 follows a request from this Blazor app into the C# API and down to the exact SQL query as one trace, and Part 3 brings your background jobs -- even the ones with no SDK -- into the same SigNoz. The full series is on the index page.


The complete code

Everything you need to reproduce this post. Create the files at the paths shown. This is Part 1 of a series, so it also carries the shared foundation (the OpenTelemetry bootstrap, the Collector config, the Compose file, and the SigNoz install) that Parts 2 and 3 reuse.

NuGet packages

The Blazor app references a small shared library (Shared.Telemetry, below), and that library references the OpenTelemetry packages:

<!-- in Shared.Telemetry.csproj -->
<ItemGroup>
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
  <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
  <PackageReference Include="OpenTelemetry.Extensions.Hosting"            Version="1.15.3" />
  <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore"    Version="1.15.2" />
  <PackageReference Include="OpenTelemetry.Instrumentation.Http"          Version="1.15.1" />
  <PackageReference Include="OpenTelemetry.Instrumentation.Runtime"       Version="1.15.1" />
</ItemGroup>

The Blazor project is a standard Microsoft.NET.Sdk.Web app targeting net10.0, with <ProjectReference Include="...\Shared.Telemetry\Shared.Telemetry.csproj" />.

shared/Shared.Telemetry/ObservabilityExtensions.cs

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace Shared.Telemetry;

/// <summary>
/// One OpenTelemetry bootstrap shared by every .NET service. It wires traces, metrics, and logs
/// to a single OTLP endpoint (a vendor-neutral OpenTelemetry Collector), so SigNoz -- or any other
/// backend -- is selected in collector config, never in application code.
/// </summary>
public static class ObservabilityExtensions
{
    /// <summary>The logical system these services belong to. Becomes <c>service.namespace</c>.</summary>
    public const string ServiceNamespace = "blazor-signoz";

    /// <summary>
    /// Configures OpenTelemetry on any host builder (<c>WebApplicationBuilder</c> and
    /// <c>HostApplicationBuilder</c> both implement <see cref="IHostApplicationBuilder"/>).
    /// </summary>
    public static TBuilder AddObservability<TBuilder>(
        this TBuilder builder,
        string defaultServiceName,
        Action<ObservabilityOptions>? configure = null)
        where TBuilder : IHostApplicationBuilder
    {
        var options = new ObservabilityOptions();
        configure?.Invoke(options);

        var serviceName = builder.Configuration["OTEL_SERVICE_NAME"] ?? defaultServiceName;
        var serviceVersion = typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString() ?? "1.0.0";

        var resource = ResourceBuilder.CreateDefault()
            .AddService(serviceName: serviceName, serviceVersion: serviceVersion)
            .AddAttributes(new Dictionary<string, object>
            {
                ["service.namespace"] = ServiceNamespace,
                ["service.instance.id"] = Environment.MachineName,
                ["deployment.environment"] = builder.Environment.EnvironmentName,
            });

        var hasOtlp = TryReadOtlp(builder.Configuration, out var endpoint, out var protocol);

        builder.Logging.AddOpenTelemetry(logging =>
        {
            logging.IncludeFormattedMessage = true;
            logging.IncludeScopes = true;
            logging.ParseStateValues = true;
            logging.SetResourceBuilder(resource);

            if (hasOtlp)
            {
                logging.AddOtlpExporter(o => ApplyOtlp(o, endpoint!, protocol));
            }
        });

        builder.Services.AddOpenTelemetry()
            .ConfigureResource(r => r
                .AddService(serviceName: serviceName, serviceVersion: serviceVersion)
                .AddAttributes(new Dictionary<string, object>
                {
                    ["service.namespace"] = ServiceNamespace,
                    ["service.instance.id"] = Environment.MachineName,
                    ["deployment.environment"] = builder.Environment.EnvironmentName,
                }))
            .WithTracing(tracing =>
            {
                foreach (var source in options.ActivitySources)
                {
                    tracing.AddSource(source);
                }

                if (options.InstrumentAspNetCore)
                {
                    tracing.AddAspNetCoreInstrumentation(o => o.RecordException = true);
                }

                if (options.InstrumentHttpClient)
                {
                    tracing.AddHttpClientInstrumentation();
                }

                options.ConfigureTracerProvider?.Invoke(tracing);

                if (hasOtlp)
                {
                    tracing.AddOtlpExporter(o => ApplyOtlp(o, endpoint!, protocol));
                }
            })
            .WithMetrics(metrics =>
            {
                foreach (var meter in options.Meters)
                {
                    metrics.AddMeter(meter);
                }

                if (options.InstrumentAspNetCore)
                {
                    metrics.AddAspNetCoreInstrumentation();
                    metrics.AddMeter("Microsoft.AspNetCore.Server.Kestrel");
                    metrics.AddMeter("Microsoft.AspNetCore.Hosting");
                }

                if (options.InstrumentHttpClient)
                {
                    metrics.AddHttpClientInstrumentation();
                    metrics.AddMeter("System.Net.Http");
                    metrics.AddMeter("System.Net.NameResolution");
                }

                if (options.InstrumentRuntime)
                {
                    metrics.AddRuntimeInstrumentation();
                }

                options.ConfigureMeterProvider?.Invoke(metrics);

                if (hasOtlp)
                {
                    metrics.AddOtlpExporter(o => ApplyOtlp(o, endpoint!, protocol));
                }
            });

        return builder;
    }

    private static bool TryReadOtlp(IConfiguration config, out Uri? endpoint, out OtlpExportProtocol protocol)
    {
        endpoint = null;
        protocol = OtlpExportProtocol.Grpc;

        var raw = config["OTEL_EXPORTER_OTLP_ENDPOINT"];
        if (string.IsNullOrWhiteSpace(raw))
        {
            return false;   // no endpoint -> app still runs, just doesn't ship telemetry
        }

        endpoint = new Uri(raw, UriKind.Absolute);
        protocol = config["OTEL_EXPORTER_OTLP_PROTOCOL"]?.Trim().ToLowerInvariant() switch
        {
            "http/protobuf" or "httpprotobuf" or "http" => OtlpExportProtocol.HttpProtobuf,
            _ => OtlpExportProtocol.Grpc,
        };

        return true;
    }

    private static void ApplyOtlp(OtlpExporterOptions o, Uri endpoint, OtlpExportProtocol protocol)
    {
        o.Endpoint = endpoint;
        o.Protocol = protocol;
    }
}

shared/Shared.Telemetry/ObservabilityOptions.cs

using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

namespace Shared.Telemetry;

/// <summary>Per-application knobs for AddObservability. Keeps the shared bootstrap generic and
/// driver-neutral while each service adds its own sources/meters and DB instrumentation.</summary>
public sealed class ObservabilityOptions
{
    public List<string> ActivitySources { get; } = [];
    public List<string> Meters { get; } = [];

    public bool InstrumentAspNetCore { get; set; } = true;
    public bool InstrumentHttpClient { get; set; } = true;
    public bool InstrumentRuntime { get; set; } = true;

    /// <summary>Add provider-specific tracing, e.g. AddNpgsql() / AddSqlClientInstrumentation().</summary>
    public Action<TracerProviderBuilder>? ConfigureTracerProvider { get; set; }

    /// <summary>Add provider-specific metrics, e.g. AddNpgsqlInstrumentation().</summary>
    public Action<MeterProviderBuilder>? ConfigureMeterProvider { get; set; }
}

src/Blazor.Frontend/Telemetry/FrontendTelemetry.cs

using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace Blazor.Frontend.Telemetry;

/// <summary>
/// Custom traces and metrics for the Blazor Server frontend. .NET 10 also ships built-in circuit
/// metrics (meter Microsoft.AspNetCore.Components.Server.Circuits); these custom instruments
/// demonstrate the CircuitHandler approach and let you attach your own tags. The two track each other.
/// </summary>
public sealed class FrontendTelemetry : IDisposable
{
    public const string ActivitySourceName = "Blazor.Frontend";
    public const string MeterName = "Blazor.Frontend";

    public static readonly ActivitySource ActivitySource = new(ActivitySourceName);

    private readonly Meter _meter = new(MeterName, "1.0.0");

    private readonly UpDownCounter<long> _activeCircuits;
    private readonly UpDownCounter<long> _connectedCircuits;
    private readonly Histogram<double> _sessionDuration;
    private readonly Counter<long> _productViews;
    private readonly Counter<long> _purchaseClicks;
    private readonly Counter<long> _loadTestRequests;

    private long _activeCircuitCount;

    public FrontendTelemetry()
    {
        _activeCircuits = _meter.CreateUpDownCounter<long>(
            "frontend.circuits.active", unit: "{circuit}",
            description: "Active Blazor circuits (opened, not yet closed), tracked via CircuitHandler.");

        _connectedCircuits = _meter.CreateUpDownCounter<long>(
            "frontend.circuits.connected", unit: "{circuit}",
            description: "Circuits whose SignalR connection is currently up.");

        _sessionDuration = _meter.CreateHistogram<double>(
            "frontend.circuit.session.duration", unit: "s",
            description: "Lifetime of a Blazor circuit (a user session) in seconds.");

        _productViews = _meter.CreateCounter<long>(
            "frontend.product.views", unit: "{view}",
            description: "Number of times the product catalog was loaded.");

        _purchaseClicks = _meter.CreateCounter<long>(
            "frontend.purchase.clicks", unit: "{click}",
            description: "Purchase button clicks, tagged by outcome.");

        _loadTestRequests = _meter.CreateCounter<long>(
            "frontend.loadtest.requests", unit: "{request}",
            description: "Outbound requests issued by the socket-exhaustion load test, tagged by outcome.");

        // Same active count as an asynchronous gauge -- shows the observable-instrument pattern.
        _meter.CreateObservableGauge(
            "frontend.circuits.active_gauge",
            () => Interlocked.Read(ref _activeCircuitCount),
            unit: "{circuit}",
            description: "Active Blazor circuits, reported as an observable gauge.");
    }

    public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal)
        => ActivitySource.StartActivity(name, kind);

    public void CircuitOpened()
    {
        _activeCircuits.Add(1);
        Interlocked.Increment(ref _activeCircuitCount);
    }

    public void CircuitClosed(TimeSpan lifetime)
    {
        _activeCircuits.Add(-1);
        Interlocked.Decrement(ref _activeCircuitCount);
        _sessionDuration.Record(lifetime.TotalSeconds);
    }

    public void ConnectionUp() => _connectedCircuits.Add(1);
    public void ConnectionDown() => _connectedCircuits.Add(-1);
    public void RecordProductView() => _productViews.Add(1);

    public void RecordPurchaseClick(string outcome)
        => _purchaseClicks.Add(1, new TagList { { "outcome", outcome } });

    public void RecordLoadTestRequest(string outcome)
        => _loadTestRequests.Add(1, new TagList { { "outcome", outcome } });

    public void Dispose() => _meter.Dispose();
}

src/Blazor.Frontend/Telemetry/CircuitTrackingCircuitHandler.cs

using Microsoft.AspNetCore.Components.Server.Circuits;

namespace Blazor.Frontend.Telemetry;

/// <summary>
/// A CircuitHandler registered as scoped, so the DI container creates one instance per circuit.
/// It feeds circuit lifecycle events into FrontendTelemetry: active count, connected count, and
/// per-session duration. The canonical way to "count circuits" and "measure session duration".
/// </summary>
public sealed class CircuitTrackingCircuitHandler(
    FrontendTelemetry telemetry,
    ILogger<CircuitTrackingCircuitHandler> logger) : CircuitHandler
{
    private long _startTimestamp;
    private bool _connectionUp;

    public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        _startTimestamp = System.Diagnostics.Stopwatch.GetTimestamp();
        telemetry.CircuitOpened();
        logger.LogInformation("Circuit {CircuitId} opened", circuit.Id);
        return Task.CompletedTask;
    }

    public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        if (!_connectionUp)
        {
            _connectionUp = true;
            telemetry.ConnectionUp();
        }

        logger.LogDebug("Circuit {CircuitId} connection up", circuit.Id);
        return Task.CompletedTask;
    }

    public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        if (_connectionUp)
        {
            _connectionUp = false;
            telemetry.ConnectionDown();
        }

        logger.LogInformation("Circuit {CircuitId} connection down (client disconnected)", circuit.Id);
        return Task.CompletedTask;
    }

    public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        // The connection may still be "up" on a graceful close; balance the connected counter.
        if (_connectionUp)
        {
            _connectionUp = false;
            telemetry.ConnectionDown();
        }

        var lifetime = System.Diagnostics.Stopwatch.GetElapsedTime(_startTimestamp);
        telemetry.CircuitClosed(lifetime);
        logger.LogInformation(
            "Circuit {CircuitId} closed after {SessionSeconds:F1}s", circuit.Id, lifetime.TotalSeconds);
        return Task.CompletedTask;
    }
}

src/Blazor.Frontend/Services/LoadTestService.cs

using System.Diagnostics;
using Blazor.Frontend.Telemetry;

namespace Blazor.Frontend.Services;

/// <summary>
/// Fires N concurrent requests through the "constrained" HttpClient (a SocketsHttpHandler capped at
/// MaxConnectionsPerServer = 2). Extra requests queue for a connection, which surfaces as a rising
/// http.client.request.time_in_queue and a pinned http.client.open_connections.
/// </summary>
public sealed class LoadTestService(
    IHttpClientFactory httpClientFactory,
    FrontendTelemetry telemetry,
    ILogger<LoadTestService> logger)
{
    public async Task<LoadTestResult> RunAsync(int concurrency, int delayMs, CancellationToken ct = default)
    {
        concurrency = Math.Clamp(concurrency, 1, 200);
        delayMs = Math.Clamp(delayMs, 0, 5000);

        using var activity = telemetry.StartActivity("frontend.loadtest", ActivityKind.Internal);
        activity?.SetTag("loadtest.concurrency", concurrency);
        activity?.SetTag("loadtest.delay_ms", delayMs);

        var client = httpClientFactory.CreateClient("constrained");
        var overall = Stopwatch.StartNew();

        var tasks = Enumerable.Range(0, concurrency).Select(async _ =>
        {
            var requestSw = Stopwatch.StartNew();
            try
            {
                using var response = await client.GetAsync($"/api/diagnostics/slow?ms={delayMs}", ct);
                response.EnsureSuccessStatusCode();
                telemetry.RecordLoadTestRequest("success");
            }
            catch (Exception ex)
            {
                telemetry.RecordLoadTestRequest("error");
                logger.LogWarning(ex, "Load-test request failed");
            }

            return requestSw.Elapsed.TotalMilliseconds;
        });

        var durations = await Task.WhenAll(tasks);
        overall.Stop();

        return new LoadTestResult(
            concurrency, delayMs, overall.Elapsed.TotalMilliseconds,
            durations.Min(), durations.Max(), durations.Average());
    }
}

public sealed record LoadTestResult(int Concurrency, int DelayMs, double TotalMs, double MinMs, double MaxMs, double AvgMs);

src/Blazor.Frontend/Services/BackendApiClient.cs

using System.Net.Http.Json;
using Blazor.Frontend.Telemetry;

namespace Blazor.Frontend.Services;

// Client-side shapes matching the backend's JSON. (In a real app, share these via a package.)
public sealed record ProductDto(Guid Id, string Name, string? Description, int Quantity, decimal Price);
public sealed record PurchaseResult(bool Success, string? Error, ProductDto? Product);

/// <summary>
/// Typed client over the backend API. Every call goes through the framework-instrumented HttpClient,
/// so a CLIENT span is created automatically and W3C trace context propagates to the backend -- that
/// is what produces the Blazor → API → database distributed trace.
/// </summary>
public sealed class BackendApiClient(
    HttpClient httpClient,
    FrontendTelemetry telemetry,
    ILogger<BackendApiClient> logger)
{
    public async Task<IReadOnlyList<ProductDto>> GetProductsAsync(CancellationToken ct = default)
    {
        using var activity = telemetry.StartActivity("frontend.load-products");
        var products = await httpClient.GetFromJsonAsync<List<ProductDto>>("/api/products", ct) ?? [];
        telemetry.RecordProductView();
        activity?.SetTag("app.product.count", products.Count);
        logger.LogInformation("Loaded {ProductCount} products from backend", products.Count);
        return products;
    }

    public async Task<PurchaseResult> PurchaseAsync(Guid productId, int quantity, CancellationToken ct = default)
    {
        using var activity = telemetry.StartActivity("frontend.purchase");
        activity?.SetTag("app.product.id", productId);
        activity?.SetTag("app.purchase.quantity", quantity);

        var response = await httpClient.PostAsJsonAsync($"/api/products/{productId}/purchase", new { quantity }, ct);
        if (response.IsSuccessStatusCode)
        {
            var product = await response.Content.ReadFromJsonAsync<ProductDto>(ct);
            telemetry.RecordPurchaseClick("success");
            return new PurchaseResult(true, null, product);
        }

        telemetry.RecordPurchaseClick(response.StatusCode == System.Net.HttpStatusCode.Conflict ? "out_of_stock" : "error");
        activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, $"HTTP {(int)response.StatusCode}");
        return new PurchaseResult(false, $"HTTP {(int)response.StatusCode}", null);
    }
}

src/Blazor.Frontend/Program.cs

using Blazor.Frontend.Components;
using Blazor.Frontend.Services;
using Blazor.Frontend.Telemetry;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Shared.Telemetry;

var builder = WebApplication.CreateBuilder(args);

// A deliberately low inbound limit so flooding the server past it makes
// kestrel.rejected_connections climb (Step 4). Raise or remove in production.
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxConcurrentConnections = 20);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddSingleton<FrontendTelemetry>();

// Scoped => one handler instance per circuit, which is how we count circuits and time sessions.
builder.Services.AddScoped<CircuitHandler, CircuitTrackingCircuitHandler>();

var backendBaseUrl = builder.Configuration["Backend:BaseUrl"] ?? "http://localhost:5081";

builder.Services.AddHttpClient<BackendApiClient>(client => client.BaseAddress = new Uri(backendBaseUrl));

// A deliberately tiny connection pool so the load-test page can saturate it and surface
// http.client.open_connections / http.client.request.time_in_queue in SigNoz.
builder.Services.AddHttpClient("constrained", client => client.BaseAddress = new Uri(backendBaseUrl))
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        MaxConnectionsPerServer = 2,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    });

builder.Services.AddSingleton<LoadTestService>();

builder.AddObservability("blazor-frontend", options =>
{
    options.ActivitySources.Add(FrontendTelemetry.ActivitySourceName);
    options.Meters.Add(FrontendTelemetry.MeterName);

    options.Meters.Add("Microsoft.AspNetCore.Components");
    options.Meters.Add("Microsoft.AspNetCore.Components.Lifecycle");
    options.Meters.Add("Microsoft.AspNetCore.Components.Server.Circuits");
    options.Meters.Add("Microsoft.AspNetCore.Http.Connections");
});

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseAntiforgery();

// MapStaticAssets (not UseStaticFiles) serves the framework's static web assets, including
// blazor.web.js. Skip it and a published app still starts, but the circuit never connects.
app.MapStaticAssets();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

The Razor components themselves (Products.razor, LoadTest.razor, etc.) are ordinary Blazor UI -- they just call BackendApiClient and LoadTestService. Drop the telemetry files above into your Blazor app and you're done; the UI is yours.

observability/otel-collector.yaml

The Collector receives OTLP from the app and forwards it to SigNoz. (It also tails .txt files -- that's Part 3; harmless here.)

receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
  filelog/legacy:
    include: [/var/log/legacy/*.txt]
    start_at: beginning
    multiline:
      line_start_pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
    operators:
      - type: regex_parser
        regex: '(?s)^(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(?P<sev>\w+)\] (?P<msg>.*)$'
        timestamp: { parse_from: attributes.ts, layout: '%Y-%m-%d %H:%M:%S' }
        severity: { parse_from: attributes.sev }
      - type: move
        from: attributes.msg
        to: body

processors:
  batch: {}
  resource/legacy:
    attributes:
      - { key: service.name, value: legacy-batch-job, action: upsert }
      - { key: service.namespace, value: blazor-signoz, action: upsert }

exporters:
  debug: { verbosity: normal }
  otlp/signoz:
    endpoint: signoz-otel-collector:4317 # SigNoz's own collector, reached over signoz-net
    tls: { insecure: true }

service:
  pipelines:
    traces: { receivers: [otlp], processors: [batch], exporters: [debug, otlp/signoz] }
    metrics: { receivers: [otlp], processors: [batch], exporters: [debug, otlp/signoz] }
    logs/otlp: { receivers: [otlp], processors: [batch], exporters: [debug, otlp/signoz] }
    logs/filelog:
      {
        receivers: [filelog/legacy],
        processors: [resource/legacy, batch],
        exporters: [debug, otlp/signoz],
      }

docker-compose.yml

Runs the apps + the Collector and joins SigNoz's signoz-net network so the Collector can forward to it. (.NET services build from a standard multi-stage Dockerfile -- one shown after this.)

name: blazor-signoz

services:
  postgres:
    image: postgres:16-alpine
    environment: { POSTGRES_DB: blazorsignoz, POSTGRES_USER: app, POSTGRES_PASSWORD: app }
    ports: ['15440:5432']
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app -d blazorsignoz']
      interval: 5s
      retries: 20
    networks: [blazorsignoz]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.152.0
    command: ['--config=/etc/otelcol-contrib/config.yaml']
    volumes:
      - ./observability/otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro
      - job-logs:/var/log/legacy:ro
    ports: ['5317:4317', '5318:4318']
    networks: [blazorsignoz, signoz-net] # signoz-net is created by SigNoz

  backend-api: # code in Part 2
    build: { context: ., dockerfile: src/Backend.Api/Dockerfile }
    environment:
      ASPNETCORE_URLS: http://+:8080
      Database__Provider: Postgres
      ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=blazorsignoz;Username=app;Password=app
      OTEL_SERVICE_NAME: backend-api
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
      OTEL_METRIC_EXPORT_INTERVAL: '5000'
    ports: ['5081:8080']
    depends_on:
      { postgres: { condition: service_healthy }, otel-collector: { condition: service_started } }
    networks: [blazorsignoz]

  blazor-frontend:
    build: { context: ., dockerfile: src/Blazor.Frontend/Dockerfile }
    environment:
      ASPNETCORE_URLS: http://+:8080
      Backend__BaseUrl: http://backend-api:8080
      OTEL_SERVICE_NAME: blazor-frontend
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
      OTEL_METRIC_EXPORT_INTERVAL: '5000'
    ports: ['5080:8080']
    depends_on: [backend-api, otel-collector]
    networks: [blazorsignoz]

networks:
  blazorsignoz: { name: blazorsignoz }
  signoz-net: { external: true } # created by `docker compose up` in signoz/deploy/docker

volumes:
  postgres-data:
  job-logs:

(Parts 2 and 3 add the worker-jobs, legacy-job, and python-job services to this same file.)

src/Blazor.Frontend/Dockerfile

All three .NET services use this pattern -- just change the project path.

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY shared/Shared.Telemetry/Shared.Telemetry.csproj shared/Shared.Telemetry/
COPY src/Blazor.Frontend/Blazor.Frontend.csproj src/Blazor.Frontend/
RUN dotnet restore src/Blazor.Frontend/Blazor.Frontend.csproj
COPY . .
RUN dotnet publish src/Blazor.Frontend/Blazor.Frontend.csproj -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Blazor.Frontend.dll"]

Next

This trace started on a Blazor circuit and crossed into the backend. Part 2 -- Full-stack observability for a C# API with Postgres/SQL Server follows it the rest of the way into the database. Part 3 -- On-prem observability for background jobs brings non-.NET jobs (a legacy text-log job, PowerShell, Python) into the same SigNoz. Part 4 -- Instrument now, collect later is the .NET instrumentation underneath all of it.

💼Open for consulting

I take on consulting and delivery work across .NET and React — on my own or alongside a trusted group of senior engineers I work with. Together we can build, untangle and modernize your software:

  • Building ASP.NET / Blazor / C# / WPF apps with Postgres / ClickHouse
  • Untangling, refactoring & modernizing legacy ASP.NET, C#, Blazor and WPF into a modern stack (modular monolith C# + React)
  • Cloud & on-premise DevOps: Azure DevOps, CI/CD pipelines and automation
  • Observability & analytics — in the cloud and on-premise
  • On-premise migrations
  • Scaling up delivery with experienced .NET, backend and React engineers, plus technical leadership