.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.
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).
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.
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.
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.
- demo.button.presses
- 0
- demo.work.duration (last)
- -
- demo.cart.items
- 0
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. |