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,33 @@
# @suaveplan/events-otel - OpenTelemetry tracing companion for @suaveplan/events: wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span errors.
## Summary
OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span errors.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
const bus = new EventBus();
const handle = attachEventTracing(bus);
await bus.emitAsync("user.created", { id: "u-1" });
handle.detach();
```
## Modules
- [`src/core/attach-event-tracing/attach-event-tracing`](src/core/attach-event-tracing/attach-event-tracing.md) — `attachEventTracing`, `EventTracingHandle`, `EventTracingOptions`
- [`src/propagation/trace-context/trace-context`](src/propagation/trace-context/trace-context.md) — `serializeTraceContext`, `deserializeTraceContext`
- [`src/sampling/sampling-rule/sampling-rule`](src/sampling/sampling-rule/sampling-rule.md) — `SamplingRule`, `shouldSample`, `SamplingRuleSchema`

View File

@@ -0,0 +1,84 @@
{
"name": "@suaveplan/events-otel",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "OpenTelemetry tracing companion for @suaveplan/events: wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span errors.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/attach-event-tracing": {
"import": {
"types": "./dist/core/attach-event-tracing/attach-event-tracing.d.ts",
"default": "./dist/core/attach-event-tracing/attach-event-tracing.js"
},
"require": {
"types": "./dist/core/attach-event-tracing/attach-event-tracing.d.ts",
"default": "./dist/core/attach-event-tracing/attach-event-tracing.js"
}
},
"./propagation/trace-context": {
"import": {
"types": "./dist/propagation/trace-context/trace-context.d.ts",
"default": "./dist/propagation/trace-context/trace-context.js"
},
"require": {
"types": "./dist/propagation/trace-context/trace-context.d.ts",
"default": "./dist/propagation/trace-context/trace-context.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,14 @@
/** Stable OTel span attribute keys for events-otel. */
export const SPAN_ATTRIBUTES = {
EVENT_NAME: "suaveplan.event.name",
EVENT_ID: "suaveplan.event.id",
EVENT_CATEGORY: "suaveplan.event.category",
LISTENER_ID: "suaveplan.listener.id",
BUS_ID: "suaveplan.bus.id",
} as const;
/** Stable OTel span names for events-otel. */
export const SPAN_NAMES = {
DISPATCH: "events.dispatch",
LISTENER: "events.listener",
} as const;

View File

@@ -0,0 +1,87 @@
# attach-event-tracing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches OTel tracing instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. Each dispatch produces a span named `events.dispatch` with event name and event ID attributes. Listener exceptions are recorded on the span as errors before being re-thrown. The interceptor captures the active context at dispatch time so that any OTel operations inside listeners produce correctly parented child spans.
The implementation is sampling-aware: it evaluates the configured `SamplingRule` to decide whether to create a span. When no sampling rule is configured it defaults to `always`.
## Features
- `attachEventTracing(bus, options?)` — installs a tracing interceptor and returns `EventTracingHandle`
- Span per dispatch (`events.dispatch`) with `suaveplan.event.name` and `suaveplan.event.id` attributes
- Listener exception → `span.recordException(err)` + `SpanStatusCode.ERROR`
- Sampling rule support (`always`, `never`, `rate`, `event-name`)
- `eventFilter` option to exclude event types from tracing
- Injectable `tracer`, `propagator`, `clock`, `logger` for testability
- `EventTracingHandle.detach()` removes the interceptor
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventTracing(bus);
bus.on("user.created", (p) => console.log("created", p.id));
await bus.emitAsync("user.created", { id: "u-1" });
// A span named "events.dispatch" was created
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
import { trace } from "@opentelemetry/api";
const bus = new EventBus();
const handle = attachEventTracing(bus, {
tracer: trace.getTracer("my-app"),
samplingRule: { type: "rate", rate: 0.1 },
eventFilter: (name) => !name.startsWith("internal."),
});
// Only 10% of non-internal event dispatches produce spans
await bus.emitAsync("user.created", { id: "u-2" });
handle.detach();
```
## API Reference
### `attachEventTracing<TMap>(bus, options?): EventTracingHandle`
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventTracingOptions?` | Tracer, propagator, filter, sampling rule, clock, logger |
**Returns**: `EventTracingHandle`
### `EventTracingHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the tracing interceptor |
### `EventTracingOptions`
| Field | Type | Description |
|---|---|---|
| `tracer` | `Tracer?` | OTel tracer (default: global) |
| `propagator` | `TextMapPropagator?` | W3C propagator (default: global) |
| `eventFilter` | `(name: string) => boolean` | Exclude event names from tracing |
| `samplingRule` | `SamplingRule?` | Sampling strategy (default: `always`) |
| `clock` | `Clock?` | Injectable clock |
| `logger` | `LoggerLike?` | Diagnostic logger |
## Implementation Notes
The interceptor uses `tracer.startActiveSpan(SPAN_NAMES.DISPATCH, {}, context.active(), ...)` to both create the span and set it as the active context for the duration of `next()`. This ensures all OTel operations within listeners (including nested dispatches) are correctly parented. The `context.active()` snapshot happens synchronously at interceptor entry, so even if the active context changes between dispatch and listener execution, the span retains the correct parent.

