Skip to content

.NET instrumentation

On the .NET side Belemetry listens with the same BCL primitives the OpenTelemetry SDK is built on - an ILoggerProvider, an ActivityListener and a MeterListener - so your ILogger calls, ActivitySource spans and Meter instruments are collected without referencing Belemetry anywhere in the code that produces them.

In this demo the .NET side runs in WebAssembly: these buttons produce records in your browser's .NET runtime, which exports them as OTLP/JSON to this site's own receiver (their source in the feed is wasm). Logs below Error need the Logs feature, spans Traces, metrics Metrics, and Error/Critical logs Errors - grant them on the consent page if a feed says nothing is granted. Metrics are exported every 10 s here (60 s by default).

Logs

Every ILogger entry at or above Logs.MinimumLevel (Information by default) becomes a log record: the formatted message as the body, the category as the instrumentation scope, the structured values of the message template as attributes, the EventId as log.event.id and log.event.name when it is set, the values of the active scopes (IncludeScopes, on by default), an exception as exception.type, exception.message and exception.stacktrace, and the trace context of the current Activity. Entries at Error or Critical, or carrying an exception, belong to the Errors feature; the rest to Logs.

Trace and Debug are below the minimum level, so their buttons produce nothing - which is the point. IncludeMessageTemplate keeps the raw template as log.record.template, and ExcludedCategories drops category prefixes outright (Belemetry's own diagnostics category, Bit.Belemetry, is always excluded, so its diagnostics never feed back into its pipeline).

LiveILogger
The live feed starts once the page is interactive.

No .NET logs yet - press one of the level buttons above.

Per-category levels use the standard logging configuration, under the Belemetry provider alias - so Belemetry can collect less than the console shows, or more:

Traces

The activities of every source named in Traces.Sources are recorded as spans - by default System.Net.Http, Microsoft.AspNetCore.Components* and Bit.Belemetry.*, the instrumentation packages (and Microsoft.AspNetCore for the server pipeline). A trailing * matches a prefix, so MyApp* collects MyApp.Checkout and MyApp.Search alike. This demo adds its own Bit.Belemetry.Demo source. 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.

Sampling is parent-based: a span whose parent was sampled is recorded, a new trace is recorded with probability Traces.SamplingRatio (1.0), deciding on the trace id the way OpenTelemetry's TraceIdRatioBased sampler does - so every service in a trace makes the same decision. Without Traces consent, activities are still created for propagation, so trace context keeps flowing to downstream services, but nothing is recorded. MaxEventsPerSpan and MaxLinksPerSpan (128 each) cap what one span can carry.

Sampling at the end of a span - keep every failure and every slow span, a fraction of the rest - is a processor. BelemetrySampler is public so the processor makes the same deterministic, trace-wide decision the ratio sampler makes, and the kept fraction is whole traces rather than scattered spans.

LiveActivitySource

A root demo.checkout span, a demo.validate-cart child, an HttpClient call to /api/demo/echo (a System.Net.Http client span), and a demo.charge-card child that fails with an exception event.

The live feed starts once the page is interactive.

No .NET spans yet - press the button above.

// Program.cs
builder.Services.AddBitBelemetryServices(options =>
{
    options.Traces.Sources.Add("MyApp*");          // MyApp, MyApp.Checkout, MyApp.Search, ...
    options.Traces.SamplingRatio = 0.25;           // a quarter of new traces; children follow their parent
    options.Traces.IgnoredActivityNames.Add("Microsoft.AspNetCore.Components.HandleEvent");   // never created
});

// Anywhere: plain System.Diagnostics, no reference to Belemetry.
public sealed class Checkout(HttpClient http)
{
    private static readonly ActivitySource Source = new("MyApp.Checkout");

    public async Task PlaceOrderAsync(Cart cart)
    {
        using var activity = Source.StartActivity("PlaceOrder");
        activity?.SetTag("cart.items", cart.Items.Count);

        await http.PostAsJsonAsync("api/orders", cart);     // a System.Net.Http child span

        try
        {
            await ChargeAsync(cart);
        }
        catch (Exception ex)
        {
            activity?.AddException(ex);                  // an "exception" span event
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }
}

Metrics

The instruments of every meter named in Metrics.Meters are aggregated in process - by default System.Net.Http, Microsoft.AspNetCore.Components* and System.Runtime, with the same trailing-* patterns. Counters and up-down counters become sums, histograms become explicit-bucket histograms (HistogramBoundaries; empty by default, which means the OpenTelemetry defaults, unless the instrument advises its own on .NET 9+), and gauges keep their last value.

Metrics are collected and exported every ExportInterval (60 s; 10 s in this demo) with Temporality - cumulative by default, as OTLP expects; Azure Monitor always gets deltas. Each instrument keeps at most CardinalityLimit (2000) attribute sets; measurements with new sets past it are folded into one point carrying otel.metric.overflow = true, so a user id used as a tag by mistake cannot exhaust memory.

In WebAssembly, the System.Diagnostics.Metrics feature switch is off by default, which leaves every Meter inert. The Bit.Belemetry package turns it back on (it sets MetricsSupport to true unless the project sets it), and Belemetry logs a warning at startup when a project turns it off again. This demo references the library as a project, so its client imports the package's build props itself; without them, the buttons below would produce nothing.

LiveMeter
demo.button.presses
0
demo.work.duration (last)
-
demo.cart.items
0
The live feed starts once the page is interactive.

No metrics yet - they are exported every 10 s in this demo.

// Program.cs
builder.Services.AddBitBelemetryServices(options =>
{
    options.Metrics.Meters.Add("MyApp*");
    options.Metrics.ExportInterval = TimeSpan.FromSeconds(30);
    options.Metrics.Temporality = BelemetryMetricTemporality.Delta;      // for backends that want deltas
    options.Metrics.HistogramBoundaries = [0, 10, 50, 100, 500, 1000];   // empty = the OpenTelemetry defaults
    options.Metrics.CardinalityLimit = 500;
});

// Anywhere: plain System.Diagnostics.Metrics.
public static class ShopMetrics
{
    private static readonly Meter Meter = new("MyApp.Shop");

    public static readonly Counter<long> Orders = Meter.CreateCounter<long>("shop.orders", "{order}");
    public static readonly Histogram<double> CheckoutDuration = Meter.CreateHistogram<double>("shop.checkout.duration", "ms");
    public static readonly UpDownCounter<long> CartItems = Meter.CreateUpDownCounter<long>("shop.cart.items", "{item}");
}

ShopMetrics.Orders.Add(1, new KeyValuePair<string, object?>("payment.method", "card"));
ShopMetrics.CheckoutDuration.Record(elapsed.TotalMilliseconds);
ShopMetrics.CartItems.Add(-1);

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 - Bit.Belemetry.EntityFrameworkCore and Bit.Belemetry.Hangfire - 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; any other ActivityListener can record them too.

// dotnet add package Bit.Belemetry.EntityFrameworkCore
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
    }));

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.type and an error status. CaptureQueryText adds db.query.text; Filter skips commands, say a job store's. Application Insights shows them as database dependencies.

