MeterBoard 0.2.0
dotnet add package MeterBoard --version 0.2.0
NuGet\Install-Package MeterBoard -Version 0.2.0
<PackageReference Include="MeterBoard" Version="0.2.0" />
<PackageVersion Include="MeterBoard" Version="0.2.0" />
<PackageReference Include="MeterBoard" />
paket add MeterBoard --version 0.2.0
#r "nuget: MeterBoard, 0.2.0"
#:package MeterBoard@0.2.0
#addin nuget:?package=MeterBoard&version=0.2.0
#tool nuget:?package=MeterBoard&version=0.2.0
MeterBoard
Dashboards as code for .NET: derive Grafana dashboards and Prometheus alert rules from the System.Diagnostics.Metrics instruments your app already defines. Zero external dependencies.
var meter = new Meter("checkout-api");
var requests = meter.CreateCounter<long>("http.requests", unit: "{request}", description: "Handled requests");
var errors = meter.CreateCounter<long>("http.request.errors", unit: "{request}", description: "Failed requests");
var duration = meter.CreateHistogram<double>("http.request.duration", unit: "s", description: "Request duration");
string dashboardJson = MeterBoard.QuickDashboard(meter, "checkout-api").ToJson();
string rulesYaml = RuleFile.Create()
.AddSlo(Slo.For("checkout-availability")
.ErrorRatio("http_request_errors_total", "http_requests_total")
.WithObjective(99.9)
.WithWindow(days: 30))
.ToYaml();
Those two strings are a complete observability setup. The dashboard JSON imports into Grafana as a RED dashboard for the service: a Rate row with a requests-per-second timeseries and a running total stat, an Errors row with an error-rate panel carrying a red threshold, and a Duration row with p50/p95/p99 latency curves plus throughput, all wired to a datasource picker at the top of the page. The YAML is a Prometheus rule file with seven error-ratio recording rules and two multiwindow burn-rate alerts (page and ticket) that promtool check rules accepts as-is.
Your instruments already describe your dashboard: every counter is a rate panel waiting to happen, every histogram is a latency chart, every unit and description is panel metadata. MeterBoard turns that description into artifacts you commit, so the dashboard gets reviewed in the same PR as the code that emits the metrics.
Why this exists
Grafana ships official dashboard-codegen SDKs for Go, TypeScript, Python, PHP and Java, and closed the C# request: grafana-foundation-sdk#745, "not on the roadmap at this time". On NuGet, prometheus-net has 315 million downloads for emitting metrics and there is nothing for generating the dashboards that display them. Meanwhile Python's grafanalib (hundreds of thousands of downloads a month) proves the dashboards-as-code demand. And no ecosystem, including Go's, derives dashboards from the code-level instrument definitions the app already owns; every existing tool makes you describe the dashboard a second time.
Surviving the schema treadmill
Grafana's dashboard schema changes constantly, and generators that chase it die: grafonnet-lib was deprecated after years of hand-maintaining the full panel schema. MeterBoard refuses that treadmill with three load-bearing decisions:
- A thin dashboard model. Exactly four panel types in v0.1: timeseries, stat, gauge, table. That covers the overwhelming majority of real service dashboards, and a small surface is one that can stay correct for years. More panel types are added on demonstrated demand, not speculatively.
- A pinned
schemaVersion. Every dashboard is emitted at schema version 39 (the Grafana 10.4 to 11.x line). Grafana migrates older schema versions forward automatically on import, which is exactly why grafanalib survives on a 2024 release. The constant isGrafanaSchema.Version; it moves rarely and deliberately. - Prometheus rule-file YAML for alerts, never Grafana alerting JSON. Grafana's unified-alerting provisioning API keeps churning; the Prometheus rule-file format has been stable for years and is consumed unchanged by Prometheus, Mimir, Thanos, the prometheus-operator and sloth. Alert rules belong in the years-stable format.
The generated JSON is deterministic and diff-friendly: fixed key order, \n newlines, byte-identical output across runs and operating systems (asserted in the test suite). Dashboards-as-code lives in git; output you cannot diff is output you cannot review.
Every generated dashboard declares one template variable of type datasource and points every panel at ${datasource} instead of a hardcoded datasource uid. That is what makes the JSON portable: it imports into any Grafana instance and the viewer picks their Prometheus datasource at the top of the page.
Install
dotnet add package MeterBoard
The package is MeterBoard; the namespace is MeterBoards (plural, so the MeterBoard entry-point class never collides with its own namespace, the same convention TaskGroup uses).
Scanning meters
MeterScan captures instrument definitions with a MeterListener, the reliable way to enumerate instruments: starting a listener replays a callback for every existing instrument, including observable ones, without recording a single measurement.
var scan = MeterScan.FromMeters(meter);
var dashboard = DashboardDeriver.Derive(scan, new DeriveOptions
{
Title = "checkout-api",
RateInterval = "1m",
MetricNameMapper = name => "shop_" + name.Replace('.', '_'),
});
MeterScan.FromMeters(params Meter[])captures the instruments of the meters you pass. Your app owns itsMeterobjects; hand them over and the scan is exact.MeterScan.FromListener(TimeSpan window)captures every instrument in the process: all pre-existing ones immediately, plus anything created during the window. Useful when meters are created at startup by code you do not control.- What is captured: instrument name, kind, unit, description. What is not: tag names, because tags are a runtime concept that arrives with each measurement and cannot be known statically. Derived queries therefore aggregate with
sum by (job)and leave finer slicing to dashboard variables.
Derivation conventions
| Instrument | Panels | Query shape |
|---|---|---|
Counter, ObservableCounter |
rate timeseries + total stat | sum by (job) (rate(name_total[5m])), sum(name_total) |
Histogram |
p50/p95/p99 timeseries + throughput | histogram_quantile(0.95, sum by (le) (rate(name_bucket[5m]))) |
UpDownCounter, ObservableUpDownCounter, ObservableGauge |
gauge + timeseries | sum(name), sum by (job) (name) |
Heuristics on top:
- Names containing
error,fail,faultorexceptionget a red threshold on the rate panel at an absolute value of 1 error per second. This is panel coloring only, a visual cue on the dashboard; it never pages. Alerting is the job of the rule file andSlo, not dashboard thresholds. The threshold value is fixed in v0.1; a per-name threshold is a roadmap item. - When a meter has rate, error and duration siblings (any two of the three), panels are grouped into RED rows: Rate, Errors, Duration, then Other. Otherwise one row per meter.
- Units drive Grafana formatting: instrument unit
sgives seconds,msmilliseconds,usmicroseconds,nsnanoseconds,minminutes,hhours,ddays,Bybytes,%percent. - Histogram throughput panels are labeled
reqps(requests per second) even for histograms that do not measure requests; v0.2 will refine this per instrument. - Histogram quantile panels aggregate across jobs, slicing by
leonly (sum by (le)). Per-job quantiles are a documented v0.1 limitation; a by-label option is on the roadmap.
Metric naming. MeterBoard assumes the OpenTelemetry Prometheus exporter conventions: characters outside [a-zA-Z0-9_] become underscores (runs collapse), a known unit appends a suffix (s to _seconds, ms to _milliseconds, us to _microseconds, ns to _nanoseconds, min to _minutes, h to _hours, d to _days, By to _bytes), and monotonic counters get _total. So http.requests becomes http_requests_total and http.request.duration with unit s becomes http_request_duration_seconds. If your exporter names things differently, set DeriveOptions.MetricNameMapper; it receives the raw instrument name and must return the final base metric name (MeterBoard appends nothing to mapper output except the structural _bucket/_count suffixes in histogram queries), and its output is validated against the Prometheus metric-name grammar. The mapper is Func<string, string> today; a Func<InstrumentInfo, string> sibling that also sees the kind and unit is a roadmap item, so this signature may gain an overload.
The raw builder
The derivation layer sits on a small fluent builder you can use directly:
var dashboard = Dashboard.Create("Payments")
.WithUid("payments")
.WithTags("payments", "team-core")
.WithRefresh("30s")
.WithTimeRange("now-6h", "now")
.WithVariable("job", "Job", "label_values(job)")
.AddRow("Traffic", row => row
.AddTimeSeries("Requests per second", p => p
.WithQuery("sum by (job) (rate(http_requests_total[5m]))", "{{job}}")
.WithUnit(Units.RequestsPerSecond))
.AddStat("Total requests", p => p
.WithQuery("sum(http_requests_total)"))
.AddGauge("Error ratio", p => p
.WithQuery("sum(rate(http_requests_total{code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m]))")
.WithUnit("percentunit")
.WithThreshold(0.01, "yellow")
.WithThreshold(0.05, "red"))
.AddTable("Top endpoints", p => p
.WithQuery("topk(10, sum by (path) (rate(http_requests_total[5m])))")));
string json = dashboard.ToJson();
Layout is auto-computed on Grafana's 24-column grid: rows are full-width headers, timeseries and table panels take half the width (12 units, height 8), stat and gauge panels a quarter (6 units, height 4), flowing left to right and wrapping. Any panel can pin itself with WithGridPos(x, y, w, h). Units holds the common Grafana unit ids (short, percent, s, ms, µs, ns, m, h, d, bytes, reqps); any Grafana unit id string is accepted. Dashboard.Save(path) writes the JSON to disk.
Rule files and SLOs
string yaml = RuleFile.Create()
.AddGroup("api-rules", g => g
.AddRecording("job:http_requests:rate5m", "sum by (job) (rate(http_requests_total[5m]))")
.AddAlert(
"HighErrorRate",
"sum(rate(http_requests_total{code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) > 0.05",
@for: "10m",
labels: new Dictionary<string, string> { ["severity"] = "page" },
annotations: new Dictionary<string, string> { ["summary"] = "Error rate above 5% for 10 minutes" }))
.ToYaml();
Every PromQL expression is emitted as a YAML literal block scalar (|). PromQL is full of braces, quotes and regex characters; inside a literal block none of them need escaping, so what you wrote is byte-for-byte what Prometheus parses. Labels and annotations are emitted sorted by key, rule names and durations are validated at build time, and the output is deterministic.
SLO burn-rate alerts
Slo generates the multiwindow multi-burn-rate pattern from the Google SRE Workbook (chapter 5, "Alerting on SLOs"), the same table sloth implements. A burn rate of 1 means consuming exactly the whole error budget over the SLO window.
| Severity | Burn rate | Long window | Short window | Budget consumed |
|---|---|---|---|---|
| page | 14.4 | 1h | 5m | 2% in 1 hour |
| page | 6 | 6h | 30m | 5% in 6 hours |
| ticket | 3 | 1d | 2h | 10% in 1 day |
| ticket | 1 | 3d | 6h | 10% in 3 days |
For Slo.For("checkout-availability").ErrorRatio(errors, total).WithObjective(99.9).WithWindow(days: 30), RuleFile.AddSlo emits seven recording rules (checkout_availability:sli_error:ratio_rate5m through ratio_rate3d, each labeled with the SLO name, objective and window) and two alerts: a page alert that fires when both windows of either page row exceed their threshold, and a ticket alert for the slow-burn rows. Thresholds are computed in decimal arithmetic, so a 99.9% objective produces exactly 14.4 * 0.001, never a floating-point smear. All values are named constants on SloBurnRates.
The SLO window is 30 days in v0.1. The recording windows (5m through 3d) and the annotation prose are calibrated for a 30-day budget, so WithWindow accepts only days: 30 and throws for any other value rather than emit rules that would page against the wrong budget. Arbitrary SLO windows are a roadmap item.
Validation
The committed golden fixtures are verified byte-for-byte by the test suite, and that suite runs on the three-OS CI matrix (Linux, Windows, macOS): the golden tests assert byte-equality, so any nondeterminism or newline drift across operating systems fails CI. Determinism is therefore matrix-proven, not just asserted on one machine.
The fixtures were also validated against the real tools: both rule-file fixtures pass promtool check rules (Prometheus 3.x), and all three dashboard fixtures import successfully into Grafana OSS (validated on the 11.x and 13.x lines) via the dashboards API, which also confirms schema version 39 auto-migrates cleanly. The derivation naming was checked against the OpenTelemetry .NET Prometheus exporter output.
Honest limitations
- Four panel types, by design. No heatmaps, logs, traces or flame graphs in v0.1; see the treadmill section for why the surface is small.
- Tags are unknowable statically. Instrument definitions do not carry tag names, so derived queries aggregate over everything and slice by
jobonly. - Exporter-naming assumptions. The default naming matches the OpenTelemetry Prometheus exporter; other exporters need a
MetricNameMapper. The OpenTelemetry Collector'sprometheusexporter behaves compatibly for the cases MeterBoard emits, but verify against your pipeline. - Prometheus only. No Loki, Tempo or CloudWatch datasources; the generated dashboards assume a Prometheus-compatible datasource (Prometheus, Mimir, Thanos, VictoriaMetrics).
- Grafana dashboard schema v2 is not targeted until it leaves the experimental phase; version 39 imports and auto-migrates cleanly on Grafana 10 through 13.
MeterScanreads live objects. Instruments must exist before the scan runs; instruments created lazily after startup needFromListenerwith a window, or a scan at a later point in the app lifecycle.
Roadmap
- A preset pack for the built-in ASP.NET Core meters (
Microsoft.AspNetCore.Hostingrequest duration and friends): one call, a complete request dashboard for any ASP.NET Core app. - More panel types, only on demonstrated demand.
- Grafana dashboard schema v2 when it stabilizes.
- Recording-rule generation for expensive derived queries (pre-aggregating the quantile panels).
- Arbitrary SLO windows (not just 30 days), re-tuning the recording windows and annotations per window.
QuantileByLabels: per-job (or per-label) histogram quantiles instead of thesum by (le)cross-job aggregation.- A
Func<InstrumentInfo, string>overload ofDeriveOptions.MetricNameMapperso custom naming can see the instrument kind and unit, not just the name. FromListenerAsync: a non-blocking listener scan that does not sleep the calling thread.- A per-name (or per-instrument) error-rate threshold instead of the fixed absolute value.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.