Metrics Conventions¶
Metrics use System.Diagnostics.Metrics with one meter per domain, exported through OpenTelemetry to Prometheus.
Telemetry class per domain¶
Each domain has a static Telemetry class with an internal static readonly Meter named YourApp.<Domain> and internal static readonly instruments.
internal static class Telemetry
{
internal static readonly Meter Meter = new("YourApp.Download");
internal static readonly Counter<long> Completed =
Meter.CreateCounter<long>("yourapp.download.completed_total");
internal static readonly Counter<long> Failed =
Meter.CreateCounter<long>("yourapp.download.failed_total");
internal static readonly Histogram<double> Duration =
Meter.CreateHistogram<double>("yourapp.download.duration_seconds", unit: "s");
private static int _queueSize;
internal static readonly ObservableGauge<int> QueueSize =
Meter.CreateObservableGauge("yourapp.download.queue_size", () => _queueSize);
internal static void SetQueueSize(int value) => _queueSize = value;
}
No abstraction
No base class, no shared IMetrics wrapper, no generic helper for meters. Each domain's Telemetry class is plain static code.
Registration¶
Register every meter centrally in the telemetry setup, then export with Prometheus.
services.AddOpenTelemetry()
.WithMetrics(m => m
.AddMeter("YourApp.Download")
.AddMeter("YourApp.Search")
.AddPrometheusExporter());
A meter that is not registered emits nothing, so add the .AddMeter(...) line in the same change that creates the meter.
Naming¶
| Element | Rule | Example |
|---|---|---|
| Meter | YourApp.<Domain> |
YourApp.Download |
| Instrument | yourapp.<domain>.<metric_name> in snake_case |
yourapp.download.duration_seconds |
| Counter | ends with _total |
yourapp.search.requests_total |
| Time histogram | ends with _seconds |
yourapp.search.duration_seconds |
Tags¶
- Short, lowercase names:
reason,source,type,status,phase. - Cardinality must stay bounded.
Never use unbounded values as tags
Entity IDs, file names, URLs, and query strings create one time series per value and will overwhelm the metrics backend. Map them to a small fixed set (reason=timeout) instead.
ObservableGauge¶
Use a static backing field with an internal static void Set*() setter. The actor (or service) calls the setter when the value changes. The gauge callback only reads the field.
This keeps the gauge callback free of actor state access and thread-safety concerns.
Instrument at the operation boundary¶
Record metrics where the operation completes, success or failure, not at each call site that starts it.
// Good: one place, covers every path
private void Complete(Stopwatch sw, bool ok)
{
Telemetry.Duration.Record(sw.Elapsed.TotalSeconds);
(ok ? Telemetry.Completed : Telemetry.Failed).Add(1);
}
This prevents double counting and missed failure paths when an operation has several entry points.