Files
suaveplan/packages/api-stack/api/api-rbac/src/instruments/instruments.md
2026-05-27 23:29:53 +01:00

4.0 KiB

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

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:

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.