Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createStdoutAuditInstruments,
|
||||
type StdoutAuditInstruments,
|
||||
type StdoutAuditInstrumentsConfig,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `instruments` — OTel counters for the stdout audit sink
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the two counters the stdout sink emits at runtime,
|
||||
matching design doc §12.3's metric surface for the audit pipeline.
|
||||
Operators wire the `MeterProvider` once at process boot; the sink
|
||||
picks up the configured provider through this factory. When no SDK
|
||||
is installed, the OTel global resolves to a no-op meter so the sink
|
||||
runs cleanly in tests without any ceremony.
|
||||
|
||||
The factory returns a frozen bundle so the sink does zero meter
|
||||
lookups on the hot path — both counters are constructed once and the
|
||||
record's `type` / `outcome` labels are attached at observation time.
|
||||
|
||||
## Features
|
||||
|
||||
- `api.audit.records_total{sink, type, outcome}` — successful write
|
||||
counter.
|
||||
- `api.audit.dropped_total{sink, reason}` — drop counter for
|
||||
queue-full and writer-failed paths.
|
||||
- Default meter provider falls back to the OTel global.
|
||||
- Frozen bundle — references stable for the sink's lifetime.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
instruments.recordsTotal.add(1, {
|
||||
sink: "stdout",
|
||||
type: "request.success",
|
||||
outcome: "success",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
|
||||
const meterProvider = new MeterProvider();
|
||||
const instruments = createStdoutAuditInstruments({ meterProvider });
|
||||
|
||||
// Hand them into createStdoutAuditSink so the sink emits through the
|
||||
// SDK-installed provider rather than the global no-op.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createStdoutAuditInstruments(config?)`
|
||||
- `meterProvider?: MeterProvider` — defaults to
|
||||
`metrics.getMeterProvider()`.
|
||||
- Returns `StdoutAuditInstruments` with `meter`, `recordsTotal`,
|
||||
`droppedTotal`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Counters are constructed eagerly so the first write does zero
|
||||
allocations beyond the attribute object. The bundle is frozen to
|
||||
prevent accidental shadowing. The `sink="stdout"` attribute is
|
||||
attached at observation time by the sink so the same provider can
|
||||
serve multiple sink implementations side by side (file, vault, etc.)
|
||||
without conflict on the metric name.
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createStdoutAuditInstruments } from "./instruments.js";
|
||||
|
||||
async function readCounter(
|
||||
provider: MeterProvider,
|
||||
reader: PeriodicExportingMetricReader,
|
||||
exporter: InMemoryMetricExporter,
|
||||
name: string
|
||||
): Promise<number> {
|
||||
exporter.reset();
|
||||
await provider.forceFlush();
|
||||
const records = exporter.getMetrics();
|
||||
let total = 0;
|
||||
for (const rm of records) {
|
||||
for (const scope of rm.scopeMetrics) {
|
||||
for (const m of scope.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
total += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
describe("createStdoutAuditInstruments", () => {
|
||||
it("falls back to the OTel global meter provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
expect(instruments.recordsTotal).toBeDefined();
|
||||
expect(instruments.droppedTotal).toBeDefined();
|
||||
expect(instruments.meter).toBeDefined();
|
||||
});
|
||||
|
||||
it("returned bundle is frozen", { timeout: 5000 }, () => {
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
expect(Object.isFrozen(instruments)).toBe(true);
|
||||
});
|
||||
|
||||
it("counters increment through a real MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
instruments.recordsTotal.add(1, {
|
||||
sink: "stdout",
|
||||
type: "x",
|
||||
outcome: "success",
|
||||
});
|
||||
instruments.recordsTotal.add(2, {
|
||||
sink: "stdout",
|
||||
type: "x",
|
||||
outcome: "success",
|
||||
});
|
||||
instruments.droppedTotal.add(1, { sink: "stdout" });
|
||||
|
||||
const records = await readCounter(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.records_total"
|
||||
);
|
||||
const dropped = await readCounter(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.dropped_total"
|
||||
);
|
||||
expect(records).toBe(3);
|
||||
expect(dropped).toBe(1);
|
||||
await provider.shutdown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Config consumed by {@link createStdoutAuditInstruments}. The meter
|
||||
* provider defaults to the OTel global (which no-ops without an
|
||||
* installed SDK) so the sink works in test envs without ceremony.
|
||||
*/
|
||||
export interface StdoutAuditInstrumentsConfig {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen bundle of the two counters this sink emits.
|
||||
*
|
||||
* - `api.audit.records_total{sink="stdout", type, outcome}` — one
|
||||
* increment per successful write.
|
||||
* - `api.audit.dropped_total{sink="stdout"}` — one increment per
|
||||
* dropped record (queue-full or writer-failed). Drops are NEVER
|
||||
* silent — every drop bumps this counter before the sink returns.
|
||||
*/
|
||||
export interface StdoutAuditInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly recordsTotal: Counter;
|
||||
readonly droppedTotal: Counter;
|
||||
}
|
||||
|
||||
const METER_NAME = "@suaveplan/api-audit-stdout";
|
||||
|
||||
/**
|
||||
* Build the sink's instrument registry. Counters are created once at
|
||||
* construction so the hot-path writes do zero meter lookups.
|
||||
*/
|
||||
export function createStdoutAuditInstruments(
|
||||
config: StdoutAuditInstrumentsConfig = {}
|
||||
): StdoutAuditInstruments {
|
||||
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = meterProvider.getMeter(METER_NAME);
|
||||
const recordsTotal = meter.createCounter("api.audit.records_total", {
|
||||
description:
|
||||
"Audit records successfully written, by sink + type + outcome",
|
||||
});
|
||||
const droppedTotal = meter.createCounter("api.audit.dropped_total", {
|
||||
description: "Audit records dropped by backpressure or writer failure",
|
||||
});
|
||||
return Object.freeze({ meter, recordsTotal, droppedTotal });
|
||||
}
|
||||
Reference in New Issue
Block a user