Bit.Belemetry
11.0.0-pre-18
See the version list below for details.
dotnet add package Bit.Belemetry --version 11.0.0-pre-18
NuGet\Install-Package Bit.Belemetry -Version 11.0.0-pre-18
<PackageReference Include="Bit.Belemetry" Version="11.0.0-pre-18" />
<PackageVersion Include="Bit.Belemetry" Version="11.0.0-pre-18" />
<PackageReference Include="Bit.Belemetry" />
paket add Bit.Belemetry --version 11.0.0-pre-18
#r "nuget: Bit.Belemetry, 11.0.0-pre-18"
#:package Bit.Belemetry@11.0.0-pre-18
#addin nuget:?package=Bit.Belemetry&version=11.0.0-pre-18&prerelease
#tool nuget:?package=Bit.Belemetry&version=11.0.0-pre-18&prerelease
bit Belemetry
One lightweight telemetry SDK for every Blazor app - WebAssembly, Server, Auto, static SSR and Hybrid (MAUI, WPF, WinForms) - covering both sides of the app:
- Browser: page views, clicks, web vitals, the document load, resource timing, long tasks, JS errors, and fetch/XHR dependencies with W3C trace-context propagation.
- .NET:
ILoggerlogs,Activitytraces andMetermetrics.
It speaks OpenTelemetry - the data model, the semantic conventions and the OTLP protocol - but does not depend
on the OpenTelemetry NuGet packages: it is built on the BCL's own ActivityListener, MeterListener and
ILoggerProvider, with hand-written OTLP and Application Insights encoders and a single dependency-free script. It
exports to:
- OTLP (HTTP with protobuf or JSON, or gRPC): the OpenTelemetry Collector, the .NET Aspire dashboard, Grafana (Tempo, Loki, Mimir), Jaeger, Honeycomb, Datadog, New Relic, Elastic, SigNoz, Uptrace, and anything else that speaks OTLP.
- Azure Monitor / Application Insights, natively (
/v2.1/track): page views, requests, dependencies, exceptions, events and metrics land in their usual tables, with Live Metrics on top. - Sentry: errors become Sentry Issues, with .NET frames Sentry symbolicates from the uploaded PDBs and JS frames parsed for every browser engine, each linked to the trace it was recorded in - no Sentry SDK in the app for the .NET and JavaScript errors (native crashes on mobile still need one, see Sentry).
- Console, in-memory, or your own
IBelemetryExporter.
When a user reports a problem, production diagnostics turn what the app did - routes, components, event handlers, failed requests, browser errors, circuit transitions, grouped exceptions, release and trace ids - into a support bundle the user previews and confirms before anything leaves the app.
And it is GDPR-ready: nothing is sent before the user consents (by default), and every part - errors, page views, interactions, performance, dependencies, sessions, user tracking, logs, traces, metrics, offline persistence, custom events - can be granted and revoked independently, at runtime.
The documentation website lives in Demo - run the
Server project (dotnet run). It exports to itself, so its Live telemetry page shows
your own clicks, page views, web vitals and errors arriving as you use the site. The same server hosts an MCP
server at /mcp for AI agents: setup per host, the exact API, a configuration generator and reviewer, and the
semantic-convention and Azure-mapping references.
Contents
- Install
- Configuration from appsettings
- Set up per host
- Exporters
- Consent and GDPR
- What is collected
- Manual telemetry from C#
- .NET instrumentation
- Browser instrumentation
- Privacy
- Processors
- Production diagnostics and support bundles
- Offline persistence
- The server pipeline
- Transport: Direct and Relay
- Environment variables
- Semantic conventions
- Azure Monitor mapping
- Using the script without .NET
- How it works
- Troubleshooting
- Upgrade notes
Install
dotnet add package Bit.Belemetry
using Bit.Belemetry;
builder.Services.AddBitBelemetryServices(options =>
{
options.ServiceName = "MyApp";
options.Environment = builder.Environment.EnvironmentName;
options.AddOtlpExporter("http://localhost:4318"); // Aspire dashboard / OTel Collector
options.AddAzureMonitorExporter(builder.Configuration["ApplicationInsights:ConnectionString"]!);
options.Consent.Default = BelemetryFeatures.None; // opt-in (the default)
});
AddBitBelemetryServices is idempotent and follows the Options pattern: call it in every project that hosts Blazor,
and every configure callback passed across calls runs, in order, on the same BelemetryOptions.
Add @using Bit.Belemetry to _Imports.razor in every project whose components use it. Without it,
<BelemetryScript /> compiles as an unknown HTML element (only a warning), and telemetry silently never starts.
What the core cannot see on its own ships as separate packages, so an app only takes the dependencies it uses:
Bit.Belemetry.EntityFrameworkCore (a span per database command) and Bit.Belemetry.Hangfire (a span per job
execution, parented to the enqueue). See Entity Framework Core and Hangfire.
Configuration from appsettings
Every option can come from configuration instead of code. Bind the section the way a trimmed or AOT app binds any
options - the configuration binder's source generator (<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
in the app's project) handles BelemetryOptions without reflection, and Belemetry itself takes no dependency on the
binder:
builder.Services.AddBitBelemetryServices(options =>
{
builder.Configuration.GetSection("Belemetry").Bind(options);
options.AddExporter(new MyExporter()); // what only code can add: custom exporters, processors, token providers
});
{
"Belemetry": {
"ServiceName": "MyApp",
"Environment": "Production",
"Consent": { "Default": "Errors" },
"Browser": { "TrackPageVisitTime": true },
"Traces": { "Sources": [ "MyApp.*" ], "IgnoredActivityNames": [ "Microsoft.AspNetCore.Components.HandleEvent" ] },
"Server": { "CollectDotnetTelemetry": true, "IgnoredRequestPaths": [ "/health", "/alive" ] },
"OtlpExporters": [ { "Endpoint": "https://otel.example.com", "Protocol": "HttpProtobuf", "Headers": { "x-api-key": "..." } } ],
"AzureMonitorExporters": [ { "ConnectionString": "InstrumentationKey=...", "MinimumLogLevel": "Warning" } ],
"SentryExporters": [ { "Dsn": "https://<public key>@<org>.ingest.sentry.io/<project id>" } ]
}
}
Two binder rules worth knowing: a bound list adds to the option's defaults (Privacy.RedactQueryParameters keeps
its built-in names and gains yours), and a bound array replaces nothing either, which is why Metrics.HistogramBoundaries
is empty by default and means "the OpenTelemetry defaults" until you set it. Enums and flags bind by name
("Features": "Errors, Logs, Traces"), and TimeSpans by hh:mm:ss.
Set up per host
Blazor Web App (Server, WebAssembly or Auto render modes, and static SSR)
Register in both projects (server and client):
// Server/Program.cs and Client/Program.cs
builder.Services.AddBitBelemetryServices(options => { /* same configuration */ });
Add the script to the <head> of App.razor - it starts before Blazor boots, so it sees the initial page load, the
web vitals and early errors, and it also covers static SSR pages that never become interactive:
<head>
...
<BelemetryScript />
</head>
And connect .NET once the app is interactive, in Routes.razor (or the main layout):
<Router AppAssembly="typeof(Program).Assembly">...</Router>
<BelemetryInitializer />
With the WebAssembly or Auto render modes, the .NET pipeline (logs, traces, metrics) runs in the browser and its
export is flushed with keepalive when the page unloads. On the server, Belemetry only serves the script unless you
turn on the server pipeline.
What that costs the download, measured on a trimmed Release publish of Samples/Wasm (brotli, as served):
| Part | Size |
|---|---|
Bit.Belemetry.wasm (the .NET pipeline, both encoders, the bridge) |
107 KB |
bit-belemetry.js (the browser script) |
16 KB |
System.Diagnostics.DiagnosticSource.wasm, when the app did not already ship it |
29 KB |
System.Text.Json and System.Net.Http are already part of every Blazor WebAssembly app. The package is
IsTrimmable and IsAotCompatible; the E2E suite's publish gate fails on any trim or AOT warning raised inside it.
In the browser, metrics need System.Diagnostics.Metrics, which the WebAssembly SDK turns off by default
(MetricsSupport=false) to save size. The Bit.Belemetry package turns it back on for you: its build props set
MetricsSupport to true unless the project sets it. A project that sets it to false gets no metrics, and Belemetry
logs a warning at startup.
HttpClient spans are a switch of their own, which the WebAssembly SDK also turns off (HttpActivityPropagationSupport)
and the package leaves alone. Off, HttpClient's requests go out through the browser's fetch, and the browser script
records them as its own client spans (Dependencies, with traceparent only where Browser.PropagateTraceContextTo
allows). Set <HttpActivityPropagationSupport>true</HttpActivityPropagationSupport> to have .NET trace them as
System.Net.Http spans inside the current activity's trace instead - but then HttpClient sends traceparent to every
origin it calls, and a cross-origin API that does not allow the header in CORS fails.
Standalone Blazor WebAssembly
builder.Services.AddBitBelemetryServices(options =>
{
options.ServiceName = "MyApp";
options.AddOtlpExporter(o =>
{
o.Endpoint = new Uri("https://otel.example.com");
o.Protocol = BelemetryOtlpProtocol.HttpProtobuf;
});
});
@* App.razor *@
<Router AppAssembly="typeof(App).Assembly">...</Router>
<BelemetryInitializer />
Optionally add the module to index.html too:
<script type="module" src="_content/Bit.Belemetry/bit-belemetry.js"></script>
The browser then downloads it in parallel with the .NET runtime and window.BitBelemetry exists before Blazor starts,
but the script itself only starts when <BelemetryInitializer /> hands it the configuration. (It starts on load only
from the <script type="application/json" id="bit-belemetry-config"> element that <BelemetryScript /> renders in a
Blazor Web App; you can write that element into index.html yourself, in the shape of the browser config - it then
fixes the exporters, and the later start from .NET only updates consent and the transport.) Starting late costs
little: web vitals, the document-load span, resource timing and long tasks are read from the browser's buffered
performance entries. What a late start misses is a JavaScript error thrown before it.
Blazor Hybrid (MAUI, WPF, WinForms)
builder.Services.AddBitBelemetryServices(options =>
{
options.ServiceName = "MyApp.Maui";
options.AddOtlpExporter("https://otel.example.com");
});
@* Routes.razor / Main.razor *@
<BelemetryInitializer />
In Hybrid the browser script relays its records to .NET over JS interop (no CORS, no credentials in the page),
and the .NET pipeline exports everything, persisting it on the device while offline. MAUI, WPF and Windows Forms
share the same BlazorWebView core, so the setup is the same in all three; the repository's Samples/Maui and
Samples/WinForms show it. Flush before the app is suspended (or, in a desktop app, before its window closes):
// App.xaml.cs, with the app's IServiceProvider injected into its constructor as _services
protected override async void OnSleep()
{
base.OnSleep();
try
{
// IBelemetry is scoped; the queue it flushes is the app-wide pipeline.
await using var scope = _services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<IBelemetry>().FlushAsync();
}
catch (Exception ex)
{
// async void: an exception here would crash the app on its way to the background.
System.Diagnostics.Debug.WriteLine($"Flushing telemetry failed: {ex}");
}
}
Exporters
Any number of exporters can be combined. The browser script exports with the same configuration (unless
IncludeInBrowser = false).
OTLP
options.AddOtlpExporter(o =>
{
o.Endpoint = new Uri("http://localhost:4318"); // /v1/traces, /v1/logs, /v1/metrics are appended
o.Protocol = BelemetryOtlpProtocol.HttpProtobuf; // or HttpJson, or Grpc (see below)
o.Headers["x-honeycomb-team"] = "..."; // vendor API keys (a server keeps these out of the page)
o.BrowserHeaders["x-ingest-key"] = "..."; // what the browser script may send - public by definition
o.Compression = BelemetryCompression.Gzip;
o.BrowserEndpoint = new Uri("https://otel.example.com"); // public, CORS-enabled route for the browser (may be relative: "/otlp")
o.Signals = BelemetrySignals.All; // honored by the browser too (it sends traces and logs)
o.MinimumLogLevel = LogLevel.Warning; // this exporter gets warnings and up; others get everything
// o.TracesEndpoint / LogsEndpoint / MetricsEndpoint: full per-signal URLs, used verbatim
});
gRPC. BelemetryOtlpProtocol.Grpc speaks OTLP/gRPC over HTTP/2 without a gRPC library: a unary call is an HTTP/2
POST with the 5-byte message prefix, and the status comes back in the trailers. It is what the OpenTelemetry SDKs
default to and what the .NET Aspire AppHost's WithOtlpExporter() hands a resource (OTEL_EXPORTER_OTLP_PROTOCOL=grpc
and the dashboard's gRPC endpoint; the default port is 4317). Compression becomes the gRPC message compression
(grpc-encoding: gzip). A browser cannot speak gRPC, so a gRPC exporter reaches the page only through an OTLP/HTTP
BrowserEndpoint; in a WebAssembly app the .NET pipeline cannot either, so there the whole exporter becomes OTLP/HTTP
to that endpoint (which must be absolute), and one without is skipped with a startup warning. On Android and iOS the
gRPC calls go through a SocketsHttpHandler of their own, since the platforms' native handlers cannot do HTTP/2
without TLS.
One level per backend. MinimumLogLevel is the per-exporter counterpart of a logging provider's level: with it,
Application Insights can receive warnings and errors only while an OTLP backend receives everything, from one
pipeline. It applies to the plain log records of the .NET pipeline (its own and the browser's relayed ones); events -
page views, interactions, web vitals, custom events, exceptions - are not logs to a log level and always pass, as do
spans and metrics.
The browser posts to the collector from the page origin, so the receiver must allow it. For the OpenTelemetry Collector:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
cors:
allowed_origins: ["https://myapp.example.com"]
allowed_headers: ["*"]
For the .NET Aspire dashboard, set DASHBOARD__OTLP__CORS__ALLOWEDORIGINS to your app's origin and use its OTLP
HTTP endpoint (protobuf).
Headers and the page. Whatever the browser script exports with is readable by anyone who opens the page. A server
(Blazor Server, static SSR, the server half of Auto) therefore never hands an exporter's Headers to the script -
they are server configuration, often a real API key, or the Aspire dashboard's key from OTEL_EXPORTER_OTLP_HEADERS -
and the page sends only BrowserHeaders, for a key that is meant to be public. Belemetry warns at startup about an
exporter whose page now exports without the headers it has. In WebAssembly and Hybrid the configuration already lives
on the device, so the script sends Headers too (with BrowserHeaders on top): never put a credential in a client's
exporter. For vendors that need a secret key, use a collector in between, or the
Relay transport.
User info in an endpoint. The page never gets an endpoint's user info, on any host: https://user:token@collector.example.com,
set in code or through OTEL_EXPORTER_OTLP_ENDPOINT, reaches the browser script as https://collector.example.com/,
and a server warns at startup about an exporter whose page now exports without it. Belemetry's own warnings and
diagnostics name every endpoint without it too.
User info authenticates no export anyway - neither .NET's HttpClient nor the browser sends it - so put the
credential in Headers (an Authorization header) instead.
Azure Monitor (Application Insights)
options.AddAzureMonitorExporter("InstrumentationKey=...;IngestionEndpoint=https://...", o =>
{
o.MaskIp = true; // default: ai.location.ip = 0.0.0.0, no IP-based geolocation
o.Compression = BelemetryCompression.Gzip; // also from the browser (the endpoint's CORS allows Content-Encoding)
o.Signals = BelemetrySignals.All; // honored by the browser too
o.EnableLiveMetrics = true; // Live Metrics stream (not in the browser)
o.MinimumLogLevel = LogLevel.Warning; // Application Insights gets warnings and up (spans and metrics unaffected)
o.AccessTokenProvider = async ct => // Entra ID auth, for resources with local auth disabled
(await credential.GetTokenAsync(new(["https://monitor.azure.com//.default"]), ct)).Token;
});
Belemetry takes no dependency on Azure.Identity - the token provider is a plain delegate. An exporter with a token provider is never handed to the browser script.
Live Metrics follows the Application Insights QuickPulse protocol (API version
2024-04-01-preview); treat it as a preview feature. In a Hybrid app it streams only while the user grantsMetrics, and it follows the service's endpoint redirects over HTTPS only.Besides the request, dependency and exception rates and the process CPU and memory, it applies what the portal configures:
- Sample telemetry. Each stream's filters pick the requests, dependencies, exceptions, custom events and traces (log messages, and the named events of spans) it shows. A stream takes up to 3 samples of a type at once and earns one more every 2 seconds, saving up to 30, unless the service sets other quotas.
- Custom charts. A chart's filters, projection (
Count(),Duration,CustomDimensions.{key}) and aggregation are computed for every data point.Filters compare text case-sensitively, except
ContainsandDoesNotContain. A sample carries the properties of the Application Insights item it stands for - the record's attributes after privacy and processors, without the user and session ids or the attributes the item's own fields hold, plus what the item adds (a page visit'sPageName, an event'smessage, ...) - andCustomDimensions.{key}filters and charts read the same. Samples and custom charts only take what the exporter itself sends, as itsSignalsandMinimumLogLevelallow; the built-in request, dependency and exception rates count every span and exception the pipeline accepts, whatever these settings. The rates and the custom charts' sums and averages count a sampled record as the items it stands for (1 / SamplingRatio, rounded, as Application Insights counts itssampleRate), so sampling leaves them true; a sample is one item. A sample waiting for the next data point is not sent once the user revokes its feature, even if they grant it again first. When the portal changes the configuration, the streams and charts it leaves as they were keep their quotas and the values they gathered. What Belemetry cannot apply (performance counters, custom metrics, an unknown field, filters that are not a list) is left out and reported back to the portal as a configuration error; a configuration it cannot read at all is reported too, and nothing is sampled until the portal sends another. The stream goes through a registeredBelemetryHttpMessageHandlerProvider, like the rest of the export traffic.
Sentry
options.AddSentryExporter("https://<public key>@<org>.ingest.sentry.io/<project id>", o =>
{
o.MinimumEventLevel = LogLevel.Error; // the default: exceptions, and error logs, become events
o.Release = "shop@2.0.0"; // default: {service.name}@{service.version}
o.IncludeUnhandledExceptions = true; // false where a Sentry SDK reports the crashes (Android, iOS)
o.MaskIp = true; // the default: Sentry does not infer the user's IP address
});
Sentry takes OTLP traces and logs (point an OTLP exporter at its OTLP endpoint), but nothing it receives over OTLP becomes an Issue. The Sentry exporter posts each error as a Sentry event to the project's envelope endpoint, so one pipeline carries traces, logs and metrics over OTLP and errors as Sentry events, without a Sentry SDK in the app.
SENTRY_DSN fills in the DSN of an exporter added without one (options.AddSentryExporter()), but never adds an
exporter by itself: an app that sets it for a Sentry SDK of its own would get every error twice. An invalid DSN skips
the exporter, with a startup warning.
- What becomes an event. A record carrying an exception - an
ILoggerentry with one,TrackException, an unhandled .NET exception, a JS error, an exception recorded on a span (Activity.AddException) - becomes an error event with its exception chain and stack trace, and a plain log record a message event, both at or aboveMinimumEventLevel(Errorby default, as with Sentry's own logging integration). Page views, custom events and web vitals do not. An exception logged under a span and recorded on it too is one event. Each event is an envelope of its own. - Sentry alone. With no other exporter, Belemetry collects no metric and queues only what Sentry sends: a span
without an exception, a page view, a web vital, a custom event or a log below the level goes no further (spans are
still recorded, for their exceptions and the logs' trace ids). The browser script still records what
Options.Featuresturns on - consent covers those features - so leave out the ones nothing reads (PageViews,Interactions,Performance,Dependencies,Events,Metrics) to spare the page their work too. - Symbolication. Neither Relay nor Sentry parses a stack string, so Belemetry sends frames. A .NET frame carries the
method's metadata token and its IL offset, and the event lists the assembly's
pe_dotnetdebug image (its PDB id, as Sentry's .NET SDK writes it): upload the PDBs withsentry-cli debug-files upload, and Sentry's symbolicator adds the file and the line. That needs the assembly as a file - servers and desktop apps, MAUI on Windows; in WebAssembly, on Android and iOS, in single-file and NativeAOT apps the frames carry what the stack trace's text tells (the file and the line when the PDBs ship with the app), each exception of the chain on its own. An async method's, lambda's or local function's frame is the method as written, so the same failure groups alike either way. JavaScript stacks are parsed for V8, SpiderMonkey and JavaScriptCore; upload the source maps for the release. A JS interop call's error, whose message carries the .NET exception's text, keeps its JavaScript frames, and the .NET runtime's WebAssembly frames (wasm://) are not the app's code. - Traces. An event recorded under a span carries its trace and span ids (
contexts.trace), so the Issue links to the trace Sentry received over OTLP; an error logged outside any span has no trace to link to. - What an event carries. The level, the logger (the instrumentation scope), the release, the environment
(
deployment.environment.name),server_name(host.name), the user (user.id, elseenduser.pseudo.id- only withUserTracking),session.idas a tag, the browser's user agent (Sentry tells the browser and the OS from it), the runtime and the OS, the resource (contexts.otel), and the record's other attributes asextra. All of it is what the privacy rules and the processors left - the exception's type, message and stack trace included: the exception object's frames are read only while the stack trace is still the text the record was made with, so one they changed, redacted or removed is all that is sent - no frame, path or inner exception comes from the object then - and an inner exception's message only when the stack text still holds it. The mechanism ishandled: falsefor what nothing caught: the browser'serrorandunhandledrejection, Belemetry's unhandled .NET exceptions, and what the framework's renderers and hosts log. A .NET exception the browser script records - a Blazor Server component'sTrackExceptiongoes through the page - keeps its .NET frames. - The browser. The DSN's public key is public by design - Sentry's browser SDK puts it in every page - so the
script sends its errors itself (
IncludeInBrowser, on by default), tohttps://<host>/api/<project>/envelope/?sentry_key=..., which Sentry's CORS allows. A legacy secret key in the DSN is never used, nor handed to the page. With the Relay transport, the browser's errors reach Sentry through .NET as the same events. - Crashes on mobile. Native crashes on Android, iOS and Mac Catalyst are out of Belemetry's reach. Keep a Sentry
SDK there as the crash reporter only - the core
Sentrypackage rather thanSentry.Maui, with tracing off - and setIncludeUnhandledExceptions = false, as that SDK reports the .NET crashes too; Belemetry sends the rest. - Delivery. A rate limit (429, with
Retry-After), 502, 503 and 504 are retried and kept by the offline persistence; any other error is final. Once an event's request has failed, the batch's other events are not tried one by one - each would wait out its own retries - but kept for the retry pass (dropped without persistence). An event keeps its id across retries, so Sentry drops a duplicate.Compression = Gzipcompresses the envelopes, from the browser too.
Console, in-memory and custom exporters
options.AddConsoleExporter(); // development
var memory = new BelemetryInMemoryExporter(); // tests and in-app viewers
options.AddExporter(memory);
options.AddExporter(new MyExporter()); // any IBelemetryExporter
public sealed class MyExporter : IBelemetryExporter
{
public string Name => "mine";
public ValueTask<BelemetryExportResult> ExportAsync(BelemetryBatch batch, CancellationToken cancellationToken)
{
foreach (var span in batch.Spans) { /* ... */ }
return ValueTask.FromResult(BelemetryExportResult.Success);
}
}
Consent and GDPR
Every record belongs to one feature, and is only collected when the feature is enabled by configuration
(BelemetryOptions.Features, all by default) and granted by the user. The grant never exceeds the configuration:
IBelemetryConsent.Granted (and getConsent() in the browser) is always a subset of Available, and granting a
feature the configuration switched off records nothing - so a feature you enable later needs the user's consent.
| Feature | Covers |
|---|---|
Errors |
JS errors and unhandled rejections; .NET logs at Error/Critical or with an exception; TrackException; unhandled .NET exceptions and unobserved task exceptions; exceptions recorded on spans (Activity.AddException) - without Errors they are taken off the span, which is still sent |
PageViews |
Page views (navigation, TrackPageView) |
Interactions |
Clicks and other interactions |
Performance |
Web vitals, document load, resource timing, long tasks |
Dependencies |
Browser fetch/XHR spans |
Sessions |
A session id that survives reloads (sessionStorage); without it the session id lives in memory only |
UserTracking |
The pseudonymous id enduser.pseudo.id (localStorage) and the authenticated user.id |
Logs |
.NET logs below Error, console capture |
Traces |
.NET Activity spans |
Metrics |
.NET Meter metrics |
Persistence |
Keeping unsent telemetry on the device for a later retry |
Events |
Custom events (TrackEvent) |
Groups: Diagnostics (Errors, Logs, Traces, Dependencies), Analytics (PageViews, Interactions, Sessions,
UserTracking, Events), Monitoring (Performance, Metrics), All.
@inject IBelemetryConsent Consent
<button @onclick="() => Consent.SetAsync(BelemetryFeatures.All)">Accept all</button>
<button @onclick="() => Consent.SetAsync(BelemetryFeatures.Errors)">Only error reports</button>
<button @onclick="() => Consent.SetAsync(BelemetryFeatures.None)">Reject</button>
@if (Consent.IsDecided is false) { <ConsentBanner /> }
How it behaves:
- Opt-in by default. Before the user decides, only
Consent.Defaultis granted (Noneunless you set it, e.g. toErrorswhen error reporting is covered by legitimate interest, or toAllfor apps that need no consent). - Nothing undecided leaves the device. Records of not-yet-granted features are held in memory (up to
Consent.PendingLimit) and sent if the user grants them, dropped if not. - Revoking is immediate. Queued records of a revoked feature are discarded, and its device storage is cleared:
revoking any feature clears the offline queue (a stored request can hold that feature's records),
Sessionsthe stored session,UserTrackingthe pseudo id and user id, which also leave the records still queued. What already left the queue and holds a revoked feature's records (or the user ids) is dropped too: not sent if it has not been yet, not retried, not stored - and the other records of its batch go with it. A revoke ofPersistenceorSessions, which no record belongs to, drops nothing in flight. - The choice is remembered (
Consent.Persist, default on) inlocalStorage(and in a file in Hybrid apps) - recording the choice itself is strictly necessary. Other open tabs of the app adopt it as it is made, so a banner answered in one tab closes in the rest, and a revoke there stops them sending too. - Global Privacy Control is honored (
Consent.HonorGlobalPrivacyControl, default on): a browser sending GPC never gets theAnalyticsfeatures. - No cookies. Belemetry sets none.
- Without consent for
Traces, activities are still created for propagation only, so trace context keeps flowing to downstream services, but nothing is recorded.
On WebAssembly and Hybrid consent is app-wide. On Blazor Server it belongs to the circuit's user (it lives in their
browser), so IBelemetryConsent needs an interactive render mode there.
What is collected
| WebAssembly | Server | Static SSR | Hybrid | |
|---|---|---|---|---|
| Browser instrumentation | yes | yes | yes | yes (relayed) |
| .NET logs, traces, metrics | in the browser | opt-in (server pipeline) | opt-in | in the app |
| Unload flush | keepalive | keepalive | keepalive | app lifecycle |
| Offline persistence | localStorage | localStorage | localStorage | files |
Manual telemetry from C#
@inject IBelemetry Belemetry
Belemetry.TrackEvent("checkout.completed", new Dictionary<string, object?> { ["cart.items"] = 3, ["cart.total"] = 42.5 });
Belemetry.TrackPageView("Checkout - payment step");
Belemetry.TrackException(ex, new Dictionary<string, object?> { ["order.id"] = orderId });
await Belemetry.SetUserAsync(userId); // user.id, only kept with UserTracking consent
await Belemetry.FlushAsync();
On WebAssembly and Hybrid these go through the .NET pipeline; on Blazor Server they are routed through the user's browser, so their consent applies.
The user ids are consent's to add: without UserTracking, a user.id or enduser.pseudo.id passed as an attribute,
a log scope, a structured log value or a metric tag is dropped (from span events and links too), and a log message that
would render one is exported with its template as the body (Paid by {user.id}) - also when UserTracking is revoked
after the message was logged and before it was exported.
.NET instrumentation
Logs
Every ILogger entry at or above Logs.MinimumLevel (Information) becomes a log record with its structured values as
attributes, the active scopes (Logs.IncludeScopes), the exception (exception.type, exception.message,
exception.stacktrace) and the current trace context. Per-category levels use the standard configuration under the
Belemetry alias:
{
"Logging": {
"Belemetry": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
}
}
}
Belemetry's own diagnostics are logged under the category Bit.Belemetry, which is never exported (an export failure
would otherwise be exported by the pipeline it describes). Every other category is collected, including your own
types in Bit.Belemetry.* namespaces; Logs.ExcludedCategories drops more by prefix.
Unhandled exceptions
An exception nothing caught reaches no ILogger, so the .NET pipeline listens for them itself
(Errors.CaptureUnhandledExceptions, on by default):
AppDomain.UnhandledException- the crash of a MAUI, WPF or WinForms app, or a background thread of a server that threw. The record is a FATALexceptionevent (Criticalin Application Insights) with the bodyAn unhandled exception ended the process..TaskScheduler.UnobservedTaskException- a faulted task that was never awaited, raised when the garbage collector finds it. The record is an ERRORexceptionevent with the bodyA faulted task's exception was never observed.; the task's own exception is reported rather than itsAggregateExceptionwhen it is the only one. The event is not marked observed: that stays the app's choice.
Both carry exception.type, exception.message, exception.stacktrace and the trace context of Activity.Current
where they are reported, under the scope Bit.Belemetry.UnhandledExceptions. For AppDomain.UnhandledException that
is the thread that threw, so the span it threw in; an unobserved task exception is reported by the finalizer thread,
long after the task ran, and carries no trace context of the task's. They are Errors records: consent and the
error limits apply as to any other, and production diagnostics count them as
unhandled exceptions (the UnhandledExceptions trigger).
The process ends right after AppDomain.UnhandledException, before anything async completes. So Belemetry writes the
crash record, with everything still queued before it, to the offline store synchronously,
and the next launch sends it with the rest of the store (its first resend pass is 10 s after start). That needs
Persistence: granted by the user on a client, in Server.Features on a server (off by default there; see
The server pipeline). Without it nothing is written to disk - the user did not allow it - and
the record is lost with the process. Records held for a consent decision are never written either. In WebAssembly
the page outlives the .NET runtime, so the queue is handed to the script instead, which sends it as it sends the
unload flush. The queue is saved in requests of at most Batch.MaxBatchSize records each, newest first: when the
store's Persistence.MaxBytes cannot hold them all, older files make room, then the oldest of these, never the crash
record. Only the HTTP exporters (OTLP, Azure Monitor, Sentry) are saved, as only their requests can be stored; custom
exporters and the console exporter get the records only if the process goes on. Nothing waits on the network as the
process goes down: a crash never takes longer because of telemetry.
Crashes reported elsewhere
Some platforms report a crash through an event of their own rather than AppDomain.UnhandledException. Forward it
with TrackBitBelemetryUnhandledException, on the app's root service provider, and it is recorded and saved for the
next launch the same way. An exception object it already recorded is not recorded again, so forwarding a crash that
also reaches AppDomain.UnhandledException is one record. It records even with CaptureUnhandledExceptions off.
// MauiProgram.cs
var app = builder.Build();
#if ANDROID
// .NET for Android may not raise AppDomain.UnhandledException for a crash (dotnet/runtime#44526).
Android.Runtime.AndroidEnvironment.UnhandledExceptionRaiser += (_, e) => app.Services.TrackBitBelemetryUnhandledException(e.Exception);
#elif WINDOWS
// WinUI reports an exception on its UI thread here; it ends the app unless a handler sets e.Handled.
Microsoft.UI.Xaml.Application.Current.UnhandledException += (_, e) => app.Services.TrackBitBelemetryUnhandledException(e.Exception);
#endif
return app;
iOS and Mac Catalyst raise AppDomain.UnhandledException for a crash; nothing to forward. On Windows desktop:
WinForms shows its own dialog for an exception on the UI thread (
Application.ThreadException) and goes on. Either let such exceptions crash the app, so they reachAppDomain.UnhandledException, or forward them from your handler as handled:// Program.cs, before Application.Run Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); // or Application.ThreadException += (_, e) => provider.TrackBitBelemetryUnhandledException(e.Exception, terminating: false);WPF crashes on an exception
Dispatcher.UnhandledExceptionleaves unhandled, throughAppDomain.UnhandledException. A handler that setse.Handled = trueforwards it withterminating: false.
Traces
Activities of the sources in Traces.Sources (default System.Net.Http, Microsoft.AspNetCore.Components* and
Bit.Belemetry.* - the instrumentation packages; a trailing * matches a prefix) are recorded. Sampling is
parent-based with Traces.SamplingRatio for new traces, compatible with the OpenTelemetry TraceIdRatioBased
sampler. Names in Traces.IgnoredActivityNames are never created at all - their children attach to the parent - which
is the cheapest way to drop a chatty source, such as Blazor's per-event spans.
Blazor's component spans (Navigate, HandleEvent) exist on a server; in WebAssembly and Hybrid only with
Diagnostics.Enabled (Belemetry registers the framework's component source for diagnostics). With diagnostics on, they
are exported there too when Traces.Sources matches Microsoft.AspNetCore.Components (the default), and the HttpClient
spans a click or navigation starts are their children.
options.Traces.Sources.Add("MyApp*");
options.Traces.SamplingRatio = 0.25;
options.Traces.IgnoredActivityNames.Add("Microsoft.AspNetCore.Components.HandleEvent");
static readonly ActivitySource Source = new("MyApp.Checkout");
using var activity = Source.StartActivity("PlaceOrder");
A span records how it was sampled in BelemetrySpan.SamplingRatio, so Azure Monitor can send it as each item's
sampleRate and Application Insights counts each item 1 / SamplingRatio times: request and dependency counts stay
true (see Azure Monitor mapping). A trace the ratio keeps carries it in its W3C tracestate
(belemetry=sr:0.25), so it reaches every span of the trace: local children, a span started from another's context (an
in-process queue's consumer), a Hangfire job, and the downstream services and the server a WebAssembly
HttpClient calls. OTLP sends it with the span's trace state.
- A span of a trace this process started takes
Traces.SamplingRatio. - A span that came with a sampled remote parent takes the ratio the caller's tracestate carries. It is never below this
process's own ratio, and it is 1 when this process does not sample: anyone can send a
tracestate, so a caller must not make a request count for more than one sampled here would. Give every service of a trace the same ratio. - Without the entry it is 1: a caller whose ratio is not known. The browser script sends every
traceparentas sampled, so the server records all of its requests.
Application Insights counts an item in whole numbers (it supports sample rates of 100 / N), so 1 / SamplingRatio is
rounded: use 1 / N ratios (0.5, 0.25, 0.1, 0.01) for exact counts. With 0.3, each kept item counts 3 times, not 3.33.
With HttpActivityPropagationSupport on in WebAssembly and a ratio below 1, HttpClient sends tracestate along with
traceparent, and a cross-origin API must allow both in CORS.
Sampling at the end of a span (keep every failure and every slow span, a fraction of the rest) is a
processor. BelemetrySampler makes the same deterministic, trace-wide decision the ratio sampler
makes, so the kept fraction is whole traces, not scattered spans. The decisions nest: a trace kept at 0.05 is kept at
every larger ratio, Traces.SamplingRatio included. So the trace was kept at the smaller of the two ratios, not at
their product, and the processor sets SamplingRatio to it:
public sealed class TailSampler : IBelemetryProcessor
{
public bool OnSpan(BelemetrySpan span)
{
if (span.Status == BelemetryStatusCode.Error || span.Duration > TimeSpan.FromSeconds(1)) return true;
if (BelemetrySampler.ShouldSample(span.TraceId, 0.05) is false) return false;
// Kept at 0.05, so at a head ratio of 0.25 too: 5% of the traces, each one standing for 20.
span.SamplingRatio = Math.Min(span.SamplingRatio, 0.05);
return true;
}
}
A processor that samples at random instead (independently of the trace id) multiplies SamplingRatio by its ratio.
Metrics
Instruments of the meters in Metrics.Meters (default System.Net.Http, Microsoft.AspNetCore.Components*,
System.Runtime) are aggregated - counters and up-down counters as sums, histograms with explicit buckets (instrument
advice is honored), gauges as last values - and exported every Metrics.ExportInterval (60 s), with
Metrics.Temporality (cumulative by default; Azure Monitor always gets deltas) and a Metrics.CardinalityLimit per
instrument (2000; overflow goes to an otel.metric.overflow point).
options.Metrics.Meters.Add("MyApp");
options.Metrics.HistogramBoundaries = [0, 10, 50, 100, 500, 1000]; // empty (the default) means BelemetryMetricsOptions.DefaultHistogramBoundaries
Belemetry's own health. Add the Bit.Belemetry meter to watch for data loss on a dashboard instead of finding
out from a gap:
options.Metrics.Meters.Add("Bit.Belemetry");
| Instrument | Attributes | Counts |
|---|---|---|
belemetry.records.dropped |
belemetry.reason, belemetry.signal (traces/logs) |
records dropped before export: queue_full, pending_full (the hold before a consent decision), not_consented, consent_revoked, error_limit, relay_limit, sampled_out (a relayed record of a session the ratio does not keep), filtered (a processor returned false) |
belemetry.records.exported |
belemetry.exporter, belemetry.outcome |
records handed to an exporter - for Azure Monitor the items it sends, so a span event sent as an item of its own counts and a metric point with nothing new does not: delivered, rejected (refused for good - the items a partial success refused included), stored (kept for a retry; delivered again when the retry succeeds), failed (lost), withdrawn (consent for a feature of its records was revoked after it left the queue: neither sent again nor stored) |
In the browser, BitBelemetry.getStats() gives the script's counterpart: { dropped: { <reason>: n }, requests: { delivered, rejected, kept, dropped, withdrawn }, queued, pending } since the page loaded (with sampled_out among the
reasons).
Metrics depend on the System.Diagnostics.Metrics feature switch (MetricsSupport). The WebAssembly SDK turns it off
by default; the Bit.Belemetry package turns it back on unless the project sets it. With the switch off, no metrics are
exported while logs and traces still flow, and Belemetry logs a warning at startup. To drop metrics on purpose, also
remove BelemetryFeatures.Metrics from options.Features, which silences the warning. A project that references
Bit.Belemetry.csproj instead of the package skips the package's build props: import
buildTransitive/Bit.Belemetry.props from it, as this repository's samples do.
Entity Framework Core and Hangfire
Neither EF Core nor Hangfire starts activities of its own, and the OpenTelemetry instrumentations for them only run
inside the OpenTelemetry SDK. Belemetry ships the two bridges as packages of their own, so an app without a database
or a job server takes neither dependency; their spans land on Bit.Belemetry.* sources, which the core collects by
default, and any other ActivityListener can record them too.
dotnet add package Bit.Belemetry.EntityFrameworkCore
dotnet add package Bit.Belemetry.Hangfire
services.AddDbContext<AppDbContext>(options => options
.UseSqlServer(connectionString)
.UseBitBelemetry(o =>
{
o.CaptureQueryText = false; // db.query.text, off by default
o.Filter = (command, data) => data.Context is not JobDbContext; // skip a job store's own commands
}));
services.AddHangfire(configuration => configuration
.UseSqlServerStorage(connectionString)
.UseBitBelemetry(o =>
{
o.PropagateTraceContext = true; // the enqueue's trace is the execution's parent
o.Filter = job => job.Type != typeof(HeartbeatJob); // skip the noisy ones
}));
- EF Core: a client span per command, named after the database, with
db.system.name,db.namespace,server.address,server.port,efcore.command.source(SaveChanges,LinqQuery,Migrations, ...) and, on a failure,error.typeand an error status. Application Insights shows them as database dependencies. The span covers the execution, not the reading of the result set, as the OpenTelemetry instrumentation's does. - Hangfire: a consumer span
JOB {Type}.{Method}per execution withjob.id,job.type,job.method,job.queueandjob.created_at, parented to the trace that enqueued the job (the W3C trace context travels in the job's parameters, so the enqueue and the execution may run in different processes), and marked as an error witherror.typewhen the job throws. Application Insights shows them as requests. Reading the context back costs one storage round trip per execution, like any job parameter.
Browser instrumentation
Option (options.Browser.*) |
Default | |
|---|---|---|
Enabled |
true |
the script at all |
TrackHashChanges |
false |
fragment-only changes count as page views |
TrackPageVisitTime |
false |
how long each page was visited, as a browser.page_visit_time event (Application Insights: PageVisitTime) |
PageRoutes |
none | route templates (/orders/{id:int}) page paths are matched against, besides the discovered ones - see Page route templates |
DiscoverPageRoutes |
true |
add the @page routes of the app's components to PageRoutes |
PageRouteAssemblies |
none | (code only) the assemblies discovery reads, exactly: your router's AppAssembly and AdditionalAssemblies |
InteractionEvents |
click |
DOM events recorded as interactions |
CaptureInteractionText |
false |
record the clicked element's text (may contain personal data) |
TrackRageClicks |
true |
three clicks on one element within a second also emit one browser.rage_click event per burst - the frustration signal of a user who expected something to happen |
WebVitals / DocumentLoad |
true |
LCP, INP, CLS, FCP, TTFB / the load span with navigation timing |
ResourceTiming / LongTasks |
false |
spans per fetched resource / main-thread tasks over 50 ms |
Fetch / Xhr |
true |
dependency spans |
PropagateTraceContextTo |
same-origin |
where traceparent is added (same-origin, *, or regexes) |
IgnoreUrls |
Blazor framework routes | regexes never recorded as dependencies |
IgnoreErrorMessages |
ResizeObserver loop, ^Script error\.?$ |
regexes of errors never recorded (the second is a cross-origin script's error without CORS: no message, no stack, nothing to act on) |
CaptureConsole / ConsoleLevels |
false / error, warn |
console capture |
SessionInactivityTimeout / SessionMaxDuration |
30 min / 24 h | session rotation |
SessionSampleRatio |
1.0 |
fraction of sessions recorded, whole; Azure Monitor sends it as every browser item's sampleRate (1 / N keeps the counts exact) |
FlushInterval, MaxBatchSize, MaxQueueSize |
5 s, 100, 1024 | batching |
MaxRelayedPerMinute |
3000 | records one circuit may relay per minute on Blazor Server (see Transport) |
Debug |
false |
the script's own diagnostics in console.debug |
Page route templates
A page view carries url.template: the route of the page, not its address, so that /orders/42 and /orders/43
are both /orders/{id:int} - one page in per-page charts, alerts and the Application Insights pageViews table, not
one per order. Every page view the script records has one. A TrackPageView .NET records itself (WebAssembly, Hybrid,
a server with no page connected) has one when it has a URL: the one you pass (a relative one is below the base path),
else the current page's (NavigationManager.Uri); only where there is neither - no URL passed, outside a page - it has
none. It is, in this order:
- the
url.templateyou pass toTrackPageView(a dialog or a wizard step has no route of its own); - the first of the app's routes that matches the path, as the Blazor router picks it (literal segments before
constrained parameters, before parameters, before catch-alls; case-insensitive; constraints such as
int,guidorlength(3)checked; a segment of several parts,{name}.{ext}, split from the right), relative to the<base href>and written after it (/shop/orders/{id:int}under<base href="/shop/" />); - otherwise the path with the segments that look like ids replaced: numbers by
{n}, GUIDs by{guid}, long hexadecimal or mixed letters-and-digits tokens by{hex}or{id}(/orders/{n}). A slug or a user name stays.
.NET runs the same rules, with the framework's own parsers for the constraints; the script approximates those (a
datetime value, for one, is a numeric date, a time, or a date with a month name - as .NET's invariant culture reads
them). A regex constraint that could take exponential time on a crafted path - a repeated group holding a quantifier
or an alternation, such as (a+)+, or a backreference - is not run in the browser, which cannot stop it (.NET gives
it a timeout): the parameter takes any value there, as a constraint of the app's own does on both sides.
The app's routes are found once per process: the @page ([Route]) templates of the components in the entry
assembly and in the component libraries it references (a server's .Client project, a shared page library for MAUI) -
close to the assemblies an app hands its router, but not exactly: a referenced library's own pages count even if the
router was not given it. For the router's exact list, set Browser.PageRouteAssemblies to its AppAssembly and
AdditionalAssemblies:
options.Browser.PageRouteAssemblies.AddRange([typeof(App).Assembly, typeof(Client._Imports).Assembly]);
An app without an entry assembly (.NET for Android) is searched from its loaded assemblies instead, and an app whose
search finds no route at all logs a warning under Bit.Belemetry: set PageRouteAssemblies there. The routes are
written into the browser config only when page views are recorded. A page whose script was started already - an Auto
app's WebAssembly client in the page its server rendered - keeps the routes its server found and adds those configured
in the client (PageRoutes and the PageRouteAssemblies' routes), without a second search. Add routes discovery
cannot see (a lazy-loaded WebAssembly assembly's) to Browser.PageRoutes, or switch discovery off with
Browser.DiscoverPageRoutes = false and list them yourself.
A page view is named after the name you give TrackPageView, else its page's title (<PageTitle>), else its template
(it was its path) - in its log record's body, the Application Insights pageViews name and ai.operation.name, and the
PageName of its PageVisitTime - on every host, whether the script or .NET records it.
Error floods and runaway values
An error thrown in a loop - a timer, a render loop, a retry - would fill the queue, evict every other record, and cost
the backend one record per occurrence from every client. So both the script and the .NET pipeline of a WebAssembly or
Hybrid app apply options.Errors:
DuplicateWindow(5 s): an error identical to one just recorded - same scope (logger category), type, message and first stack frame - is suppressed for this long, so a loop is recorded once per window.MaxPerMinute(100): at most this many error records per minute, with a burst of as many; the excess is dropped.
An exception recorded on a span (Activity.AddException) is an error record too. One the rules drop stays on its
span (an OTLP span still shows it) but is not sent to Azure Monitor as an item of its own.
The server pipeline serves every user at once and is not capped. Zero switches either rule off.
options.MaxValueLength (32768 characters) cuts longer attribute values, log bodies and span status messages - after
privacy scrubbing, so a cut never hides an e-mail address from it. It is the OpenTelemetry attribute value length
limit, applied to bodies too, so one serialized object or huge stack trace cannot make a request too large to deliver.
Zero keeps strings whole.
Name interactions with data-bit-belemetry-name, and keep a region out entirely with data-bit-belemetry-ignore:
<button data-bit-belemetry-name="checkout.pay">Pay</button>
<section data-bit-belemetry-ignore>...</section>
Input values are never read. An ignored region stays out everywhere: clicks in it are skipped, those in a shadow root
under it too, and with CaptureInteractionText its text is left out of the text of a clicked element around it.
Privacy
Applied on both sides before anything is queued, and by .NET to every metric point it collects (options.Privacy.*):
RedactQueryParameters: values of sensitive query parameters (token,code,password,secret,key,access_token,email, ..., and the signatures and credentials of pre-signed storage URLs -X-Amz-Signature,X-Amz-Credential,X-Amz-Security-Token,X-Goog-Signature, Azure SASsig) becomeREDACTEDin every URL.RedactedAttributes: attribute names whose values becomeREDACTED, whatever they look like. A name matches a key whole or its last dot-separated segment, case-insensitively, so the defaultpasswordcovers a structured log value{Password}anduser.password, andauthorizationcovershttp.request.header.authorization- whilepassword_policyis left alone; a trailing*matches a prefix (http.request.header.*). Defaults:password,passwd,pwd,secret,client_secret,token,access_token,refresh_token,id_token,apikey,api_key,x-api-key,authorization,proxy-authorization,cookie,set-cookie. A log message that renders such a value (or a blocked one) is exported with its template as the body (Sign-in for {User} with {Password}), so the value does not reach the backend in the message either.StripQueryString(off; it also dropsurl.query) andStripFragment(on - fragments may carry OAuth tokens).url.querygets the same parameter redaction as a URL.ScrubPatterns(on): bearer tokens, JWTs, e-mail addresses, card numbers (Luhn-checked) and IPv4 addresses are replaced in log bodies, span status messages and every string attribute - structured log values (logger.LogInformation("User {Email}", email)) and event attributes included - on both sides. Kept verbatim: the ids (session.id,enduser.pseudo.id,user.id),server.addressanduser_agent.original(whose four-part versions read as addresses). A key named like a version (AppVersion,app.version,build_version) keeps its four-part values too; the other patterns still apply to it.CollectUserAgent(on): the user agent string in the browser resource.BlockedAttributes: attribute keys (exact, case-sensitive) removed from every record, span event, span link and metric point, by the script and by .NET. The ids (session.id,enduser.pseudo.id,user.id) are governed by consent and untouched by every other privacy rule, but a blocked one is never added, and is removed from relayed browser records.- Azure Monitor's
MaskIp(on) stops IP-based geolocation; Belemetry never adds the client IP itself.
IP scrubbing leaves the host of a URL and server.address alone, even when they are IP literals: they name the
server that was called (http://10.0.0.5:5000/api stays readable in a dependency), not the user. The rest of the URL
is still scrubbed, credentials in a URL are always dropped, and IPs in any other text (messages, stack traces,
client.address in the browser) are replaced with [ip].
Processors
public sealed class TenantProcessor(ITenantAccessor tenants) : IBelemetryProcessor
{
public bool OnSpan(BelemetrySpan span)
{
span.Attributes.Set("tenant.id", tenants.Current);
return span.Name != "GET /health"; // false drops it
}
public bool OnLog(BelemetryLogRecord log) => log.Body?.Contains("noise") is not true;
}
options.AddProcessor(new TenantProcessor(...));
Processors run on every .NET-side record, including those relayed from the browser, after the privacy rules. One that
throws is skipped for that record (the record is kept), and reported once, as a warning in the Bit.Belemetry
category. With the Direct transport the browser's records never reach .NET; process them in the page instead (below).
In the browser
// Runs on every browser record as it is exported (or relayed), after consent and privacy. Change it in place;
// return false to drop it. A processor added right after the script started still sees the initial page view.
const remove = BitBelemetry.addProcessor(record => {
if (record.eventName === 'browser.user_interaction' && record.attributes['target.name'] === 'noise') return false;
record.attributes['app.release.channel'] = 'beta';
});
// Attributes on every record from now on (merged; null removes a key, setAttributes(null) removes them all). They
// go through privacy like any attribute, a record's own attributes win, and the consent-governed ids (session.id,
// enduser.pseudo.id, user.id) cannot be set this way - use setUser.
BitBelemetry.setAttributes({ 'app.tenant': 'contoso', 'feature.checkout_v2': true });
The record is the browser record model: a span has traceId, spanId, name, kind, startTime, endTime,
status and events; a log record has time, severityNumber, body and eventName; both have attributes,
scope and feature. Processors must be fast and synchronous: while the page unloads nothing else runs.
Production diagnostics and support bundles
Telemetry tells you that something failed. When a user reports a problem, you need to know what the Blazor app was doing: which route and component, which event handler, which requests failed before it, what the page reported, how the circuit behaved, which release - and the trace ids to look the rest up in your backend. Production diagnostics keep exactly that, in memory and bounded, and turn it into a support bundle the user (or a support agent) previews, narrows and confirms before anything leaves the app.
What it is not: no session replay, no screenshots, no DOM contents, keystrokes, form values or clipboard; no arbitrary object or memory dumps; no hosted service. A bundle is a ZIP of JSON documents and a Markdown report that you download or hand to your own support integration.
Turn it on
builder.Services.AddBitBelemetryServices(o =>
{
o.Diagnostics.Enabled = true; // off by default
// Optional: capture automatically (for review, never sent on its own) on unhandled exceptions, circuit failures and
// exceptions a classifier marks critical.
o.Diagnostics.AutoCaptureTriggers = BelemetryDiagnosticTriggers.Default;
// Optional release markers, attached to every capture.
o.Diagnostics.Release.Commit = Environment.GetEnvironmentVariable("GIT_COMMIT");
o.Diagnostics.Release.Metadata["ring"] = "canary";
});
{ "Belemetry": { "Diagnostics": { "Enabled": true, "AutoCaptureTriggers": "Default", "Release": { "Commit": "4f2a91c" } } } }
Call it in every project that hosts Blazor, as for the rest of Belemetry. It needs no exporter: an app can use
support bundles without sending any telemetry. Without one, though, Belemetry does not collect .NET spans, so the
timeline has no HttpClient or server request spans and none of the app's own ActivitySources (the Http and Trace
categories hold the browser's requests only) - even when another tracer, such as an OpenTelemetry SDK, records them.
Logs, exceptions, the framework's navigation and UI events and the browser's evidence are there either way. Keep
<BelemetryInitializer /> in Routes.razor (it registers a Blazor Server circuit's timeline and connects the browser's).
The defaults are the production-safe ones:
| Option | Default | What it bounds |
|---|---|---|
MaxEvents / MaxFailureEvents |
300 / 50 | The activity and failure rings (failures have their own, so chatter cannot evict the error being diagnosed). Per circuit on a server |
MaxMemoryBytes |
1 MiB | Estimated memory of one timeline; the oldest activity is evicted first |
MinimumLogLevel |
Warning |
ILogger entries recorded (independent of the export's level) |
IncludeSuccessfulActivity |
true |
Successful navigations, UI events and requests kept as context |
IncludeLogMessages / IncludeExceptionMessages |
false / true |
Log entries keep their template (Order {OrderId} failed), not the rendered message (and always their template when the message renders a redacted key such as a circuit id); exceptions keep their scrubbed message - off, neither the message nor its template is kept, and the stack text keeps its frames only (grouping still uses the message, hashed) |
CreateFrameworkActivities |
null: yes on WebAssembly and Hybrid, no on a server | Whether Belemetry makes the framework create its Navigate/HandleEvent activities when its own traces do not cover them (see below) |
BrowserMaxEvents |
200 | The browser script's own timeline |
MaxBundleBytes |
4 MiB | A bundle's JSON, snapshots, groups and components included; past it the oldest activity is left out, then the oldest failures (never the subject) - and the oldest snapshots first when they alone do not fit - and the manifest says how much |
MaxExceptionGroups |
50 | Groups remembered (least recently seen forgotten first) |
PostTriggerWindow / AutoCaptureCooldown |
2 s / 10 min | What follows a trigger is included; one automatic capture per failure per cooldown |
MaxPendingCaptures / CaptureRetention |
3 / 30 min | Automatic captures waiting for review (a new one replaces the oldest) |
Snapshots.* |
depth 6, 50 items, 1024 chars, 32 KiB, 2 s | One snapshot provider's output |
Report a problem: capture, preview, confirm, export
@inject IBelemetryDiagnostics Diagnostics
<button @onclick="ReportAsync">Report a problem</button>
@if (_capture is not null)
{
@* Sections with a checkbox each, the findings, every event with a Remove button, what is missing, the report as it
will be sent - then "Confirm and download" or "Cancel". Unstyled: style the bit-belemetry-preview-* classes. *@
<BelemetryCapturePreview Capture="_capture" OnConfirmed="_ => _capture = null" OnCancelled="() => _capture = null" />
}
@code {
private BelemetryDiagnosticCapture? _capture;
private async Task ReportAsync() =>
_capture = await Diagnostics.CaptureAsync(new() { Reason = "user-report", IncludeSnapshots = true });
}
Without the component, the same flow in code - for example to send the bundle to your own support API:
var capture = await diagnostics.CaptureAsync(new() { Reason = "support-ticket", Exception = errorBoundaryException });
if (capture.Succeeded is false) return; // capture.Error says why (diagnostics off, ...)
// Preview: capture.Sections, capture.Report (findings), capture.Events, capture.MissingEvidence, capture.ToMarkdown()
capture.ExcludeSection(BelemetryBundleSections.Snapshots); // each change withdraws a previous confirmation
capture.RedactAttribute("tenant");
capture.SetDescription(userText); // user-provided, through the privacy patterns
capture.Confirm(); // the user agreed to share what they saw
await using var body = new MemoryStream();
await capture.ExportAsync(body); // throws unless confirmed
// ... POST body to your support endpoint, or: await diagnostics.DownloadAsync(capture);
DownloadAsync saves capture.SuggestedFileName in the browser (WebAssembly and Server; on Server the bundle is
streamed over the circuit), and in WebView2 on Windows. It returns false in a Hybrid app on Android, iOS or Mac Catalyst,
whose web view ignores downloads: write ExportAsync to a file or a share sheet there (the MAUI sample's
SampleSettings.SaveBundle), and pass Download="false" to <BelemetryCapturePreview>.
An ErrorBoundary is the natural place to offer it: pass its CurrentException as Exception, and the report is
about that failure. With AutoCaptureTriggers, IBelemetryDiagnostics.CaptureAvailable fires when an automatic
capture is ready (raised outside any render: use InvokeAsync(StateHasChanged)), and PendingCaptures holds them;
Dismiss one once it was handled.
What a capture holds
| Section | Content |
|---|---|
| Release | service.name, version, environment, commit, build id, configuration version, metadata, host kind, runtime, OS family, architecture, browser brands/platform/language |
| Exceptions | Groups by fingerprint: type, message template, anchor frame, classification, count, first/last seen, releases, and the occurrences (at most the last five) of the events the bundle holds - an older failure the timeline no longer holds counts, but its message, trace and route are not exported |
| Timeline (.NET) | Navigation (route templates from the router), component UI events (HandleEvent: component type, method, event attribute), exceptions (type, scrubbed message, stack without file paths, inner exceptions, origin), logs (category, template), HTTP client and server spans (method, status, URL template, error type), framework circuit activities, the app's own events and recorded snapshots |
| Browser | Page views (route template), fetch/XHR (method, status, URL template, trace ids), JS errors and rejections (type, message, stack), clicks (event type and element only), long tasks, reconnection transitions, page hide/restore |
| Components | The component types the timeline names, ranked by association - trace (the failure happened in that component's event-handler trace), stack (inferred from the first application frame), framework - with render statistics when measured, and ancestry when a component-context provider (BevTools) knows it |
| Snapshots | The app's snapshot providers' sanitized output, only when the capture asks for them |
| Report | The issue report: title, summary, exception, release, route, triggering action, navigation, failed requests, interop failures, circuit transitions, components, trace ids, prerequisites, missing evidence, and every finding marked observed, inferred or user-provided |
Observed facts come straight from the evidence (an exception logged inside a HandleEvent activity is that
handler's failure). Inferred ones say so: a request that failed two seconds before the exception "may be the cause"; a
route templated from the page's URL is marked as such; a component named by a stack frame is an inference. Missing
evidence is listed, never filled in: an unreachable browser, events left out for consent or size, a hierarchy nobody
provided.
Where the evidence comes from
| Source | WebAssembly | Server | Hybrid |
|---|---|---|---|
ILogger (≥ MinimumLogLevel), exceptions (renderer, ErrorBoundary, circuit, app) |
yes | the circuit's own | yes |
Navigate / HandleEvent framework activities (.NET 10+; registered by AddBitBelemetryServices) |
yes | the circuit's own, when something creates them (below) | yes |
.NET HttpClient spans |
as browser fetch spans | with traces collected (Server.CollectDotnetTelemetry) |
with Traces |
| The browser timeline (fetch/XHR, JS errors, page views, clicks, reconnection) | yes | yes, the user's tab | yes (the WebView) |
| Server request spans and errors of the page's own requests, by trace id | other process: trace ids | yes | - |
Render statistics (ComponentRenderStatistics; the framework's lifecycle meter) |
call AddComponentsMetrics() |
yes | call AddComponentsMetrics() |
The framework's component activities. They name the component, the event handler and the route of a failure. An
activity that exists becomes the parent of the spans started under it, and an unsampled parent makes a parent-based
sampler - OpenTelemetry's default - drop them. So Belemetry makes the framework create them only where that changes
nothing for the app's traces: when its own exported traces cover Microsoft.AspNetCore.Components (then they decide,
and a name in Traces.IgnoredActivityNames is never created), or, without them, on WebAssembly and Hybrid, where
Belemetry is the app's tracer. On a server with an OpenTelemetry setup of its own, either have that setup listen to
Microsoft.AspNetCore.Components (Belemetry observes what exists), or set Diagnostics.CreateFrameworkActivities = true
knowing that spans under a UI event then follow its (unsampled) flag.
Blazor Server: one timeline per circuit. The server serves everyone, so each circuit records only what is
attributable to it: code that ran on its renderer's synchronization context, or under one of its UI events or
navigations while that event still runs (work that left the renderer with ConfigureAwait(false) still carries the
event's activity). Anything else - a background job, another user's request, a timer a click handler started that
keeps running after it - is never put on any user's timeline. Every scope of a circuit (an OwningComponentBase's
included) shares its timeline. The browser half comes from
that user's own tab over the circuit. A server error logged while one of the page's own requests ran is added by the
trace id the page generated and propagated. Record circuit transitions with a scoped CircuitHandler:
public sealed class CircuitDiagnostics(IBelemetryDiagnostics diagnostics) : CircuitHandler
{
public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
{
diagnostics.Record("circuit.connection_down", severity: BelemetrySeverity.Warn, category: BelemetryDiagnosticCategories.Circuit);
return Task.CompletedTask;
}
}
builder.Services.AddScoped<CircuitHandler, CircuitDiagnostics>();
Never record the circuit id: it is a bearer secret (the sanitizer redacts CircuitId keys anyway). The browser
script also records the reconnect dialog's transitions (components-reconnect-state-changed), when the app has a
reconnect UI of its own (#components-reconnect-modal, as the .NET 10 template's).
Prerendering and render-mode changes. The browser timeline lives in the page from its first load (the script
starts from <BelemetryScript />), so a prerendered page that turns interactive keeps one browser timeline. The .NET
side of a prerendering request is its own short-lived timeline.
Exception grouping and releases
BelemetryExceptionFingerprint groups deterministically, independent of any backend: the exception type, the
message template (Order {n} not found - GUIDs, timestamps, long hex, quoted values, numbers of four digits or more
and URL path ids replaced; short numbers stay, a 404 is not a 500) and up to three meaningful frames (application
frames, without file paths, line numbers, arguments or compiler-generated names; JavaScript frames as
function@file.js). The algorithm is versioned (BelemetryExceptionFingerprint.Version), and every exported group
says which version made it. Groups span releases by default; GroupAcrossReleases = false keeps each release's
occurrences apart. Every occurrence keeps its release, time, trace id, route and component.
Classify exceptions of your own domain with an IBelemetryExceptionClassifier (a singleton): a category, a grouping
override, Critical (an automatic capture trigger), or Ignore for an expected, noisy failure.
public sealed class PaymentsClassifier : IBelemetryExceptionClassifier
{
public BelemetryExceptionClassification? Classify(BelemetryDiagnosticException exception) =>
exception.Type == "MyApp.PaymentDeclinedException" ? new() { Category = "payments", Critical = true } : null;
}
builder.Services.AddSingleton<IBelemetryExceptionClassifier, PaymentsClassifier>();
Snapshots of application state
Belemetry never serializes components, services or arbitrary objects. A snapshot provider describes what support
needs, as plain values, when a capture asks for it (IncludeSnapshots = true) - and it is sanitized, bounded and
isolated (one that throws or exceeds its timeout is reported, and the others still run):
public sealed class CartSnapshot(Cart cart) : IBelemetrySnapshotProvider
{
public string Name => "cart";
public ValueTask<IReadOnlyDictionary<string, object?>?> CaptureAsync(BelemetrySnapshotContext context, CancellationToken cancellationToken) =>
ValueTask.FromResult<IReadOnlyDictionary<string, object?>?>(new Dictionary<string, object?>
{
["itemCount"] = cart.Items.Count,
["currency"] = cart.Currency,
["step"] = cart.CheckoutStep, // enums, dates, numbers, strings, nested dictionaries and lists
});
}
builder.Services.AddBitBelemetrySnapshotProvider<CartSnapshot>(); // scoped: per circuit on Blazor Server
await diagnostics.RecordSnapshotAsync("before-checkout") keeps the providers' snapshots on the timeline, so a later
capture shows the state before an operation next to the state at the end. A snapshot is a description, not a
restorable state.
Privacy, consent and security
- Consent applies as for telemetry. The timeline holds what the pipeline accepted for a feature the user granted
(or has not decided on yet - then it is held, and dropped if not granted); a capture includes granted features only,
checked again when it is previewed and exported; a revoke purges the timeline and discards pending captures (one
being built across the revoke too).
ClearAsyncforgets the .NET timeline and the page's, with its stored copy. On a server, the server's own records answer toServer.Features, as the server pipeline does. Consent your banner asks for covers the timeline: it never leaves the device unless the user confirms a bundle. - One sanitizer for everything, applied when a value is recorded and again at export: blocked and redacted keys
(the privacy options' lists,
Diagnostics.RedactedKeys, and any key that containspassword,secret,token,apikey,authorization,cookie,credential,connectionstring,signature, card names), yourIBelemetryDiagnosticSanitizers (a singleton; one that throws excludes the value), pattern scrubbing (e-mail, bearer, JWT, card, IPv4 - always on here, whateverPrivacy.ScrubPatternssays for export), and size limits. - The export's rules first. The timeline taps each record after the privacy rules and the app's processors: an exception message the export blocks or a processor rewrites is blocked or rewritten here too, in the browser as in .NET (a session sampled out of the export still runs the processors before its timeline keeps a record).
- What the user removes stays removed. Excluding an event or a section in the preview takes its exception
occurrences out of
exceptions.jsontoo, and a redacted key is redacted in the report as well as in the timeline. - Metadata, not values. Events keep the attributes their category needs (method, status, route template,
component type, ...) plus
AllowedAttributes. URLs become templates (/orders/{n}) with onlyAllowedQueryParameters, no fragment, no credentials. Never request or response bodies, headers, cookies, user ids, session ids or DOM text. - A bundle is data. JSON (with HTML-sensitive characters escaped) and Markdown whose values are code spans or escaped text - no HTML, links, scripts or serialized objects - and a manifest with every file's SHA-256.
- A browser is untrusted input on a server. The page's timeline is bounded and sanitized again in .NET, trace ids are validated, and a page can trigger at most what the capture limits allow.
Correlating with your backend
Trace ids are the link. The report lists the subject's trace and those of failed requests and failures; the timeline
carries trace, span and parent span ids (never invented: an event without a real parent has none). Look them up in
the OTLP backend Belemetry - or your own OpenTelemetry setup - exports to: the browser's fetch spans propagate
traceparent to the origins in PropagateTraceContextTo (same origin by default), so a page's request and the
server's span share one trace. A session id is neither a trace id nor a user id, and none of them is in a bundle.
BevTools
BevTools is the interactive side. The Bit.BevTools.Belemetry package
(builder.Services.AddBitBevToolsBelemetry()) adds a Support panel that lists this session's timeline and exception
groups, opens the component tree at the component a failure is associated with, filters the timeline to a trace,
previews and exports bundles and validates imported ones - and gives captures the component hierarchy and render counts
BevTools observes (IBelemetryComponentContextProvider). BevTools' own UI events (its panels' buttons and tabs) stay off
the timeline (IBelemetryDiagnosticComponentFilter, which any tool rendered in the app can implement). Belemetry works
without it: BevTools is a development tool, and in production a capture names the component types the framework
reported and says the hierarchy is unavailable.
The bundle format
A ZIP (deterministic: sorted keys, fixed entry times) holding:
| File | Content |
|---|---|
manifest.json |
format (bit-belemetry-support-bundle), schemaVersion (1), generator, capture id and time, host, trigger, ordering note, fingerprint version, sections (status, items, omitted, note), every file's size and SHA-256, privacy summary (redactions, the user's preview changes, consent), limits, dropped evidence by reason, missing evidence |
report.md / report.json |
The issue report, human- and machine-readable |
release.json, exceptions.json, timeline.json, components.json, snapshots.json |
The sections, when included |
Events are ordered by time, then by sequence; sequences count per source (browser events are prefixed b in
references), and elapsedMs is each source's monotonic clock. BelemetrySupportBundle.ReadAsync reads and validates
a bundle - format, version (a newer one is refused), checksums (a listed file without one is an error, and is not read) -
with bounded decompression and no extraction to disk, ignoring files the format does not define.
Troubleshooting incomplete captures
- Empty timeline:
Diagnostics.Enabled, consent (the timeline follows it),MinimumLogLevel,Categories. - "Browser evidence: the browser script was not reachable": the capture ran during prerendering or after the
circuit dropped, the script did not load, or the page's config predates
Diagnostics.Enabled(reload). - No components: the framework reports
HandleEvent/Navigatefrom .NET 10 in interactive render modes only; static SSR has none. On a server they exist only when something creates them (CreateFrameworkActivities, or a tracer listening toMicrosoft.AspNetCore.Components). No hierarchy without a component-context provider (BevTools). - A server event missing from a circuit's capture: it ran neither on the circuit's renderer nor under one of its
UI events (a background service, a
Task.Runstarted outside an event). That is the isolation working; record it explicitly withIBelemetryDiagnostics.Recordfrom the circuit's scope when it matters. - Events "left out at the bundle size limit" or "evicted": raise
MaxBundleBytes,MaxEventsorMaxMemoryBytes, or capture closer to the failure (Lookback).
Offline persistence
With Persistence consent and options.Persistence.Enabled (default), requests that fail with a network error or a
retryable status are kept - for OTLP 429, 502, 503 and 504, the only ones the OTLP specification allows retrying; for
Azure Monitor also 408, 439 and 500, which its ingestion endpoint asks to retry; any other status is the backend's final
word and the request is dropped, with a warning - in localStorage in the
browser (BrowserMaxBytes, 1 MB), in files in Hybrid and on servers (Directory, MaxBytes 20 MB) - and retried
every RetryInterval (1 min, on both sides), when the page starts and when the browser comes back online, for up to
MaxAge (48 h). A retry pass sends the stored requests oldest first, one attempt each. A request that fails holds
back the rest of its URL's backlog until the next pass - that backend is still unreachable, and hammering it would only
waste the network - but not the requests to other backends. The browser takes each stored request out of
localStorage only while sending it, so the backlog survives the page going away mid-pass (and a request being sent
without keepalive goes back to storage when the page is hidden).
When the page is hidden or unloads, the script sends what is queued with fetch(..., { keepalive: true }), which
outlives the page - but a browser caps the bodies of all of a page's keepalive requests in flight at 64 KiB together.
The script tracks that budget: a request that does not fit is stored (with Persistence consent) and sent on the next
visit, or sent as a plain request without it (which completes if the page was only hidden). Delivery during unload is
best effort; nothing can guarantee it.
Stored requests never contain headers, so API keys are not written to disk or localStorage. The exporter's current
headers are added when a stored request is sent, so a rotated key applies to the backlog too. (In WebAssembly, a .NET
request stored by the browser is resent with the headers of the browser exporter posting to the same URL.) A request
stored for an endpoint the exporter no longer posts to is dropped, so the current key never goes to an old address.
A .NET process that ends with an unhandled exception saves its queue to the store as it goes down, and the next launch sends it - see Unhandled exceptions.
The server pipeline
On an ASP.NET Core host, Belemetry's .NET pipeline is off by default - such hosts usually have their own
OpenTelemetry setup - and Belemetry only serves the browser script. Turn it on to collect the server's logs, traces
(including Microsoft.AspNetCore request spans) and metrics with the same exporters:
options.Server.CollectDotnetTelemetry = true;
options.Server.Features = BelemetryFeatures.Errors | BelemetryFeatures.Logs | BelemetryFeatures.Traces | BelemetryFeatures.Metrics;
options.Server.IgnoredRequestPaths.AddRange(["/health", "/alive", "/_framework", "/_content", "/_blazor"]); // no request spans for probes and Blazor's own traffic
IgnoredRequestPaths matches url.path; and since the span is dropped when it ends, a child it
had - the database check behind /health - is still exported, with a parent that never arrives.
The server pipeline serves every user at once, so it is governed by Server.Features (operator telemetry), not by
any one user's consent. User consent keeps governing the browser script and the IBelemetry calls of each circuit.
Persistence is not in the default Server.Features, so a server keeps nothing on disk: neither the exports that
failed nor the queue of a process that crashed (Unhandled exceptions). Add it to keep both
for the next start:
options.Server.Features |= BelemetryFeatures.Persistence;
The store is {LocalApplicationData}/bit-belemetry/{service.name} by default. Instances of one service on one machine
(several worker processes, replicas sharing a volume) share it and would resend each other's requests, so give each
its own Persistence.Directory - a stable one, which its next start finds again (a container's local disk does not
outlive the container).
A host without Blazor at all - an API, a worker - works the same way: it has no JS runtime, so IBelemetry goes
straight to the server pipeline.
A server also describes where it runs, the way the OpenTelemetry resource detectors do, from environment variables
and /proc alone (no metadata-service call): host.name, process.pid, process.executable.name, process.owner,
container.id (Linux), and on Azure App Service, Functions or Container Apps cloud.provider, cloud.platform,
cloud.region, cloud.resource_id, host.id and azure.*. Its service.instance.id is the platform's instance
name (WEBSITE_INSTANCE_ID, CONTAINER_APP_REPLICA_NAME) or the machine name, so Application Insights keeps counting
the same instance across restarts - a device or a browser tab keeps a random id, which identifies nobody.
ASP.NET Core's request activity (Microsoft.AspNetCore.Hosting.HttpRequestIn) carries no HTTP tags of its own by
default. Belemetry fills them in without a middleware or a reference to ASP.NET Core, from the tags ASP.NET Core
puts on its http.server.request.duration measurement: http.request.method, url.scheme, http.route,
http.response.status_code, network.protocol.version and error.type. The span is named {method} {route} (or
{method} without a route, unless the app renamed the activity), and a 5xx marks it as an error. Belemetry
also turns off ASP.NET Core's Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData switch (unless the app
set it either way), which adds url.path, server.address and server.port.
Transport: Direct and Relay
options.Browser.Transport:
- Direct (default except Hybrid): the script exports to the configured endpoints itself with
fetch. The receiver must allow the page origin (CORS), and exporter headers are visible in the page. - Relay (default in Hybrid): the script hands its records to .NET over JS interop, and the .NET pipeline exports
them. No CORS, no credentials in the page. Needs
<BelemetryInitializer />; on Blazor Server, records travel over the circuit (interactive pages only).
On Blazor Server the page at the other end of a circuit can be anything that speaks the interop protocol, and the
queue it relays into is shared by every user, so the server treats relayed batches as untrusted input: it accepts them
only with the Relay transport, limits each circuit to a burst of Browser.MaxQueueSize + Consent.PendingLimit
records and Browser.MaxRelayedPerMinute (3000) per minute after it (the excess is dropped, with one warning per
circuit), drops features the configuration switched off, and decides the service identity itself - service.name,
service.namespace, service.version and deployment.environment.name come from the server, the rest of the
resource from the page, and none of the server's own host or process attributes are added (as with the Direct
transport).
Environment variables
Outside the browser, with options.UseEnvironmentVariables (default on), the standard variables fill in what the code
leaves unset - this is how the Aspire AppHost and most container platforms wire telemetry in. A value set in code
always wins, as in the OpenTelemetry SDKs (headers merge per key):
| Variable | Effect |
|---|---|
OTEL_SDK_DISABLED |
true (any case) turns Belemetry off entirely - an operator's kill switch, so unlike the others it wins over code |
OTEL_SERVICE_NAME |
service.name |
OTEL_RESOURCE_ATTRIBUTES |
extra resource attributes (k=v,k2=v2) |
OTEL_EXPORTER_OTLP_ENDPOINT |
adds an OTLP exporter when none is configured, and fills unset endpoints |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, _LOGS_ENDPOINT, _METRICS_ENDPOINT |
per-signal endpoints (any of them adds the exporter too); with gRPC, a base address the service method is appended to |
OTEL_EXPORTER_OTLP_HEADERS |
OTLP headers (k=v,k2=v2) |
OTEL_EXPORTER_OTLP_PROTOCOL |
http/protobuf, http/json or grpc, for exporters whose Protocol is not set in code; anything else is reported at startup and ignored |
OTEL_EXPORTER_OTLP_COMPRESSION |
gzip or none, for exporters whose Compression is not set in code |
OTEL_EXPORTER_OTLP_TIMEOUT |
the request timeout in milliseconds, for exporters whose Timeout is not set in code |
APPLICATIONINSIGHTS_CONNECTION_STRING |
adds an Azure Monitor exporter when none is configured |
SENTRY_DSN |
the DSN of a Sentry exporter added without one (options.AddSentryExporter()); it adds no exporter by itself |
WEBSITE_*, REGION_NAME, CONTAINER_APP_* |
the Azure App Service / Functions / Container Apps resource attributes and instance id of a server (see the server pipeline) |
Semantic conventions
Resource: service.name, service.version, service.namespace, service.instance.id,
deployment.environment.name, telemetry.sdk.name = bit-belemetry, telemetry.sdk.language (dotnet / webjs),
telemetry.sdk.version, plus os.*, host.arch, process.runtime.*, process.pid (.NET), host.name,
process.executable.name, process.owner, container.id, cloud.* (servers only) or browser.*,
user_agent.original (browser).
| Record | Shape |
|---|---|
| Page view | log record, event browser.page_view, named (in its body) after the name given to trackPageView, else the page title, else its route template: url.full, url.path, url.template (the route, see Page route templates), page.title, page.referrer, browser.navigation.type (initial, push, replace, pop, hash, manual, or restore - the page came back from the back/forward cache, which fires no history event) |
| Page visit time | log record, event browser.page_visit_time (with Browser.TrackPageVisitTime): url.full, url.path, url.template, page.title, page.visit.duration (ms) |
| Interaction | log record, event browser.user_interaction: event.type, target.element, target.id, target.name, target.xpath |
| Rage click | log record, event browser.rage_click (with Browser.TrackRageClicks): the target attributes, event.type = rage_click, interaction.click_count |
| Web vital | log record, event browser.web_vital: web_vital.name, web_vital.value, web_vital.delta, web_vital.rating, web_vital.id, web_vital.navigation_type. A back/forward-cache restore starts a new generation under new ids, as Google's web-vitals library does: FCP and LCP measured from the restore to the next painted frame, CLS and INP from zero, navigation type back-forward-cache |
| Document load | internal span documentLoad with navigation-timing events |
| fetch/XHR | client span: http.request.method, url.full, server.address, server.port, http.response.status_code, error.type. An unknown method is _OTHER (span name HTTP) with http.request.method_original; a request the app aborted keeps error.type = AbortError but no error status; TimeoutError and NetworkError (XHR) are errors |
| JS error / exception | log record, severity ERROR, event exception: exception.type, exception.message, exception.stacktrace |
ILogger |
log record with the category as scope, log.event.id, log.event.name, the structured values |
| EF Core command | client span named after the database: db.system.name, db.namespace, server.address, server.port, efcore.command.source, db.query.text (opt-in), error.type |
| Hangfire execution | consumer span JOB {Type}.{Method}: job.id, job.type, job.method, job.queue, job.created_at, error.type |
| Every record | session.id; enduser.pseudo.id and user.id with UserTracking |
Azure Monitor mapping
| Record | Application Insights |
|---|---|
| Server/consumer span | requests |
| Other spans | dependencies (Ajax from the browser, HTTP, the database system, InProc) |
| Log with an exception | exceptions |
Exception recorded on a span (Activity.AddException, an exception span event) |
exceptions, under the span, as the Azure Monitor OpenTelemetry exporter writes it - but once only when the same exception (type, message and stack trace) is also logged under the span (LogError(ex) plus AddException(ex)): the log's item stands for both, unless this exporter does not send that log (Signals, MinimumLogLevel, or the log was dropped before the export) |
| Any other span event | traces, under the span, with the event name as the message - as the Azure Monitor OpenTelemetry exporter writes it |
browser.page_view |
pageViews, named after its body (the name given to TrackPageView), else page.title, else url.template (with url.template among the custom dimensions) |
browser.web_vital |
customMetrics (LCP, INP, CLS, FCP, TTFB) |
browser.page_visit_time |
customEvents as PageVisitTime with the PageVisitTime measurement and PageName (the page view's name)/PageUrl properties - the Application Insights JS SDK's shape |
| Other events | customEvents |
| Other logs | traces |
| Metrics | customMetrics (deltas; histograms with count, min, max) |
Tags: ai.cloud.role ([service.namespace]/service.name, or service.name alone - as the Azure Monitor
OpenTelemetry exporter writes it), ai.cloud.roleInstance, ai.application.ver (service.version), ai.user.agent
and ai.device.locale (browser), ai.device.type (Browser, PC or Phone), ai.operation.id (trace id), ai.operation.parentId,
ai.operation.name (requests and their events: {method} {route} or the span name; page views: the page name), ai.session.id,
ai.user.id (pseudo id), ai.user.authUserId (user id), ai.location.ip (0.0.0.0 with MaskIp),
ai.internal.sdkVersion.
sampleRate: the percentage of such items sampling kept, so Application Insights counts each one 100 / sampleRate
times (itemCount).
- The rate is
100 / N, N being1 / ratiorounded to a whole number, as Application Insights counts items only in whole numbers. Live Metrics counts the same N. A1 / Nratio (0.5, 0.25, 0.1) keeps the counts exact. - A browser item takes the ratio the page sampled its session at (
Browser.SessionSampleRatio). A relayed one takes the ratio the page says it sampled at: an Auto app's page keeps the configuration of the side that started it first. On a server a page is untrusted, so the ratio is never below the server'sBrowser.SessionSampleRatio, and a record whosesession.idthat ratio does not keep is dropped (sampled_out): the page did not sample it. A record without asession.id(BlockedAttributes) is taken as it is. - A span takes
BelemetrySpan.SamplingRatio, and so do its exception and event items: the ratio its trace was kept at (see Traces). - .NET logs (
BelemetryLogRecord.SamplingRatio) and metrics are not sampled, so they take 100. - A .NET processor that samples sets
BelemetrySpan.SamplingRatioorBelemetryLogRecord.SamplingRatioof what it keeps. A browser processor (addProcessor) has no ratio to set: what it drops is not counted. Sample browser telemetry withSessionSampleRatio, or, with the Relay transport, in a .NET processor.
Retried statuses: 408, 429, 439, 500, 502, 503 and 504, and, in a 206 partial success, only the items rejected with one of them.
Using the script without .NET
bit-belemetry.js is an ES module and also sets window.BitBelemetry:
BitBelemetry.setConsent(BitBelemetry.Features.All);
BitBelemetry.trackEvent('video.played', { 'video.id': 'intro' });
BitBelemetry.setUser('42');
await BitBelemetry.flush();
console.log(BitBelemetry.getContext()); // { consent, decided, sessionId, pseudoId, userId }
How it works
- Collectors: an
ActivityListener, aMeterListenerand anILoggerProvider- the BCL primitives the OpenTelemetry SDK itself is built on - feed one pipeline that applies consent, privacy rules and processors, batches, and exports on async timers (never threads: browser WebAssembly has one). - Encoders: OTLP protobuf (a minimal hand-written writer, conformance-tested against the official
.protofiles), OTLP JSON, and Application Insights envelopes - all without reflection, so the package is trimmable and AOT-compatible. - Delivery: retries with backoff that honor
Retry-After(on the statuses each backend's protocol calls transient), Azure partial-success (206) handling, OTLPpartial_successresponses reported as warnings (the specification forbids retrying them), offline persistence, and an unload drain in which WebAssembly hands its serialized batches to the browser to send withkeepalive. .NET exports carryUser-Agent: Bit.Belemetry/<version>(a page cannot set it). - No self-instrumentation: the exporter's own HTTP calls are suppressed from tracing, and the script ignores exporter URLs and requests already traced by .NET.
Troubleshooting
- Nothing arrives: check consent first (
IBelemetryConsent.Granted, orBitBelemetry.getContext()in the browser console) - by default nothing is sent until the user consents.BitBelemetry.getStats()says what the script dropped and why (not_consented,sampled_out,queue_full, ...) and what became of its requests (rejectedmeans the backend refused them - a key or an endpoint; on .NET those are logged as warnings). Then setoptions.Browser.Debug = trueandoptions.Debug = true, and addoptions.AddConsoleExporter(). - CORS errors in the browser console: the receiver does not allow the page origin - configure CORS on the
collector, use
BrowserEndpoint, or switch to the Relay transport. - No .NET telemetry on the server: that is the default; see the server pipeline.
- Nothing reaches the Aspire dashboard: the AppHost hands its resources a gRPC endpoint. That works with
OTEL_EXPORTER_OTLP_PROTOCOL=grpc(which it sets) andBelemetryOtlpProtocol.Grpc; an exporter with its protocol fixed to HTTP in code posts HTTP to the gRPC port and fails quietly. Either let the environment decide the protocol, or give the AppHostWithOtlpExporter(OtlpProtocol.HttpProtobuf). Belemetry reports an unknown protocol value at startup, as a warning in theBit.Belemetrycategory. - Logs and traces from WebAssembly, but no metrics:
System.Diagnostics.Metricsis off in the app, and Belemetry logs a warning at startup saying so. The package turns it on by default, so look for<MetricsSupport>false</MetricsSupport>in the client project, or a project reference to Bit.Belemetry instead of the package (see Metrics). - Duplicate HTTP spans in WebAssembly: they should not happen - the script skips requests that already carry a
traceparentfrom .NET'sHttpClient. If a custom handler strips it, add the URL toBrowser.IgnoreUrls. - No
System.Net.Httpspans in WebAssembly: the WebAssembly SDK turnsHttpClient's tracing off (HttpActivityPropagationSupport), and its requests show up as the browser script's fetch spans instead. Turning it on has a cost:HttpClientthen sendstraceparentto every origin, which a cross-origin API must allow in CORS.
Upgrade notes
Behavior that changed with the hardening release, and how to get the previous behavior where that is still possible.
The reasons are recorded in Belemetry-Improvement-Plan.md.
Security and privacy
- A server no longer publishes exporter
Headersin the page. On Blazor Server, static SSR and the server half of Auto, an OTLP exporter'sHeaders(andOTEL_EXPORTER_OTLP_HEADERS) stay on the server; the browser script exports without them, and a startup warning names the exporter. Move a key that is meant to be public toBrowserHeaders, or switch to the Relay transport. WebAssembly and Hybrid are unchanged. - The page never gets an OTLP endpoint's user info.
https://user:token@...reaches the browser script withoutuser:token@, on every host, and a server warns at startup. NeitherHttpClientnor the browser ever sent it: put the credential inHeaders(anAuthorizationheader). - Every string attribute is scrubbed on the .NET side too, as the browser script always did: structured log
values and event attributes lose e-mail addresses, tokens, card numbers and IPv4 addresses.
client.addressbecomes[ip]. Turn pattern scrubbing off withPrivacy.ScrubPatterns = false. - Credential-like attribute names are
REDACTED(Privacy.RedactedAttributes: password, secret, token, API key, authorization, cookie names). Remove a name from the list in code (options.Privacy.RedactedAttributes.Remove("token")) to keep its values. - Pre-signed storage URLs lose their signatures (
X-Amz-Signature,X-Goog-Signature, ...;RedactQueryParameters). - A server lists no exporter URLs in the page's
ignoreUrls. A WebAssembly client's .NET exports carry a fetch option the script recognizes, so they are not traced even by a script the server started (Auto). - On Blazor Server, relayed batches are untrusted input: accepted only with the Relay transport, rate-limited per circuit, and stamped with the server's service identity but not its host, process or cloud attributes.
Delivery
- OTLP retries 429, 502, 503 and 504 only, as the OTLP specification requires; 408, 439 and 500 from an OTLP backend are final (dropped, not persisted, with a warning). Azure Monitor keeps its own list.
- Rejected requests and OTLP partial successes are warnings in the
Bit.Belemetrycategory (they were Debug). - During unload, a request that cannot get
keepalive(over the 64 KiB or 9-request budget) is stored for the next visit instead of being sent and refused. - Stored requests are resent oldest first, one attempt each; a failure holds back only the backlog of its URL.
- .NET exports send
User-Agent: Bit.Belemetry/<version>unless an exporter header sets one.
Collected data
- Errors are deduplicated and capped on client hosts: an identical error within
Errors.DuplicateWindow(5 s) is suppressed, and at mostErrors.MaxPerMinute(100) are recorded per minute.TimeSpan.Zero/0switch them off. Script error.is ignored by default (Browser.IgnoreErrorMessages).- Azure Monitor gets the events of a span: an exception recorded on it (
Activity.AddException) as anexceptionsitem, any other event (the document-load span's timings, for one) as atracesitem - as the Azure Monitor OpenTelemetry exporter sends them; they were dropped. Expect more items. An exception also logged under its span is still one item, andbelemetry.records.exportedcounts them for Azure Monitor. - Azure Monitor items carry their sampling rate (
sampleRate, was always 100). WithTraces.SamplingRatioorBrowser.SessionSampleRatiobelow 1, Application Insights now counts each sampled item for the ones dropped, so request, dependency and page view counts and every chart built onitemCountrise to the real numbers. Live Metrics' rates and custom charts count them the same. A sampling processor setsBelemetrySpan.SamplingRatio/BelemetryLogRecord.SamplingRatiofor the same. A kept trace carries its ratio intracestate(belemetry=sr:...), so withHttpActivityPropagationSupporton in WebAssembly a cross-origin API must allowtracestatein CORS too. On a server, a relayed record of a session the ratio does not keep is dropped (sampled_out). - An exception recorded on a span needs
Errors: without it the exception (even a stack trace alone) comes off the span (for every exporter, OTLP included) and the span is sent without it. The error limits apply to it too - in the browser as well, to an exception an app processor adds to a span. - Strings longer than
MaxValueLength(32768) are cut.0keeps them whole. - The script's
trackExceptionhas a scope of its own,bit.belemetry.exceptions(it wasbit.belemetry.errors, the scope of theerrorandunhandledrejectionhandlers), so an exporter tells an exception the app caught from one nothing caught. A Blazor Server component'sIBelemetry.TrackExceptiongoes through it too. - An aborted fetch/XHR is not an error span (it keeps
error.type = AbortError); unknown HTTP methods are_OTHER. - New records:
browser.rage_clickevents (Browser.TrackRageClicks), page views of typerestoreand a new web-vitals generation after a back/forward-cache restore. - Unhandled .NET exceptions are recorded (
AppDomain.UnhandledException,TaskScheduler.UnobservedTaskException) asErrorsevents, and a crashing process saves its queue for the next launch (withPersistence). Switch it off withErrors.CaptureUnhandledExceptions = false, e.g. when the app records them itself. Forward the crashes Android, WinUI and WinForms report elsewhere withTrackBitBelemetryUnhandledException. See Unhandled exceptions. - Page views carry
url.template, and an untitled one is named after it (/orders/{id:int}, was/orders/42): in the log record's body, the Application InsightspageViewsname andai.operation.name, and thePageNameofPageVisitTime. Group byurl.pathfor the old breakdown. See Page route templates. - A page view is named after the name given to
TrackPageViewon every host, before its page's title: in Application Insights the name was the title (or the path) when the script recorded it (Server), and aPageVisitTime'sPageNamefollows. A .NETTrackPageViewwithout a URL takes the current page's (NavigationManager.Uri), as the script takeslocation.href. OTEL_SDK_DISABLED=trueturns Belemetry off, overriding code.
Production diagnostics (opt-in, BelemetryOptions.Diagnostics; see
Production diagnostics and support bundles)
AddBitBelemetryServicesregisters the framework's componentActivitySource(AddComponentsTracing, public and idempotent) in WebAssembly and Hybrid too, as the server already did. WithTracescollected, theMicrosoft.AspNetCore.Components.NavigateandHandleEventspans (in the defaultMicrosoft.AspNetCore.Components*sources) are now exported from those hosts, and logs written inside an event handler carry its trace id. Drop them withTraces.IgnoredActivityNames.- A browser session sampled out of the export (
SessionSampleRatio) still keeps its local diagnostic timeline when diagnostics are on; nothing more is exported. - New APIs:
IBelemetryDiagnostics,BelemetryDiagnosticsOptions,BelemetryDiagnosticCapture,<BelemetryCapturePreview>,BelemetrySupportBundle,BelemetryIssueReport,BelemetryExceptionFingerprint,IBelemetrySnapshotProvider(AddBitBelemetrySnapshotProvider<T>()),IBelemetryExceptionClassifier,IBelemetryDiagnosticSanitizer,IBelemetryComponentContextProvider,IBelemetryDiagnosticComponentFilter; in the scriptgetDiagnostics,getDiagnosticsStream,clearDiagnosticsanddownloadFile.
New APIs: BelemetryOtlpExporterOptions.BrowserHeaders, BelemetryOptions.Errors, BelemetryOptions.MaxValueLength,
BelemetryPrivacyOptions.RedactedAttributes, BelemetryBrowserOptions.TrackRageClicks, the Bit.Belemetry meter, and
in the script addProcessor, setAttributes and getStats. Since: BelemetryOptions.AddSentryExporter
(BelemetrySentryExporterOptions, BelemetryOptions.SentryExporters), BelemetrySpan.SamplingRatio and
BelemetryLogRecord.SamplingRatio.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.0 && < 11.0.0)
-
net11.0
- Microsoft.AspNetCore.Components.Web (>= 11.0.0 && < 12.0.0)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Bit.Belemetry:
| Package | Downloads |
|---|---|
|
Bit.Belemetry.Hangfire
Bit.Belemetry.Hangfire |
|
|
Bit.Belemetry.EntityFrameworkCore
Bit.Belemetry.EntityFrameworkCore |
|
|
Bit.BevTools.Belemetry
bit Belemetry integration for bit BevTools: a Support panel with Belemetry's diagnostic timeline and exception groups, a jump from a failure to its component in the tree, support bundle preview, export and import - and the component tree and render counts BevTools observes, added to Belemetry's captures. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 11.0.0-pre-19 | 0 | 11/13/2026 |
| 11.0.0-pre-18 | 267 | 11/11/2026 |
| 11.0.0-pre-17 | 874 | 11/4/2026 |
| 11.0.0-pre-16 | 418 | 11/2/2026 |
| 11.0.0-pre-15 | 100 | 10/27/2026 |
| 11.0.0-pre-14 | 69 | 10/26/2026 |
| 11.0.0-pre-13 | 75 | 10/26/2026 |
| 11.0.0-pre-12 | 72 | 10/26/2026 |
| 11.0.0-pre-11 | 82 | 10/25/2026 |
| 11.0.0-pre-10 | 91 | 10/20/2026 |
| 10.7.0 | 146 | 10/25/2026 |
| 10.7.0-pre-04 | 121 | 10/25/2026 |
| 10.7.0-pre-03 | 116 | 10/22/2026 |
| 10.7.0-pre-02 | 119 | 10/22/2026 |