Published on

Blazor Server Observability with OpenTelemetry and SigNoz

Authors
  • avatar
    Name
    Konrad Bartecki
    Twitter

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 3 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 wonderful to build with and surprisingly 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'll miss the things that actually take it down: too many open circuits, sessions that never end, and connection pools quietly 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. Everything is open source and runs on your own machine -- no SaaS, no per-seat pricing, no data leaving your network.

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:

The Services page, with p99 latency, error rate, and operations per second derived automatically from traces -- you don't configure any of it. (This capture is from the full series, so it lists the backend and the Part 3 jobs too; running just 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's the whole setup. Everything below is about measuring the things that are unique to Blazor.

Step 3 -- Count your circuits and time your sessions

This is the headline feature. We want two numbers and one histogram:

  • Active circuits -- opened but not yet closed (= server memory in use).
  • Connected circuits -- currently have a live SignalR connection. The gap between active and connected is your count of stranded sessions (a user who closed their laptop lid but whose circuit is still alive on your server). The server reaps a disconnected circuit after CircuitOptions.DisconnectedCircuitRetentionPeriod (3 minutes by default); if stranded counts and memory worry you, lower it (and DisconnectedCircuitMaxRetained) in AddInteractiveServerComponents.
  • Session duration -- how long each circuit lived.

First, the class that owns the instruments. FrontendTelemetry creates the metric instruments once and exposes one tiny method per thing we want to record. Here's its shape -- the full version (with a couple of 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 copy of FrontendTelemetry also exposes the active count a second way, as an ObservableGauge (frontend.circuits.active_gauge) whose callback reads the current value on each collection. It is there to show the pull-based gauge pattern next to the push-based UpDownCounter; for circuits the UpDownCounter is what you would actually chart, so the rest of the post uses frontend.circuits.active.

Then, the thing that feeds it. To turn circuit lifecycle events into those metrics, we use a CircuitHandler registered as scoped, so .NET creates one instance per circuit. Each per-circuit handler holds that session's own state (no dictionaries to manage) and reports into the single shared FrontendTelemetry:

// Scoped => one handler instance per circuit. This is the whole trick.
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 waiting for a free connection, latency explodes, and CPU sits idle -- and it's invisible unless you measure the pool, 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 -- the smoking gun; 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. Set KestrelServerLimits.MaxConcurrentConnections and flood the server past it, and the two metrics tell the whole story:

Inbound saturation, captured against a deliberately low MaxConcurrentConnections of 20. Left: kestrel.active_connections flatlines at the ceiling of 20 -- the server will not hold more at once. Right: every connection over that ceiling is refused, so kestrel.rejected_connections climbs (here past 10/s). A rising kestrel.rejected_connections is the unambiguous "I am turning users away" signal, and a perfect thing to alert on (Step 7).

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

_The built-in aspnetcore.components._(circuits),http.client._(outbound sockets),kestrel._, and our custom frontend._/backend.db.query._ instruments are all flowing in.*

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 on the Blazor circuit and ends at the database:

One click in the Blazor app as a single trace: GET /products (blazor-frontend) → the custom frontend.load-products span → the auto-instrumented HttpClient GETbackend-apiproducts.list → the postgresql database span. Six spans, two services -- all from one user action.

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):

Five business panels on one board. Product views and revenue over time; purchase clicks split by outcome (success vs out_of_stock); backend purchases split by success; and database query latency (p95) split by query.name so a slow products.search stands out from a fast products.list. None of these are framework metrics -- they are the counters and histograms you added.

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

The panel editor. The query builder at the bottom is the whole story: the Metrics row holds frontend.purchase.clicks; aggregate within time series is Rate (SigNoz picks this for a counter, turning a monotonic total into a readable per-second line); aggregate across time series is Sum, grouped by outcome, which is the chip that splits the one line into success and out_of_stock. The preview up top updates live, and on the right you name the panel and pick the visualization. Save and it lands on the dashboard.

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 a single board can mix frontend.* and backend.* instruments. Save it once and it is your business view for every future incident.

Logs come for free from AddObservability, which configures the OpenTelemetry logging provider with structured properties and scopes. The payoff is 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. No grepping, no guessing.

The Logs Explorer filtered to service.name = blazor-frontend. The whole circuit lifecycle is here: every "opened", "connection down (client disconnected)", and "closed after Ns" line. The mix of long closed after 305s rows next to ones that closed in seconds is the story at a glance: a handful of sessions held their circuit (and its server memory) open for minutes, while most came and went quickly. Open any row and it carries the trace_id, CircuitId, and ConnectionId it was logged with, so one click pivots from a log line to the full trace that produced it.

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.

One dashboard, four panels. Top-left, live circuits: our custom frontend.circuits.active sits exactly on top of the built-in aspnetcore.components.circuit.active, which is how you confirm the custom instrument is honest. Top-right, active vs connected: the gap between the two lines is the stranded-session count. Bottom-left, session-duration p95: it climbs toward the disconnect-retention ceiling as stranded circuits linger before the server reaps them. Bottom-right, outbound connection pool: open connections (by state) and the in-flight backlog rise together when the load test saturates the two-connection pool. Every series here is real data from the steps above.

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. The point of all this instrumentation is to be told when something is wrong while you are doing something else. Every metric in this post is a candidate for an alert:

  • 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:

Configuring the alert, top to bottom. 1 -- Define the metric: the same query builder as a panel, here frontend.circuits.active aggregated with Max (you care about the peak, not the average). 2 -- Define alert conditions: the plain-English row reads "Send a notification when A is above the threshold at least once during the last 5 mins", with the Alert Threshold set to 5. The chart above the form previews your query against that threshold line as you type, so you can sanity-check the number before saving.

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 same rule, now live. The line is frontend.circuits.active over the last 30 minutes; the dashed line at 5 is the threshold. While the metric stays under it the rule is green, and the moment it crosses (here, eight held-open circuits) it flips to Firing and notifies the channels you attached. Because the threshold, the evaluation window, and the severity are all part of the rule, you can make "10 circuits for 1 minute" a warning and "100 circuits for 5 minutes" a page.

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. All of it self-hosted, with nothing leaving your network. (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);

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.

💼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