Skip to content

BCL Instrumentation (Meter + ActivitySource)

NBenchmark emits first-class System.Diagnostics BCL instrumentation from the same emit points that feed IMeasurementObserver. No NuGet packages are required -- Meter and ActivitySource are part of the .NET BCL since .NET 8. When no OpenTelemetry SDK or listener is attached, the BCL internal checks ensure near-zero overhead.

Instrument naming

All instrument and tag names use the nbenchmark.* namespace for OpenTelemetry compatibility:

InstrumentTypeUnitDescription
nbenchmark.sample.durationHistogramns/opPer-op sample duration
nbenchmark.alloc.bytes_per_opHistogramB/opPer-op allocation delta (recorded per sample)
nbenchmark.outliers.removedCountersamplesCumulative outliers removed
nbenchmark.outliers.removed_totalObservableGaugesamplesRunning total of removed outliers
nbenchmark.jitter.detector_switchesCounterswitchesOutlier-detector auto-switches triggered by jitter
nbenchmark.gc.gen0CountercollectionsGeneration 0 GC collections during measurement
nbenchmark.gc.gen1CountercollectionsGeneration 1 GC collections during measurement
nbenchmark.gc.gen2CountercollectionsGeneration 2 GC collections during measurement
nbenchmark.ci.relative_half_widthObservableGaugeratioCI relative half-width of the running mean
nbenchmark.jitter.metricObservableGaugeratioHost jitter metric (MAD / median)
nbenchmark.sample.mean_per_opObservableGaugens/opRunning mean per-op duration
nbenchmark.ops_per_secondObservableGaugeops/sRunning operations per second (1e9 / mean per-op ns)
nbenchmark.samples.countObservableGaugesamplesRunning sample count

Trace span hierarchy

NBenchmark emits nested Activity spans that render the autotune lifecycle as a flame-graph-shaped trace:

benchmark.suite
  └── benchmark.run
        ├── nbenchmark.phase.jitter
        ├── nbenchmark.phase.calibration
        ├── nbenchmark.phase.warmup
        └── nbenchmark.phase.measurement
  • benchmark.suite (root): created at OnSuiteStarting, tags include nbenchmark.suite.name, nbenchmark.suite.benchmark_count, nbenchmark.profile, nbenchmark.runtime, nbenchmark.seed, nbenchmark.run_order; stopped at OnSuiteCompleted with nbenchmark.suite.result_count.
  • benchmark.run (per-benchmark): created at OnBenchmarkRunStarting, tags include nbenchmark.name, nbenchmark.class, nbenchmark.baseline, nbenchmark.parameter_set; stopped at OnBenchmarkRunCompleted with nbenchmark.result.median_ns, nbenchmark.result.mean_ns, nbenchmark.result.sample_count, nbenchmark.result.outliers_removed.

Phase spans

Each phase transition creates an Activity span named nbenchmark.phase.<phase> where <phase> is one of jitter, calibration, warmup, or measurement. Phase spans nest under their parent benchmark.run span. Tags include:

TagSet onValue
nbenchmark.benchmark.namestart + stopBenchmark name
nbenchmark.phasestart + stopPhase enum name
nbenchmark.sample_stop_reasonstop (measurement)Why measurement ended
nbenchmark.warmup_stop_reasonstop (warmup)Why warmup ended
nbenchmark.resolved_kstop (calibration)Calibrated ops-per-sample count
nbenchmark.resolved_warmupstop (warmup)Resolved warmup iteration count
nbenchmark.jitter_metricstop (jitter)Host jitter metric value
nbenchmark.detector_switchedstop (jitter)Whether the outlier detector was auto-switched

Span events

Span events are discrete annotations on a phase span that explain why a phase ended. A trace UI renders these as markers on the flame-graph row, making the autotune decision visible at a glance:

EventParent spanFired whenKey tags
detector.switchednbenchmark.phase.jitterThe outlier detector auto-switched IQR -> MADnbenchmark.from, nbenchmark.to, nbenchmark.jitter_metric
warmup.plateau_reachednbenchmark.phase.warmupWarmup stopped because the body settled (plateau rule)-
measurement.ci_target_metnbenchmark.phase.measurementMeasurement stopped because the CI half-width target was metnbenchmark.achieved_ci_width, nbenchmark.ci_target
phase.cap_hitnbenchmark.phase.warmup / nbenchmark.phase.measurementA phase ended early at the wall-clock tuning cap-

Getting started with OpenTelemetry

Install the OpenTelemetry SDK and the OTLP exporter:

bash
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol

Then configure in your application:

csharp
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

using var meterProvider = Sdk.CreateMeterProviderBuilder()
    .AddMeter("NBenchmark")
    .AddOtlpExporter()
    .Build();

using var tracerProvider = Sdk.CreateTracerProviderBuilder()
    .AddSource("NBenchmark")
    .AddOtlpExporter()
    .Build();

All NBenchmark instruments are automatically picked up when a Meter named NBenchmark or an ActivitySource named NBenchmark is subscribed to.

Resource attributes

Every benchmark.suite span is stamped with resource attributes that identify the run across commit, branch, CI pipeline, and machine. A downstream backend (Grafana, Jaeger, Honeycomb) can join on these to render cross-commit trend lines and regression alarms without NBenchmark shipping its own storage layer.

