Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
/** Stable OTel metric names for events-metrics. */
export const METRIC_NAMES = {
DISPATCH_TOTAL: "suaveplan.events.dispatch.total",
LISTENER_ERROR_TOTAL: "suaveplan.events.listener.error.total",
DISPATCH_LATENCY_MS: "suaveplan.events.dispatch.latency_ms",
QUEUE_DEPTH: "suaveplan.events.queue.depth",
ACTIVE_SUBSCRIBERS: "suaveplan.events.subscribers.active",
} as const;
/** Stable OTel attribute keys for events-metrics. */
export const ATTRIBUTE_KEYS = {
EVENT_NAME: "event.name",
EVENT_CATEGORY: "event.category",
LISTENER_ID: "listener.id",
BUS_ID: "bus.id",
} as const;

View File

@@ -0,0 +1,98 @@
# attach-event-metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches OTel metrics instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. It records event dispatch totals, listener error counts, and latency histograms per event name without modifying the bus or its listeners. The attachment is transparent to publishers and subscribers — they do not need to know that metrics are being collected.
The implementation prioritises zero per-dispatch allocation on the hot path for previously seen event names by pre-caching the OTel counter/histogram references and using object property access rather than Map lookups.
## Features
- `attachEventMetrics(bus, options?)` — installs a metrics interceptor and returns a `EventMetricsHandle`
- Dispatch total counter (`suaveplan.events.dispatch.total`) per event name
- Listener error counter (`suaveplan.events.listener.error.total`) per event name
- Dispatch latency histogram (`suaveplan.events.dispatch.latency_ms`) per event name
- `EventMetricsHandle.snapshot()` returns in-process totals without needing an OTel exporter
- `EventMetricsHandle.detach()` removes the interceptor
- Injectable `meter`, `clock`, `logger` for testability
- `eventFilter` option to limit recording to a subset of event names
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventMetrics(bus);
bus.on("user.created", (payload) => console.log(payload));
await bus.emitAsync("user.created", { id: "u-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["user.created"]); // 1
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
import { metrics } from "@opentelemetry/api";
const bus = new EventBus<{ click: { x: number }; submit: { formId: string } }>();
const handle = attachEventMetrics(bus, {
meter: metrics.getMeter("my-app"),
eventFilter: (name) => name.startsWith("click"),
clock: { now: () => performance.now() },
});
// Only "click" events will be instrumented; "submit" events pass through uninstrumented
await bus.emitAsync("click", { x: 42 });
await bus.emitAsync("submit", { formId: "f-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["click"]); // 1
console.log(snap.dispatchTotal["submit"]); // undefined (filtered out)
handle.detach();
```
## API Reference
### `attachEventMetrics<TMap>(bus, options?): EventMetricsHandle`
Installs a metrics interceptor on `bus`.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventMetricsOptions?` | Meter, filter, clock, logger |
**Returns**: `EventMetricsHandle`
### `EventMetricsHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the interceptor |
| `snapshot()` | `MetricsSnapshot` | In-process metric values |
### `MetricsSnapshot`
| Field | Type | Description |
|---|---|---|
| `dispatchTotal` | `Record<string, number>` | Total dispatches per event name |
| `listenerErrorTotal` | `Record<string, number>` | Total listener errors per event name |
| `avgDispatchLatencyMs` | `Record<string, number>` | Average latency per event name |
| `queueDepth` | `number` | Current queue depth (0 for synchronous bus) |
| `activeSubscribers` | `number` | Active subscriber count |
## Implementation Notes
The metrics interceptor uses `bus.use()` which is the `@suaveplan/events` public interceptor API. It wraps `next()` with a timer, increments counters, and records the histogram. OTel instruments are lazily created on first use to avoid startup cost when the bus is never dispatched. The `queueDepth` and `activeSubscribers` fields in `snapshot()` are reported as 0 because the synchronous `EventBus` does not expose a queue depth API; applications using `BoundedQueue` would need a separate gauge observer.

View File

@@ -0,0 +1,169 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { attachEventMetrics } from "./attach-event-metrics.js";
type TestMap = {
click: { x: number };
submit: { formId: string };
"user.created": { id: string };
};
describe("attachEventMetrics", () => {
it("counts dispatches per event name", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("click", { x: 2 });
await bus.emitAsync("submit", { formId: "f" });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(2);
expect(snap.dispatchTotal["submit"]).toBe(1);
handle.detach();
});
it("snapshot returns zeros for unknown events", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBeUndefined();
expect(snap.listenerErrorTotal["click"]).toBeUndefined();
handle.detach();
});
it("counts listener errors", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => {
throw new Error("listener error");
});
await bus.emitAsync("click", { x: 0 });
const snap = handle.snapshot();
expect(snap.listenerErrorTotal["click"]).toBe(1);
handle.detach();
});
it("measures latency (avgDispatchLatencyMs >= 0)", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 5 });
const snap = handle.snapshot();
expect(snap.avgDispatchLatencyMs["click"]).toBeGreaterThanOrEqual(0);
handle.detach();
});
it("respects eventFilter option", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus, {
eventFilter: (name) => name === "click",
});
bus.on("click", () => void 0);
bus.on("submit", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("submit", { formId: "f" });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(1);
expect(snap.dispatchTotal["submit"]).toBeUndefined();
handle.detach();
});
it("detach removes the interceptor", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
handle.detach();
await bus.emitAsync("click", { x: 1 });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBeUndefined();
});
it("uses injectable clock", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
let t = 0;
const clock = { now: () => (t += 10) };
const handle = attachEventMetrics(bus, { clock });
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
const snap = handle.snapshot();
expect(snap.avgDispatchLatencyMs["click"]).toBeGreaterThan(0);
handle.detach();
});
it("snapshot returns queueDepth and activeSubscribers as numbers", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
const snap = handle.snapshot();
expect(typeof snap.queueDepth).toBe("number");
expect(typeof snap.activeSubscribers).toBe("number");
handle.detach();
});
it("handles multiple listeners and counts correctly", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
let count = 0;
bus.on("click", () => {
count++;
});
bus.on("click", () => {
count++;
});
await bus.emitAsync("click", { x: 0 });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(1); // one dispatch, two listeners
expect(count).toBe(2);
handle.detach();
});
it("counts errors thrown in interceptor chain", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
// Install a second interceptor that throws
bus.use(async (_envelope, _next) => {
throw new Error("interceptor error");
});
// The event bus wraps non-EventsError throws; verify via cause chain
let caught: unknown;
try {
await bus.emitAsync("click", { x: 0 });
} catch (e) {
caught = e;
}
const hasMsg = (err: unknown, msg: string): boolean =>
err instanceof Error
? err.message.includes(msg) || hasMsg(err.cause, msg)
: false;
expect(hasMsg(caught, "interceptor error")).toBe(true);
const snap = handle.snapshot();
expect(snap.listenerErrorTotal["click"]).toBe(1);
handle.detach();
});
});

