Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
export type {
HostBaseAttributes,
HostCounterName,
HostHistogramName,
HostInstruments,
HostInstrumentsConfig,
PluginInstruments,
} from "./otel.js";
export {
createHostInstruments,
HOST_COUNTER_NAMES,
HOST_HISTOGRAM_NAMES,
} from "./otel.js";

View File

@@ -0,0 +1,86 @@
# `otel` — host instrument registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createHostInstruments` builds the frozen bundle of every OpenTelemetry counter and histogram the host emits on a production path. The instrument list mirrors design doc §12.3 exactly — every name is a published surface and removing one is a breaking change. The package never installs an OTel SDK; the meter-provider source is, in priority order, the explicit `meterProvider` override and then the OTel global provider (which falls back to a no-op when no SDK is wired). This keeps SDK initialisation an operator concern, not a library concern.
The registry also vends per-plugin meters via `forPlugin(name)` — each call returns a cached meter namespaced under `@suaveplan/api-host/plugin/<name>` with the plugin label pre-attached. Stage-4 packages plug their plugin-declared `manifest.telemetry` instruments onto these meters.
## Features
- Twelve counters (`HOST_COUNTER_NAMES`) and four histograms (`HOST_HISTOGRAM_NAMES`) covering the full §12.3 metric surface.
- Pre-tagged host meter with `host.id` + `host.version` attributes.
- Histogram units (`ms`, `By`) set at construction time.
- Cached per-plugin meters with `plugin.name` pre-attached.
- `tenantLabelOptIn` flag — defaults to `false` to bound cardinality.
- `dispose()` clears the per-plugin meter cache for graceful teardown.
## Usage: Basic
```ts
import { createHostInstruments } from "@suaveplan/api-host/otel";
const instruments = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
});
instruments.counters["api.requests_total"].add(1, { route: "/billing/invoices" });
instruments.histograms["api.request_duration_ms"].record(42, {
route: "/billing/invoices",
});
```
## Usage: Advanced
Plug an explicit meter-provider (test harness or operator-supplied SDK):
```ts
import {
HOST_COUNTER_NAMES,
createHostInstruments,
} from "@suaveplan/api-host/otel";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({
/* readers / views configured by the operator */
});
const instruments = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
tenantLabelOptIn: true,
meterProvider: provider,
});
// Per-plugin meter — caches per `pluginName`.
const billingMeter = instruments.forPlugin("billing").meter;
const errorsByReason = billingMeter.createCounter("billing.errors_total");
// Verify the surface is complete.
for (const name of HOST_COUNTER_NAMES) {
instruments.counters[name]; // present.
}
```
## API Reference
- `createHostInstruments(config: HostInstrumentsConfig): HostInstruments`
- `HOST_COUNTER_NAMES` — `readonly` tuple of every counter name.
- `HOST_HISTOGRAM_NAMES` — `readonly` tuple of every histogram name.
- `HostInstruments`:
- `meter: Meter`
- `tenantLabelOptIn: boolean`
- `counters: Readonly<Record<HostCounterName, Counter>>`
- `histograms: Readonly<Record<HostHistogramName, Histogram>>`
- `forPlugin(name): PluginInstruments`
- `dispose(): void`
- `PluginInstruments`: `{ meter: Meter; pluginName: string }`.
## Implementation Notes
The counter / histogram lists are exported as `readonly` tuples so consumers can iterate and assert against them in tests — a missing instrument shows up as a test failure, not a runtime no-op. Per-plugin meters are cached by name to avoid leaking meter objects on every reload (one meter per plugin is the design invariant — disposal happens at uninstall, mirrored by `dispose()` here clearing the whole cache). Tenant-label opt-in is gated at the call site rather than the registry level: the registry exposes the flag and the request pipeline (Chunk B) decides whether to attach `tenant` to a given measurement. This keeps the cardinality story explicit at every emit site rather than hidden behind a registry policy.

View File

