# `instruments` — `api.rbac.denials_total` _(no submodules)_ ## Purpose Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` registry against a caller-supplied `MeterProvider`. The middleware itself never touches the global OTel API directly — every emission flows through `recordRbacDenial(instruments, { plugin, route, reason })`, which guarantees the three label keys are populated on every call and the base attributes from the host (host-id, host-version, etc.) are merged in. Centralising emission here lets the spec-conformance test pin the exact counter name, label set, and reason vocabulary against a real `MeterProvider` from `@opentelemetry/sdk-metrics` — empirically the class of regression that broke production dashboards in two of three prior incidents was a label-key rename slipping past lint+typecheck. ## Features - Single counter `api.rbac.denials_total` with description, created on the `@suaveplan/api-rbac` meter. - Closed `RbacDenialReason` union — `"missing-scope" | "rule-deny" | "no-principal"` — so dashboard authors can rely on a finite label set. - Frozen `RBAC_DENIAL_REASONS` and `RBAC_DENIAL_LABEL_KEYS` tuples for tests and metric-discovery tooling. - Base attributes merged on every emission; host injects host-id / host-version at boot. - `MeterProvider` is injectable — tests use a real `MeterProvider` from `@opentelemetry/sdk-metrics`, production resolves through `metrics.getMeterProvider()`. ## Usage: Basic ```ts import { createRbacInstruments, recordRbacDenial, } from "@suaveplan/api-rbac/instruments"; const instruments = createRbacInstruments(); recordRbacDenial(instruments, { plugin: "billing", route: "/invoices", reason: "missing-scope", }); ``` ## Usage: Advanced Inject a real `MeterProvider` and a `PeriodicExportingMetricReader` with an `InMemoryMetricExporter` to assert the exact counter emissions in a conformance test: ```ts import { metrics } from "@opentelemetry/api"; import { InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; import { createRbacInstruments, RBAC_DENIALS_TOTAL_NAME, recordRbacDenial, } from "@suaveplan/api-rbac/instruments"; const exporter = new InMemoryMetricExporter(0); const reader = new PeriodicExportingMetricReader({ exporter }); const provider = new MeterProvider({ readers: [reader] }); const instruments = createRbacInstruments({ meterProvider: provider, baseAttributes: { host: "h-1" }, }); recordRbacDenial(instruments, { plugin: "billing", route: "/x", reason: "rule-deny", }); await provider.forceFlush(); const dump = exporter.getMetrics(); // dump[0].scopeMetrics[0].metrics carries RBAC_DENIALS_TOTAL_NAME … ``` ## API Reference - `RBAC_DENIALS_TOTAL_NAME` — string literal `"api.rbac.denials_total"`. - `RBAC_METER_NAME` — string literal `"@suaveplan/api-rbac"`. - `RBAC_DENIAL_REASONS` — frozen tuple of legal reason labels. - `RBAC_DENIAL_LABEL_KEYS` — frozen tuple `["plugin", "route", "reason"]`. - `RbacDenialReason` — `"missing-scope" | "rule-deny" | "no-principal"`. - `RbacInstruments` — `{ denials, baseAttributes }`. - `RbacInstrumentsOptions` — `{ meterProvider?, baseAttributes? }`. - `createRbacInstruments(options?)` — factory. - `recordRbacDenial(instruments, attrs)` — emission helper. ## Implementation Notes The factory falls back to `metrics.getMeterProvider()` only when no provider is supplied; production hosts always pass their own provider so SDK installation is opt-in. The counter description is load-bearing for the OTel collector's `view` / `attribute_filter` configuration — operators rely on it to scope the metric to particular pipelines without re-discovering it. `Object.freeze` on the registry plus `Object.freeze` on the base attribute bag makes accidental in-place mutation throw in strict mode, which is the class of bug `tslib` + `verbatimModuleSyntax` cannot catch.