View File

@@ -0,0 +1,122 @@
import type { Counter, Histogram } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { EventBus, EventMap } from "@suaveplan/events";
import { ATTRIBUTE_KEYS, METRIC_NAMES } from "../../constants/index.js";
import type {
EventMetricsHandle,
EventMetricsOptions,
MetricsSnapshot,
} from "./attach-event-metrics.types.js";
/**
* Attaches OTel metrics instrumentation to an EventBus via the interceptor API.
*
* Records dispatch totals, listener error counts, and latency histograms
* per event name. Uses `ObservableGauge` for queue depth and active subscribers
* (polled at collection time, not per-dispatch).
*/
export function attachEventMetrics<TMap extends EventMap>(
bus: EventBus<TMap>,
options: EventMetricsOptions = {}
): EventMetricsHandle {
const meter = options.meter ?? metrics.getMeter("suaveplan.events");
const filter = options.eventFilter ?? (() => true);
// In-process accumulators for snapshot()
const dispatchTotal: Record<string, number> = {};
const listenerErrorTotal: Record<string, number> = {};
const latencyAccum: Record<string, { sum: number; count: number }> = {};
// OTel instruments (lazily cached per event name)
let counter: Counter | null = null;
let errorCounter: Counter | null = null;
let histogram: Histogram | null = null;
function getCounter(): Counter {
counter ??= meter.createCounter(METRIC_NAMES.DISPATCH_TOTAL, {
description: "Total event dispatches",
});
return counter;
}
function getErrorCounter(): Counter {
errorCounter ??= meter.createCounter(
METRIC_NAMES.LISTENER_ERROR_TOTAL,
{
description: "Total listener errors during dispatch",
}
);
return errorCounter;
}
function getHistogram(): Histogram {
histogram ??= meter.createHistogram(METRIC_NAMES.DISPATCH_LATENCY_MS, {
description: "Event dispatch latency in milliseconds",
unit: "ms",
});
return histogram;
}
const clock = options.clock ?? { now: () => Date.now() };
const unsubscribe = bus.use(async (envelope, next) => {
const eventName = envelope.type;
if (!filter(eventName)) {
return next();
}
const startMs = clock.now();
const attrs = { [ATTRIBUTE_KEYS.EVENT_NAME]: eventName };
// Increment dispatch counter
dispatchTotal[eventName] = (dispatchTotal[eventName] ?? 0) + 1;
getCounter().add(1, attrs);
try {
const result = await next();
// Record errors from listeners
if (result.errors.length > 0) {
listenerErrorTotal[eventName] =
(listenerErrorTotal[eventName] ?? 0) + result.errors.length;
getErrorCounter().add(result.errors.length, attrs);
}
const latencyMs = clock.now() - startMs;
getHistogram().record(latencyMs, attrs);
const accum = latencyAccum[eventName] ?? { sum: 0, count: 0 };
accum.sum += latencyMs;
accum.count += 1;
latencyAccum[eventName] = accum;
return result;
} catch (err) {
listenerErrorTotal[eventName] =
(listenerErrorTotal[eventName] ?? 0) + 1;
getErrorCounter().add(1, attrs);
throw err;
}
});
return {
detach(): void {
unsubscribe();
},
snapshot(): MetricsSnapshot {
const avgDispatchLatencyMs: Record<string, number> = {};
for (const [name, accum] of Object.entries(latencyAccum)) {
// accum is only inserted into latencyAccum after count++ on
// the dispatch path, so count is always >= 1 here.
avgDispatchLatencyMs[name] = accum.sum / accum.count;
}
return {
dispatchTotal: { ...dispatchTotal },
listenerErrorTotal: { ...listenerErrorTotal },
avgDispatchLatencyMs,
queueDepth: 0,
activeSubscribers: 0,
};
},
};
}