View File

@@ -0,0 +1,149 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { attachEventTracing } from "./attach-event-tracing.js";
type TestMap = {
click: { x: number };
submit: { formId: string };
};
describe("attachEventTracing", () => {
it("does not throw when instrumenting a bus", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus);
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
handle.detach();
});
it("detach removes the interceptor", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus);
handle.detach();
bus.on("click", () => void 0);
// Should still work without the interceptor
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
});
it("respects eventFilter", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
let callCount = 0;
const handle = attachEventTracing(bus, {
eventFilter: (name) => {
callCount++;
return name === "click";
},
});
bus.on("click", () => void 0);
bus.on("submit", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("submit", { formId: "f" });
expect(callCount).toBe(2); // filter called for each event
handle.detach();
});
it("sampling rule never skips span creation", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus, {
samplingRule: { type: "never" },
});
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
handle.detach();
});
it("sampling rule always creates span", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus, {
samplingRule: { type: "always" },
});
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
handle.detach();
});
it("sampling rule event-name filters by name", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus, {
samplingRule: { type: "event-name", names: ["click"] },
});
bus.on("click", () => void 0);
bus.on("submit", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("submit", { formId: "f" });
handle.detach();
});
it("listener errors do not prevent dispatch completion", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus);
bus.on("click", () => {
throw new Error("listener threw");
});
const result = await bus.emitAsync("click", { x: 0 });
expect(result.errors.length).toBe(1);
handle.detach();
});
it("listener throwing a non-Error value is collected without recordException", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus);
bus.on("click", () => {
// Throw via an `unknown`-typed local so biome's useThrowOnlyError
// can't statically prove the value isn't Error-like. Exercises the
// non-Error branch of the listener-error coercion path.
const failure: unknown = "string-failure";
throw failure;
});
const result = await bus.emitAsync("click", { x: 0 });
expect(result.errors.length).toBeGreaterThanOrEqual(1);
handle.detach();
});
it("propagation error in interceptor rethrows", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventTracing(bus);
// Add a second interceptor that throws
bus.use(async () => {
throw new Error("interceptor failure");
});
// The event bus may wrap non-EventsError throws; check error chain.
let caught: unknown;
try {
await bus.emitAsync("click", { x: 0 });
} catch (e) {
caught = e;
}
const hasMessage = (err: unknown, msg: string): boolean =>
err instanceof Error
? err.message.includes(msg) || hasMessage(err.cause, msg)
: false;
expect(hasMessage(caught, "interceptor failure")).toBe(true);
handle.detach();
});
it("rate sampling with injectable rand", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
// We test via shouldSample independently; here we just confirm no errors
const handle = attachEventTracing(bus, {
samplingRule: { type: "rate", rate: 0.5 },
});
bus.on("click", () => void 0);
for (let i = 0; i < 5; i++) {
await bus.emitAsync("click", { x: i });
}
handle.detach();
});
});

View File