The attributes are read once per process from environment variables and cached for the process lifetime.

CI identification

AttributeSource env vars
nbenchmark.ci_providerGITHUB_ACTIONS, GITLAB_CI, AZURE_PIPELINES/TF_BUILD, CIRCLECI, APPVEYOR, TEAMCITY_VERSION, JENKINS_URL, TRAVIS, BUILDKITE
nbenchmark.ci_run_idGITHUB_RUN_ID, CI_PIPELINE_ID, BUILD_BUILDID, CIRCLE_BUILD_NUM, APPVEYOR_BUILD_ID, TEAMCITY_BUILDID, BUILDKITE_BUILD_ID, TRAVIS_BUILD_ID
nbenchmark.ci_run_urlGITHUB_SERVER_URL, CI_JOB_URL, BUILD_BUILDURI, CIRCLE_BUILD_URL
nbenchmark.ci_repositoryGITHUB_REPOSITORY, CI_REPOSITORY_URL
nbenchmark.ci_refGITHUB_REF, CI_COMMIT_REF_NAME
nbenchmark.ci_attemptGITHUB_RUN_ATTEMPT

Git identification

AttributeSource env varsFallback
nbenchmark.commit_shaGITHUB_SHA, CI_COMMIT_SHA, GIT_COMMITgit rev-parse --short HEAD
nbenchmark.branchGITHUB_HEAD_REF, CI_COMMIT_BRANCH, GIT_BRANCHgit rev-parse --abbrev-ref HEAD (detached HEAD produces no branch attribute)

CI-sourced values take precedence over the git CLI fallback. When no CI or git env vars are present and the git CLI is unavailable (or outside a repo), the commit and branch attributes are omitted.

Host identification

AttributeValue
nbenchmark.host.machine_nameEnvironment.MachineName
nbenchmark.host.oswindows, macos, or linux
nbenchmark.host.archarm64, x64, x86, etc.
nbenchmark.host.runtimeRuntimeInformation.FrameworkDescription (e.g. .NET 8.0.22)

OpenTelemetry-standard env vars

OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME are honoured verbatim. OTEL_RESOURCE_ATTRIBUTES is parsed as a comma-separated key=value list (the OTel convention) and each pair is copied onto the span. OTEL_SERVICE_NAME is mapped to service.name. A user who has already configured these for the rest of their service does not need to repeat themselves.

Cross-process streaming

Harness mode runs each discovered class in an isolated child process by default. The in-memory IMeasurementObserver callback cannot cross the process boundary, so OTLP export is the cross-process channel: instrument the child, point its exporter at a collector, and live telemetry crosses the process boundary cleanly.

Env-var forwarding

ChildProcessLauncher forwards the following environment variables from parent to every spawned child:

Env varPurpose
OTEL_EXPORTER_OTLP_ENDPOINTOTLP exporter endpoint
OTEL_EXPORTER_OTLP_PROTOCOLOTLP transport (grpc or http/protobuf)
OTEL_EXPORTER_OTLP_HEADERSOTLP exporter headers (e.g. auth)
OTEL_EXPORTER_OTLP_TIMEOUTOTLP export timeout
OTEL_RESOURCE_ATTRIBUTESResource attributes (passed through)
OTEL_SERVICE_NAMEService name (passed through)
NBENCHMARK_OTEL_ENDPOINTNBenchmark-specific endpoint mirror (see --otlp-endpoint CLI flag)

When NBENCHMARK_OTEL_ENDPOINT is set and OTEL_EXPORTER_OTLP_ENDPOINT is not, the launcher mirrors it into OTEL_EXPORTER_OTLP_ENDPOINT so an SDK wired only against the standard variable picks it up without extra configuration.

--otlp-endpoint CLI flag

bash
dotnet run -- --otlp-endpoint http://localhost:4317

The harness mirrors this into OTEL_EXPORTER_OTLP_ENDPOINT before spawning isolated children, so children stream to the same collector as the parent. When the user has already set OTEL_EXPORTER_OTLP_ENDPOINT explicitly, the CLI flag does not override it.

Observer forwarding

When --observer <name> is supplied, the parent forwards the observer names to isolated children via the IsolatedRunRequest.ObserverNames field. The child resolves each name through ObserverRegistry (populated identically by [ModuleInitializer] self-registration in the child's fresh process), so the same observers fire in the child as in the parent. Programmatic observers added via WithObserver(IMeasurementObserver) are live objects and cannot cross a process boundary; only registry-resolvable names are forwarded.

Topology

In-process / local dev:
  AdaptiveLoop -> Observer shim -> Embedded web host -> React SPA in browser

Isolated / CI:
  Child process -> OTLP -> Collector
  Host process  -> OTLP -> Collector
  Collector -> Grafana / Jaeger / Honeycomb

In-process and isolated runs look identical to the dashboard: both are OTLP producers.

See also

  • docs/reference/observers.md - the IMeasurementObserver interface and event types.
  • docs/reference/cli.md - the --otlp-endpoint CLI flag.
  • docs/statistics/diagnostics.md - runtime diagnostics counters (GC, heap, exceptions, CPU).

Released under the MIT License.