Hangfire: a consumer span JOB {Type}.{Method} per execution with job.id, job.type, job.method, job.queue and job.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 with error.type when the job throws. Application Insights shows them as requests.

API

options.Logs:

BelemetryLoggingOptions Class

ILogger collection.

Name Type and default Description
MinimumLevel LogLevel= LogLevel.Information The minimum level collected. Defaults to LogLevel.Information. Per-category levels can also be set through the standard logging configuration under the Belemetry provider alias ("Logging": { "Belemetry": { "LogLevel": { ... } } }).
IncludeScopes bool= true Whether the values of active logging scopes become attributes. Defaults to true.
IncludeMessageTemplate bool= false Whether the message template of structured entries is kept as log.record.template. Defaults to false.
ExcludedCategories List<string>= [] Category prefixes never collected (Belemetry's own categories are always excluded).

options.Traces:

BelemetryTracingOptions Class

Activity collection.

Name Type and default Description
Sources List<string>= ["System.Net.Http", "Microsoft.AspNetCore.Components*", "Bit.Belemetry.*"] The ActivitySource names collected; a trailing * matches a prefix. Defaults to System.Net.Http, Microsoft.AspNetCore.Components* and Bit.Belemetry.* (the instrumentation packages, such as Bit.Belemetry.EntityFrameworkCore and Bit.Belemetry.Hangfire), plus Microsoft.AspNetCore for the server pipeline. Add your own sources here.
IgnoredActivityNames List<string>= [] Activity names (Activity.OperationName; a trailing * matches a prefix) never recorded, decided when the activity is created so it costs nothing - e.g. Microsoft.AspNetCore.Components.HandleEvent to drop Blazor's per-event spans. The children of an ignored activity attach to its parent. Empty by default.
SamplingRatio double= 1 The fraction of new traces recorded (parent-based: a child follows its parent's decision). Defaults to 1.0.
MaxEventsPerSpan int= 128 The maximum number of events kept per span. Defaults to 128.
MaxLinksPerSpan int= 128 The maximum number of links kept per span. Defaults to 128.

options.Metrics:

BelemetryMetricsOptions Class

Meter collection.

Name Type and default Description
DefaultHistogramBoundaries double[]= [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000] The OpenTelemetry default histogram bucket boundaries, used while BelemetryMetricsOptions.HistogramBoundaries is empty.
Meters List<string>= ["System.Net.Http", "Microsoft.AspNetCore.Components*", "System.Runtime"] The Meter names collected; a trailing * matches a prefix. Defaults to System.Net.Http, Microsoft.AspNetCore.Components* and System.Runtime (plus the ASP.NET Core hosting and Kestrel meters for the server pipeline). Add your own meters here.
ExportInterval TimeSpan= 00:01:00 How often metrics are collected and exported. Defaults to 60 seconds (the OpenTelemetry default).
Temporality BelemetryMetricTemporality= BelemetryMetricTemporality.Cumulative The aggregation temporality sent over OTLP. Defaults to cumulative (Azure Monitor always gets deltas).
HistogramBoundaries double[]= [] The histogram bucket boundaries, used unless an instrument advises its own. Empty, the default, means BelemetryMetricsOptions.DefaultHistogramBoundaries - empty rather than those values themselves because a configuration binder appends to an array it binds, and boundaries must replace the defaults, never grow them.
CardinalityLimit int= 2000 The maximum number of attribute sets per instrument (the OpenTelemetry default is 2000). Measurements with new attribute sets past it are folded into one point carrying otel.metric.overflow = true.
An unhandled error has occurred. Reload 🗙