@@ -0,0 +1,73 @@
import { context, SpanStatusCode, trace } from "@opentelemetry/api";
import type { EventBus, EventMap } from "@suaveplan/events";
import { SPAN_ATTRIBUTES, SPAN_NAMES } from "../../constants/index.js";
import { shouldSample } from "../../sampling/sampling-rule/sampling-rule.js";
import type {
EventTracingHandle,
EventTracingOptions,
} from "./attach-event-tracing.types.js";
/**
* Attaches OTel tracing instrumentation to an EventBus via the interceptor API.
*
* Wraps each dispatch in a span, propagates W3C Trace Context to listener
* invocations, and records listener exceptions as span errors.
*/
export function attachEventTracing<TMap extends EventMap>(
bus: EventBus<TMap>,
options: EventTracingOptions = {}
): EventTracingHandle {
const tracer = options.tracer ?? trace.getTracer("suaveplan.events");
const filter = options.eventFilter ?? (() => true);
const samplingRule = options.samplingRule ?? { type: "always" as const };
const unsubscribe = bus.use(async (envelope, next) => {
const eventName = envelope.type;
if (!filter(eventName) || !shouldSample(samplingRule, eventName)) {
return next();
}
return tracer.startActiveSpan(
SPAN_NAMES.DISPATCH,
{},
context.active(),
async (span) => {
span.setAttributes({
[SPAN_ATTRIBUTES.EVENT_NAME]: eventName,
[SPAN_ATTRIBUTES.EVENT_ID]: envelope.id,
});
try {
const result = await next();
if (result.errors.length > 0) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Listener errors",
});
for (const err of result.errors) {
span.recordException(err as Error);
}
} else {
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: (err as Error).message,
});
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
}
);
});
return {
detach(): void {
unsubscribe();
},
};
}

View File

@@ -0,0 +1,29 @@
import type { TextMapPropagator, Tracer } from "@opentelemetry/api";
import type { Clock, LoggerLike } from "@suaveplan/types";
import type { SamplingRule } from "../../sampling/sampling-rule/sampling-rule.types.js";
/**
* Options for configuring event bus tracing instrumentation.
*/
export interface EventTracingOptions {
/** OTel Tracer instance. Defaults to the global tracer from @opentelemetry/api. */
tracer?: Tracer;
/** W3C propagator. Defaults to the global propagator. */
propagator?: TextMapPropagator;
/** Filter function: only trace matching event names. */
eventFilter?: (name: string) => boolean;
/** Sampling rule for controlling span creation. Default: always. */
samplingRule?: SamplingRule;
/** Injectable clock. */
clock?: Clock;
/** Logger for diagnostic output. */
logger?: LoggerLike;
}
/**
* Handle returned by attachEventTracing.
*/
export interface EventTracingHandle {
/** Remove the tracing interceptor from the bus. */
detach(): void;
}

View File