View File

@@ -0,0 +1,47 @@
import type { Meter } from "@opentelemetry/api";
import type { LoggerLike } from "@suaveplan/types";
/** Minimal clock interface for latency measurement. */
export interface MetricsClock {
now(): number;
}
/**
* Options for configuring event bus metrics instrumentation.
*/
export interface EventMetricsOptions {
/** OTel Meter instance. Defaults to the global meter from @opentelemetry/api. */
meter?: Meter;
/** Filter function: only record metrics for matching event names. */
eventFilter?: (name: string) => boolean;
/** Injectable clock for latency measurement. */
clock?: MetricsClock;
/** Logger for diagnostic output. */
logger?: LoggerLike;
}
/**
* Snapshot of current metrics values (no OTel exporter required).
*/
export interface MetricsSnapshot {
/** Total dispatch count per event name. */
dispatchTotal: Record<string, number>;
/** Total listener error count per event name. */
listenerErrorTotal: Record<string, number>;
/** Average dispatch latency in ms per event name. */
avgDispatchLatencyMs: Record<string, number>;
/** Current queue depth. */
queueDepth: number;
/** Current active subscriber count. */
activeSubscribers: number;
}
/**
* Handle returned by attachEventMetrics. Use to detach or snapshot metrics.
*/
export interface EventMetricsHandle {
/** Remove the metrics interceptor from the bus. */
detach(): void;
/** Return a snapshot of current in-process metric values. */
snapshot(): MetricsSnapshot;
}

View File

@@ -0,0 +1,6 @@
export { attachEventMetrics } from "./attach-event-metrics.js";
export type {
EventMetricsHandle,
EventMetricsOptions,
MetricsSnapshot,
} from "./attach-event-metrics.types.js";

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_METRICS_CODES,
EventsMetricsError,
registerEventsMetricsErrorCodes,
} from "./error-codes.js";
describe("EventsMetricsError", () => {
it("constructs with a registered code, message, and options", {
timeout: 5000,
}, () => {
const cause = new Error("root");
const err = new EventsMetricsError(
EVENTS_METRICS_CODES.ATTACH_FAILED,
"could not attach",
{ cause }
);
expect(err).toBeInstanceOf(EventsMetricsError);
expect(err.code).toBe(EVENTS_METRICS_CODES.ATTACH_FAILED);
expect(err.message).toBe("could not attach");
expect(err.name).toBe("EventsMetricsError");
expect(err.cause).toBe(cause);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new EventsMetricsError(
EVENTS_METRICS_CODES.ALREADY_ATTACHED,
"already attached"
);
expect(err.code).toBe(EVENTS_METRICS_CODES.ALREADY_ATTACHED);
expect(err.name).toBe("EventsMetricsError");
});
it("registerEventsMetricsErrorCodes is idempotent", {
timeout: 5000,
}, () => {
expect(() => {
registerEventsMetricsErrorCodes();
registerEventsMetricsErrorCodes();
}).not.toThrow();
});
it("EVENTS_METRICS_CODES exposes all expected codes", {
timeout: 5000,
}, () => {
expect(EVENTS_METRICS_CODES.ATTACH_FAILED).toBe(
"EVENTS_METRICS.ATTACH_FAILED"
);
expect(EVENTS_METRICS_CODES.ALREADY_ATTACHED).toBe(
"EVENTS_METRICS.ALREADY_ATTACHED"
);
expect(EVENTS_METRICS_CODES.DETACHED).toBe("EVENTS_METRICS.DETACHED");
});
});