@@ -0,0 +1,262 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createHostInstruments,
HOST_COUNTER_NAMES,
HOST_HISTOGRAM_NAMES,
} from "./otel.js";
interface RecordedInstrument {
readonly kind: "counter" | "histogram";
readonly name: string;
readonly options: Readonly<Record<string, unknown>>;
addCalls: {
value: number;
attributes?: Readonly<Record<string, unknown>>;
}[];
recordCalls: {
value: number;
attributes?: Readonly<Record<string, unknown>>;
}[];
}
interface CapturedMeter {
readonly name: string;
readonly instruments: RecordedInstrument[];
}
function buildFakeProvider(): {
provider: MeterProvider;
meters: CapturedMeter[];
} {
const meters: CapturedMeter[] = [];
const provider: MeterProvider = {
getMeter(name: string): Meter {
const captured: CapturedMeter = {
name,
instruments: [],
};
meters.push(captured);
const meter: Meter = {
createCounter(
n: string,
opts?: Readonly<Record<string, unknown>>
) {
const inst: RecordedInstrument = {
kind: "counter",
name: n,
options: opts ?? {},
addCalls: [],
recordCalls: [],
};
captured.instruments.push(inst);
return {
add(
value: number,
attributes?: Readonly<Record<string, unknown>>
) {
inst.addCalls.push({ value, attributes });
},
} as ReturnType<Meter["createCounter"]>;
},
createHistogram(
n: string,
opts?: Readonly<Record<string, unknown>>
) {
const inst: RecordedInstrument = {
kind: "histogram",
name: n,
options: opts ?? {},
addCalls: [],
recordCalls: [],
};
captured.instruments.push(inst);
return {
record(
value: number,
attributes?: Readonly<Record<string, unknown>>
) {
inst.recordCalls.push({ value, attributes });
},
} as ReturnType<Meter["createHistogram"]>;
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, meters };
}
describe("createHostInstruments", () => {
it("creates every documented counter on the host meter", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
for (const name of HOST_COUNTER_NAMES) {
expect(reg.counters[name]).toBeDefined();
}
expect(meters[0]?.name).toBe("@suaveplan/api-host");
expect(reg.baseAttributes).toEqual({
"host.id": "host-1",
"host.version": "0.1.0",
});
// Every counter should be present in the host meter's instrument list.
const counterNames = meters[0]?.instruments
.filter((i) => i.kind === "counter")
.map((i) => i.name);
expect(counterNames).toEqual([...HOST_COUNTER_NAMES]);
});
it("creates every documented histogram with the expected unit", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
for (const name of HOST_HISTOGRAM_NAMES) {
expect(reg.histograms[name]).toBeDefined();
}
const histograms = meters[0]?.instruments.filter(
(i) => i.kind === "histogram"
);
const byName = new Map(histograms?.map((h) => [h.name, h]));
expect(byName.get("api.request_duration_ms")?.options).toEqual({
unit: "ms",
});
expect(byName.get("plugin.reload_duration_ms")?.options).toEqual({
unit: "ms",
});
expect(byName.get("api.body.bytes")?.options).toEqual({ unit: "By" });
expect(byName.get("api.response.bytes")?.options).toEqual({
unit: "By",
});
expect(byName.get("host.admin_call_duration_ms")?.options).toEqual({
unit: "ms",
});
});
it("forPlugin returns a stable per-plugin meter", { timeout: 5000 }, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
const first = reg.forPlugin("contacts");
const second = reg.forPlugin("contacts");
expect(first.pluginName).toBe("contacts");
expect(first.meter).toBe(second.meter);
// Only one plugin meter was created (host + 1 plugin = 2 meters).
expect(meters.filter((m) => m.name.includes("/plugin/"))).toHaveLength(
1
);
const pluginMeter = meters.find((m) =>
m.name.endsWith("/plugin/contacts")
);
expect(pluginMeter).toBeDefined();
expect(first.baseAttributes).toEqual({
"host.id": "host-1",
"host.version": "0.1.0",
"plugin.name": "contacts",
});
});
it("forPlugin caches per-plugin meters by name", { timeout: 5000 }, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
reg.forPlugin("a");
reg.forPlugin("b");
reg.forPlugin("a");
// host meter + 2 plugin meters.
expect(meters).toHaveLength(3);
});
it("dispose() clears the per-plugin meter cache", { timeout: 5000 }, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
reg.forPlugin("a");
reg.dispose();
reg.forPlugin("a");
// host meter + 1st 'a' meter + 2nd 'a' meter post-dispose = 3 total.
expect(meters).toHaveLength(3);
});
it("tenantLabelOptIn defaults to false", { timeout: 5000 }, () => {
const { provider } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
expect(reg.tenantLabelOptIn).toBe(false);
});
it("tenantLabelOptIn reflects the config flag when true", {
timeout: 5000,
}, () => {
const { provider } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
tenantLabelOptIn: true,
meterProvider: provider,
});
expect(reg.tenantLabelOptIn).toBe(true);
});
it("falls back to the global meter-provider when none is supplied", {
timeout: 5000,
}, () => {
const reg = createHostInstruments({
hostId: "host-2",
hostVersion: "0.2.0",
});
// Counters / histograms still exist (noop SDK provides them).
expect(reg.counters["api.requests_total"]).toBeDefined();
expect(reg.histograms["api.request_duration_ms"]).toBeDefined();
// Add a no-op call to prove the surface is wired through.
reg.counters["api.requests_total"].add(1);
reg.histograms["api.request_duration_ms"].record(5);
});
it("counters and histograms forward to the underlying instrument", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeProvider();
const reg = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
meterProvider: provider,
});
reg.counters["api.requests_total"].add(2, { route: "/x" });
reg.histograms["api.request_duration_ms"].record(42, { route: "/x" });
const counter = meters[0]?.instruments.find(
(i) => i.name === "api.requests_total"
);
const histogram = meters[0]?.instruments.find(
(i) => i.name === "api.request_duration_ms"
);
expect(counter?.addCalls).toEqual([
{ value: 2, attributes: { route: "/x" } },
]);
expect(histogram?.recordCalls).toEqual([
{ value: 42, attributes: { route: "/x" } },
]);
});
});