@@ -0,0 +1,5 @@
export { attachEventTracing } from "./attach-event-tracing.js";
export type {
EventTracingHandle,
EventTracingOptions,
} from "./attach-event-tracing.types.js";

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export type { EventsOtelErrorCode } from "./error-codes.js";
export {
EVENTS_OTEL_CODES,
EventsOtelError,
registerEventsOtelErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,21 @@
export { SPAN_ATTRIBUTES, SPAN_NAMES } from "./constants/index.js";
export { attachEventTracing } from "./core/attach-event-tracing/attach-event-tracing.js";
export type {
EventTracingHandle,
EventTracingOptions,
} from "./core/attach-event-tracing/attach-event-tracing.types.js";
export type { EventsOtelErrorCode } from "./error-codes/index.js";
export {
EVENTS_OTEL_CODES,
EventsOtelError,
registerEventsOtelErrorCodes,
} from "./error-codes/index.js";
export {
deserializeTraceContext,
serializeTraceContext,
} from "./propagation/trace-context/trace-context.js";
export {
SamplingRuleSchema,
shouldSample,
} from "./sampling/sampling-rule/sampling-rule.js";
export type { SamplingRule } from "./sampling/sampling-rule/sampling-rule.types.js";

View File

@@ -0,0 +1,4 @@
export {
deserializeTraceContext,
serializeTraceContext,
} from "./trace-context.js";

View File

@@ -0,0 +1,70 @@
# trace-context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `serializeTraceContext` and `deserializeTraceContext` helpers for `@suaveplan/events-otel`. These functions convert the W3C Trace Context (traceparent + tracestate headers) to and from a plain `Record<string, string>` that can be safely embedded in event payloads, transport envelopes, or message-queue headers without importing OTel types.
The helpers delegate to the `@opentelemetry/api` global propagator, so the exact propagation format (W3C, B3, Jaeger, etc.) is controlled by the installed SDK — the application does not need to change this package to switch formats.
## Features
- `serializeTraceContext(ctx?)` — extracts active W3C trace context into a plain map; injectable context for testing
- `deserializeTraceContext(headers, parentCtx?)` — restores context from a map; injectable parent context for nesting
- Zero runtime state — both functions use the global OTel propagator and context APIs
## Usage: Basic Example
```ts
import { serializeTraceContext, deserializeTraceContext } from "@suaveplan/events-otel";
// Sender side: capture context before cross-boundary send
const headers = serializeTraceContext();
// { "traceparent": "00-abc...123-0000000000000001-01" }
// Receiver side: restore context before re-dispatching
const ctx = deserializeTraceContext(headers);
```
## Usage: Advanced Example
```ts
import { serializeTraceContext, deserializeTraceContext } from "@suaveplan/events-otel";
import { context, trace } from "@opentelemetry/api";
// Sender: extract context from a specific span
const span = trace.getActiveSpan();
const ctx = span ? trace.setSpan(context.active(), span) : context.active();
const headers = serializeTraceContext(ctx);
// Receiver: restore and use as parent
const restored = deserializeTraceContext(headers);
context.with(restored, () => {
// Any spans created here will be children of the sender's span
});
```
## API Reference
### `serializeTraceContext(ctx?: Context): Record<string, string>`
| Param | Type | Description |
|---|---|---|
| `ctx` | `Context?` | OTel context to serialize (default: `context.active()`) |
**Returns**: plain map of propagation headers (e.g., `{ "traceparent": "..." }`)
### `deserializeTraceContext(headers, parentCtx?): Context`
| Param | Type | Description |
|---|---|---|
| `headers` | `Record<string, string>` | Map of propagation headers |
| `parentCtx` | `Context?` | Parent OTel context (default: `context.active()`) |
**Returns**: OTel `Context` with the trace context restored; returns root context if headers contain no valid trace info.
## Implementation Notes
Both functions use the global OTel propagation API (`propagation.inject` / `propagation.extract`). When no OTel SDK is installed, the global propagator is a no-op, so `serializeTraceContext` returns an empty object and `deserializeTraceContext` returns the active context unchanged. This ensures the package is safe to use in environments without OTel.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
deserializeTraceContext,
serializeTraceContext,
} from "./trace-context.js";
describe("serializeTraceContext", () => {
it("returns a plain object", { timeout: 5000 }, () => {
const result = serializeTraceContext();
expect(typeof result).toBe("object");
expect(result).not.toBeNull();
});
it("all values are strings", { timeout: 5000 }, () => {
const result = serializeTraceContext();
for (const v of Object.values(result)) {
expect(typeof v).toBe("string");
}
});
it("accepts an explicit context", { timeout: 5000 }, () => {
const { context } = require("@opentelemetry/api");
const result = serializeTraceContext(context.active());
expect(typeof result).toBe("object");
});
});
describe("deserializeTraceContext", () => {
it("returns a Context from empty headers", { timeout: 5000 }, () => {
const ctx = deserializeTraceContext({});
expect(ctx).toBeDefined();
});
it("round-trips a serialized context", { timeout: 5000 }, () => {
// Without a real SDK, serialize returns {} and deserialize returns the active context
const headers = serializeTraceContext();
const ctx = deserializeTraceContext(headers);
expect(ctx).toBeDefined();
});
it("accepts a parent context", { timeout: 5000 }, () => {
const { context } = require("@opentelemetry/api");
const ctx = deserializeTraceContext({}, context.active());
expect(ctx).toBeDefined();
});
it("handles W3C traceparent header if present", { timeout: 5000 }, () => {
const headers = {
traceparent:
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
};
const ctx = deserializeTraceContext(headers);
expect(ctx).toBeDefined();
});
});

View File