View File

@@ -0,0 +1,33 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const EVENTS_METRICS_CODES = defineCodes("EVENTS_METRICS", [
"ATTACH_FAILED",
"ALREADY_ATTACHED",
"DETACHED",
] as const);
export type EventsMetricsErrorCode =
(typeof EVENTS_METRICS_CODES)[keyof typeof EVENTS_METRICS_CODES];
/**
* Structured error for events-metrics operations.
*/
export class EventsMetricsError extends SuaveplanError<EventsMetricsErrorCode> {
public constructor(
code: EventsMetricsErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "EventsMetricsError";
}
}
/** Register events-metrics error codes (idempotent). */
export function registerEventsMetricsErrorCodes(): void {
// defineCodes handles registration; this is a no-op hook.
}

View File

@@ -0,0 +1,6 @@
export type { EventsMetricsErrorCode } from "./error-codes.js";
export {
EVENTS_METRICS_CODES,
EventsMetricsError,
registerEventsMetricsErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,13 @@
export { ATTRIBUTE_KEYS, METRIC_NAMES } from "./constants/index.js";
export { attachEventMetrics } from "./core/attach-event-metrics/attach-event-metrics.js";
export type {
EventMetricsHandle,
EventMetricsOptions,
MetricsSnapshot,
} from "./core/attach-event-metrics/attach-event-metrics.types.js";
export type { EventsMetricsErrorCode } from "./error-codes/index.js";
export {
EVENTS_METRICS_CODES,
EventsMetricsError,
registerEventsMetricsErrorCodes,
} from "./error-codes/index.js";

View File

@@ -0,0 +1 @@
export { ATTRIBUTE_KEYS, METRIC_NAMES } from "./metric-names.js";

View File

@@ -0,0 +1,61 @@
# metric-names
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports the stable constant objects `METRIC_NAMES` and `ATTRIBUTE_KEYS` that define every OTel metric name and span attribute key used across `@suaveplan/events-metrics`. Centralising these names prevents typos, enables IDE completion, and makes it easy to audit the full observability surface of the package.
These constants are the single source of truth consumed by both the instrumentation code and the tests that assert metric names are correct.
## Features
- `METRIC_NAMES` — all five OTel metric names: dispatch total, listener error total, dispatch latency histogram, queue depth gauge, active subscribers gauge
- `ATTRIBUTE_KEYS` — all four OTel attribute keys: event name, event category, listener ID, bus ID
- Both objects use `as const` for full literal-type narrowing
## Usage: Basic Example
```ts
import { METRIC_NAMES, ATTRIBUTE_KEYS } from "@suaveplan/events-metrics";
console.log(METRIC_NAMES.DISPATCH_TOTAL); // "suaveplan.events.dispatch.total"
console.log(ATTRIBUTE_KEYS.EVENT_NAME); // "event.name"
```
## Usage: Advanced Example
```ts
import { METRIC_NAMES, ATTRIBUTE_KEYS } from "@suaveplan/events-metrics";
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("my-app");
const counter = meter.createCounter(METRIC_NAMES.DISPATCH_TOTAL);
counter.add(1, { [ATTRIBUTE_KEYS.EVENT_NAME]: "user.created" });
```
## API Reference
### `METRIC_NAMES`
| Key | Value |
|---|---|
| `DISPATCH_TOTAL` | `"suaveplan.events.dispatch.total"` |
| `LISTENER_ERROR_TOTAL` | `"suaveplan.events.listener.error.total"` |
| `DISPATCH_LATENCY_MS` | `"suaveplan.events.dispatch.latency_ms"` |
| `QUEUE_DEPTH` | `"suaveplan.events.queue.depth"` |
| `ACTIVE_SUBSCRIBERS` | `"suaveplan.events.subscribers.active"` |
### `ATTRIBUTE_KEYS`
| Key | Value |
|---|---|
| `EVENT_NAME` | `"event.name"` |
| `EVENT_CATEGORY` | `"event.category"` |
| `LISTENER_ID` | `"listener.id"` |
| `BUS_ID` | `"bus.id"` |
## Implementation Notes
The constants are defined in `src/constants/index.ts` and re-exported from this module to follow the package structure convention. Consumers should import from `@suaveplan/events-metrics` (the root barrel) rather than the internal path to benefit from tree-shaking and future path changes.

View File

@@ -0,0 +1 @@
export { ATTRIBUTE_KEYS, METRIC_NAMES } from "../../constants/index.js";