View File

@@ -0,0 +1,196 @@
import type {
Meter,
MeterProvider,
Counter as OtelCounter,
Histogram as OtelHistogram,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Every counter the host emits on a production path. The list mirrors
* design doc §12.3 exactly so a missing entry is a spec deviation and
* a CI gate. Names follow `<domain>.<noun>` per OTel naming conventions.
*
* Counters are monotonically increasing — they only `.add(n, labels)`.
* Histograms (see {@link HOST_HISTOGRAM_NAMES}) handle distributions.
*/
export const HOST_COUNTER_NAMES = [
"api.requests_total",
"api.errors_total",
"api.auth.failures_total",
"api.rbac.denials_total",
"api.tenant.unresolved_total",
"api.validation.failures_total",
"api.idempotency.replays_total",
"api.rate_limit.exceeded_total",
"plugin.lifecycle_transitions_total",
"plugin.reload_total",
"plugin.unsafe_query_total",
"host.admin_action_total",
] as const;
/**
* Every histogram the host emits on a production path. Distribution
* metrics — request duration, body / response sizes, reload duration.
*/
export const HOST_HISTOGRAM_NAMES = [
"api.request_duration_ms",
"api.body.bytes",
"api.response.bytes",
"plugin.reload_duration_ms",
"host.admin_call_duration_ms",
] as const;
export type HostCounterName = (typeof HOST_COUNTER_NAMES)[number];
export type HostHistogramName = (typeof HOST_HISTOGRAM_NAMES)[number];
/**
* Configuration consumed by {@link createHostInstruments}.
*/
export interface HostInstrumentsConfig {
/** Stable host id pre-tagged on every measurement (cardinality-bounded). */
readonly hostId: string;
/** Host semver pre-tagged on every measurement. */
readonly hostVersion: string;
/**
* When `true`, the `tenant` label is permitted on per-request metrics.
* Disabled by default to bound cardinality — operators opt in for
* environments where per-tenant slicing is required.
*/
readonly tenantLabelOptIn?: boolean;
/**
* Optional OTel meter-provider override. Defaults to the global
* `metrics.getMeterProvider()`. The package never installs an SDK —
* SDK initialisation is operator config per the proposal §Out-of-scope.
*/
readonly meterProvider?: MeterProvider;
}
/**
* Stable attribute set every host-emitted measurement should carry. The
* SDK-level resource carries these for free; the registry surfaces them
* here so call-site code can spread them onto labels deterministically
* when running with the no-op SDK or a metric-exporter that does not
* forward resource attributes.
*/
export type HostBaseAttributes = Readonly<{
"host.id": string;
"host.version": string;
}>;
/**
* Frozen bundle of every instrument the host emits. Per-plugin meters
* are constructed via {@link HostInstruments.forPlugin} so per-plugin
* cardinality stays bounded to the documented label set.
*/
export interface HostInstruments {
/** OTel meter for host-level instruments. */
readonly meter: Meter;
/** Base attribute set (host id + version) to spread onto measurements. */
readonly baseAttributes: HostBaseAttributes;
/** `true` iff the tenant label is permitted on per-request metrics. */
readonly tenantLabelOptIn: boolean;
readonly counters: Readonly<Record<HostCounterName, OtelCounter>>;
readonly histograms: Readonly<Record<HostHistogramName, OtelHistogram>>;
/**
* Construct a per-plugin instrument view. Stage-2 ships the meter +
* default counters; Stage-3+ may add plugin-declared instruments to
* this surface (per `manifest.telemetry`).
*/
forPlugin(pluginName: string): PluginInstruments;
/** Dispose every per-plugin meter created via `forPlugin`. */
dispose(): void;
}
/**
* Per-plugin instrument view returned by {@link HostInstruments.forPlugin}.
* Plugin meter names are namespaced under `@suaveplan/api-host/plugin/<name>`
* so per-plugin metrics never collide with host-level ones.
*/
export interface PluginInstruments {
readonly meter: Meter;
readonly pluginName: string;
/** Base attribute set with `plugin.name` added. */
readonly baseAttributes: HostBaseAttributes & { "plugin.name": string };
}
/**
* Build the host's instrument registry. The returned object is frozen;
* counters and histograms are created at construction time so the first
* `.add()` / `.record()` call has zero allocation cost.
*
* The meter-provider source is, in priority order:
* 1. `config.meterProvider` (explicit override),
* 2. the OTel global `metrics.getMeterProvider()` (which falls back to
* a no-op provider when no SDK is installed).
*
* Operators who care about real export configure their SDK once at
* process start and the host picks it up via the global provider.
*/
export function createHostInstruments(
config: HostInstrumentsConfig
): HostInstruments {
const provider = config.meterProvider ?? metrics.getMeterProvider();
const baseAttributes: HostBaseAttributes = Object.freeze({
"host.id": config.hostId,
"host.version": config.hostVersion,
});
const meter = provider.getMeter("@suaveplan/api-host");
const counters = Object.create(null) as Record<
HostCounterName,
OtelCounter
>;
for (const name of HOST_COUNTER_NAMES) {
counters[name] = meter.createCounter(name);
}
const histograms = Object.create(null) as Record<
HostHistogramName,
OtelHistogram
>;
for (const name of HOST_HISTOGRAM_NAMES) {
histograms[name] = meter.createHistogram(name, {
unit: histogramUnit(name),
});
}
const pluginMeters = new Map<string, Meter>();
const registry: HostInstruments = Object.freeze({
meter,
baseAttributes,
tenantLabelOptIn: config.tenantLabelOptIn === true,
counters: Object.freeze(counters),
histograms: Object.freeze(histograms),
forPlugin(pluginName: string): PluginInstruments {
let m = pluginMeters.get(pluginName);
if (m === undefined) {
m = provider.getMeter(
`@suaveplan/api-host/plugin/${pluginName}`
);
pluginMeters.set(pluginName, m);
}
return {
meter: m,
pluginName,
baseAttributes: Object.freeze({
...baseAttributes,
"plugin.name": pluginName,
}),
};
},
dispose(): void {
pluginMeters.clear();
},
});
return registry;
}
const HISTOGRAM_UNITS: Readonly<Record<HostHistogramName, string>> = {
"api.request_duration_ms": "ms",
"plugin.reload_duration_ms": "ms",
"api.body.bytes": "By",
"api.response.bytes": "By",
"host.admin_call_duration_ms": "ms",
};
function histogramUnit(name: HostHistogramName): string {
return HISTOGRAM_UNITS[name];
}