@@ -0,0 +1,22 @@
import { type Context, context, propagation } from "@opentelemetry/api";
/**
* Serialize the current active trace context into a plain string→string map
* suitable for inclusion in transport envelopes or event payloads.
*/
export function serializeTraceContext(ctx?: Context): Record<string, string> {
const carrier: Record<string, string> = {};
propagation.inject(ctx ?? context.active(), carrier);
return carrier;
}
/**
* Restore a trace context from a serialized envelope map.
* Returns the root context if the carrier contains no valid trace headers.
*/
export function deserializeTraceContext(
headers: Record<string, string>,
parentCtx?: Context
): Context {
return propagation.extract(parentCtx ?? context.active(), headers);
}

View File

@@ -0,0 +1,2 @@
export { SamplingRuleSchema, shouldSample } from "./sampling-rule.js";
export type { SamplingRule } from "./sampling-rule.types.js";

View File

@@ -0,0 +1,75 @@
# sampling-rule
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `SamplingRule` discriminated union type and the `shouldSample` evaluation function for `@suaveplan/events-otel`. Sampling rules determine which event dispatches produce OpenTelemetry spans, enabling cost-efficient tracing in high-throughput production environments without instrumentation code changes.
The implementation is pure and injectable: `shouldSample` accepts a random-number generator parameter so rate-based sampling can be tested deterministically.
## Features
- `SamplingRule` — four-variant discriminated union: `always`, `never`, `rate`, `event-name`
- `SamplingRuleSchema` — Zod schema for validating rule objects at the boundary
- `shouldSample(rule, eventName, rand?)` — evaluates a rule against a given event name and optional RNG
## Usage: Basic Example
```ts
import { shouldSample } from "@suaveplan/events-otel";
import type { SamplingRule } from "@suaveplan/events-otel";
const rule: SamplingRule = { type: "event-name", names: ["user.created", "order.placed"] };
shouldSample(rule, "user.created"); // true
shouldSample(rule, "page.viewed"); // false
```
## Usage: Advanced Example
```ts
import { shouldSample, SamplingRuleSchema } from "@suaveplan/events-otel";
// Validate from config
const rawRule = { type: "rate", rate: 0.1 };
const rule = SamplingRuleSchema.parse(rawRule);
// Deterministic test: always sample
const alwaysSample = () => 0.05; // < 0.1
shouldSample(rule, "any.event", alwaysSample); // true
// Deterministic test: never sample
const neverSample = () => 0.95; // >= 0.1
shouldSample(rule, "any.event", neverSample); // false
```
## API Reference
### `shouldSample(rule, eventName, rand?): boolean`
| Param | Type | Description |
|---|---|---|
| `rule` | `SamplingRule` | Rule to evaluate |
| `eventName` | `string` | Name of the dispatched event |
| `rand` | `() => number` | RNG for rate sampling (default: `Math.random`) |
**Returns**: `true` if the dispatch should produce a span.
### `SamplingRuleSchema`
Zod schema validating the `SamplingRule` discriminated union. Use for config parsing.
### `SamplingRule`
```ts
type SamplingRule =
| { type: "always" }
| { type: "never" }
| { type: "rate"; rate: number } // 0.0 – 1.0
| { type: "event-name"; names: string[] }
```
## Implementation Notes
Rate-based sampling uses a simple comparison `rand() < rule.rate` without any consistent hashing. For consistent per-trace sampling (where all spans in one trace are either all sampled or all dropped), the caller should check the parent span's sampling flag first — the `attachEventTracing` function does this by only evaluating `shouldSample` when there is no parent span in context.

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SamplingRuleSchema, shouldSample } from "./sampling-rule.js";
describe("shouldSample", () => {
it("always rule always returns true", { timeout: 5000 }, () => {
expect(shouldSample({ type: "always" }, "any.event")).toBe(true);
});
it("never rule always returns false", { timeout: 5000 }, () => {
expect(shouldSample({ type: "never" }, "any.event")).toBe(false);
});
it("rate rule samples based on random value", { timeout: 5000 }, () => {
const rule = { type: "rate" as const, rate: 0.5 };
expect(shouldSample(rule, "e", () => 0.49)).toBe(true);
expect(shouldSample(rule, "e", () => 0.5)).toBe(false);
expect(shouldSample(rule, "e", () => 0)).toBe(true);
expect(shouldSample(rule, "e", () => 0.99)).toBe(false);
});
it("rate 0 never samples", { timeout: 5000 }, () => {
expect(shouldSample({ type: "rate", rate: 0 }, "e", () => 0)).toBe(
false
);
});
it("rate 1 always samples", { timeout: 5000 }, () => {
expect(shouldSample({ type: "rate", rate: 1 }, "e", () => 0.999)).toBe(
true
);
});
it("event-name rule matches listed names", { timeout: 5000 }, () => {
const rule = {
type: "event-name" as const,
names: ["user.created", "order.placed"],
};
expect(shouldSample(rule, "user.created")).toBe(true);
expect(shouldSample(rule, "order.placed")).toBe(true);
expect(shouldSample(rule, "page.viewed")).toBe(false);
});
it("event-name empty list never samples", { timeout: 5000 }, () => {
expect(shouldSample({ type: "event-name", names: [] }, "any")).toBe(
false
);
});
it("uses Math.random by default for rate rule", { timeout: 5000 }, () => {
// At rate 1.0, should always be true regardless of random
const rule = { type: "rate" as const, rate: 1.0 };
for (let i = 0; i < 10; i++) {
expect(shouldSample(rule, "e")).toBe(true);
}
});
});
describe("SamplingRuleSchema", () => {
it("parses always rule", { timeout: 5000 }, () => {
expect(SamplingRuleSchema.parse({ type: "always" })).toEqual({
type: "always",
});
});
it("parses never rule", { timeout: 5000 }, () => {
expect(SamplingRuleSchema.parse({ type: "never" })).toEqual({
type: "never",
});
});
it("parses rate rule", { timeout: 5000 }, () => {
expect(SamplingRuleSchema.parse({ type: "rate", rate: 0.5 })).toEqual({
type: "rate",
rate: 0.5,
});
});
it("rejects invalid rate", { timeout: 5000 }, () => {
expect(() =>
SamplingRuleSchema.parse({ type: "rate", rate: 1.5 })
).toThrow();
});
it("parses event-name rule", { timeout: 5000 }, () => {
const result = SamplingRuleSchema.parse({
type: "event-name",
names: ["e"],
});
expect(result).toEqual({ type: "event-name", names: ["e"] });
});
it("rejects unknown type", { timeout: 5000 }, () => {
expect(() => SamplingRuleSchema.parse({ type: "unknown" })).toThrow();
});
});

