import { type Counter, type Histogram, metrics } from "@opentelemetry/api"; /** * OTel instruments for `@suaveplan/i18n-backend-http`. * * Built lazily via `metrics.getMeter('suaveplan.i18n-backend-http')`. * All instruments are no-ops when no MeterProvider is registered (the * default), so consumers pay zero overhead unless they wire up the SDK. * * In addition to emitting via OTel, this module retains a small * in-memory state ring so spec-conformance tests can assert exact * attribute payloads without standing up a full SDK + * InMemoryMetricExporter pipeline. Tests call * `resetHttpBackendMetricsState()` between cases and read * `getHttpBackendMetricsState()` to inspect counters / histogram * counts. */ export interface HttpBackendMetricSample { readonly attributes: Record; } export interface HttpBackendMetricsState { readonly readDurationMs: HttpBackendMetricSample[]; readonly readTotal: HttpBackendMetricSample[]; readonly readErrorsTotal: HttpBackendMetricSample[]; } interface CachedInstruments { readonly readDurationMs: Histogram; readonly readTotal: Counter; readonly readErrorsTotal: Counter; } let cached: CachedInstruments | undefined; const state: HttpBackendMetricsState = { readDurationMs: [], readTotal: [], readErrorsTotal: [], }; /** * Build (or return cached) OTel instruments. Idempotent — safe to call * from any production code path. */ export function getHttpBackendMetrics(): CachedInstruments { if (cached) return cached; const meter = metrics.getMeter("suaveplan.i18n-backend-http"); cached = { readDurationMs: meter.createHistogram( "i18n.backend.http.read_duration_ms", { description: "Duration in ms of an HttpBackend.read call; labels: locale, namespace, outcome.", unit: "ms", } ), readTotal: meter.createCounter("i18n.backend.http.read_total", { description: "HttpBackend.read call count; label: outcome (hit | miss | error).", }), readErrorsTotal: meter.createCounter( "i18n.backend.http.read_errors_total", { description: "HttpBackend.read error count; labels: code (the I18N error code), status (HTTP status when applicable).", } ), }; return cached; } /** * Record a `read` call's duration + outcome. Mirrors the * `read_total{outcome}` and per-error-code `read_errors_total{code,status?}` * counters and pushes one sample into the in-memory state ring per counter * touched. The optional `status` attribute is included on * `read_errors_total` when the error originated from a known HTTP status * code (404 / 5xx); transport / parse failures emit only `code`. */ export function recordRead( locale: string, namespace: string, outcome: "hit" | "miss" | "error", durationMs: number, errorCode?: string, status?: number ): void { const ins = getHttpBackendMetrics(); const histogramAttrs: Record = { locale, namespace, outcome, }; ins.readDurationMs.record(durationMs, histogramAttrs); state.readDurationMs.push({ attributes: histogramAttrs }); const aggregate: Record = { outcome }; ins.readTotal.add(1, aggregate); state.readTotal.push({ attributes: aggregate }); if (errorCode !== undefined) { const errorAttrs: Record = { code: errorCode }; if (status !== undefined) { errorAttrs["status"] = String(status); } ins.readErrorsTotal.add(1, errorAttrs); state.readErrorsTotal.push({ attributes: errorAttrs }); } } /** Return a frozen snapshot of the in-memory state. */ export function getHttpBackendMetricsState(): HttpBackendMetricsState { return state; } /** Reset the in-memory state ring. Tests call this between cases. */ export function resetHttpBackendMetricsState(): void { state.readDurationMs.length = 0; state.readTotal.length = 0; state.readErrorsTotal.length = 0; } /** Test-only: drop the cached instruments so a fresh meter can be built. */ export function resetHttpBackendMetricsCache(): void { cached = undefined; }