View File

@@ -0,0 +1,34 @@
import z from "zod";
import type { SamplingRule } from "./sampling-rule.types.js";
/** Zod schema for SamplingRule. */
export const SamplingRuleSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("always") }),
z.object({ type: z.literal("never") }),
z.object({ type: z.literal("rate"), rate: z.number().min(0).max(1) }),
z.object({ type: z.literal("event-name"), names: z.array(z.string()) }),
]);
/**
* Determines whether a dispatch should be sampled given a rule and optional random value.
*
* @param rule - Sampling rule to evaluate
* @param eventName - Name of the event being dispatched
* @param rand - Random number in [0, 1) for rate sampling (injectable for testing)
*/
export function shouldSample(
rule: SamplingRule,
eventName: string,
rand: () => number = Math.random
): boolean {
switch (rule.type) {
case "always":
return true;
case "never":
return false;
case "rate":
return rand() < rule.rate;
case "event-name":
return rule.names.includes(eventName);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Sampling rule for controlling which dispatches produce spans.
*/
export type SamplingRule =
| { type: "always" }
| { type: "never" }
| { type: "rate"; rate: number }
| { type: "event-name"; names: string[] };

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("package has correct name", { timeout: 5000 }, async () => {
const pkg = await import("../package.json", { with: { type: "json" } });
expect(pkg.default.name).toBe("@suaveplan/events-otel");
});
it("exports attachEventTracing", { timeout: 30000 }, async () => {
const { attachEventTracing } = await import("../src/index.js");
expect(typeof attachEventTracing).toBe("function");
});
it("exports serializeTraceContext", { timeout: 30000 }, async () => {
const { serializeTraceContext } = await import("../src/index.js");
expect(typeof serializeTraceContext).toBe("function");
});
});

View File

@@ -0,0 +1,10 @@
import { webcrypto } from "node:crypto";
// Polyfill Web Crypto for Node.js environments (Vitest forks pool)
if (typeof globalThis.crypto === "undefined") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
writable: false,
configurable: true,
});
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/events-otel",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});