# Capability Spec Delta: opentelemetry (opentelemetry-completion) **Package**: `@suaveplan/opentelemetry` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: In-memory span exporter for testing The package SHALL expose an `InMemorySpanExporter` class that satisfies the `SpanExporter` interface from `@opentelemetry/sdk-trace-base`, collects every span passed to `export()`, and exposes `getFinishedSpans()`, `getSpansByName(name)`, `reset()`, `shutdown()`, `forceFlush()`, and a `spanCount` getter for test assertions. After `shutdown()`, subsequent calls to `export()` SHALL return `ExportResultCode.FAILED` with an `OpenTelemetryError(OTEL.EXPORT_FAILED, ...)` in the `error` field, and `shutdown()` SHALL be idempotent. #### Scenario: collected spans are returned by getFinishedSpans - **WHEN** three spans are passed to `export()` and the result callback is invoked with `ExportResultCode.SUCCESS` - **THEN** `getFinishedSpans()` returns a fresh array containing those three spans in insertion order, and `spanCount === 3` #### Scenario: getSpansByName filters on span name - **WHEN** five spans named `"a"`, `"b"`, `"a"`, `"c"`, `"a"` are exported - **THEN** `getSpansByName("a")` returns exactly the three `"a"` spans #### Scenario: reset clears collected spans without shutting down - **WHEN** spans are exported, then `reset()` is called - **THEN** `getFinishedSpans()` returns `[]`, `spanCount === 0`, AND a subsequent `export()` succeeds and accumulates new spans #### Scenario: post-shutdown export is rejected with OTEL.EXPORT_FAILED - **WHEN** `shutdown()` has been called and `export()` is invoked again - **THEN** the result callback receives `{ code: ExportResultCode.FAILED, error: }` #### Scenario: shutdown is idempotent - **WHEN** `shutdown()` is called twice - **THEN** both calls resolve without throwing AND no double-shutdown side effect (e.g. duplicate clearing) is observable **Verified by:** `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts::"collects exported spans and surfaces them via getFinishedSpans"` AND `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts::"filters spans by name via getSpansByName"` AND `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts::"reset clears spans without shutting down"` AND `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts::"rejects export after shutdown with OTEL.EXPORT_FAILED"` AND `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts::"shutdown is idempotent"` AND `packages/monitor/opentelemetry/src/exporters/memory/memory.spec-conformance.test.ts::"satisfies the SpanExporter contract from @opentelemetry/sdk-trace-base"` --- ### Requirement: Console span exporter for development The package SHALL expose a `ConsoleSpanExporter` class that satisfies the same `SpanExporter` contract and pretty-prints every span to a configurable log function. `ConsoleSpanExporterOptions` SHALL accept an optional `logFn: (line: string) => void` (default `console.log`) and an optional `pretty: boolean` (default `true`). Pretty mode produces indented JSON; compact mode produces single-line JSON. Each rendered span SHALL include `traceId`, `spanId`, `parentSpanId`, `name`, `kind` (decoded to label), `status` (code + message), ISO `startTime` / `endTime`, `duration`, `attributes`, `events`, `links`, and `resource.attributes`. After `shutdown()`, subsequent `export()` calls SHALL return `ExportResultCode.FAILED` with `OpenTelemetryError(OTEL.EXPORT_FAILED, ...)`. #### Scenario: default logFn is console.log - **WHEN** a `ConsoleSpanExporter` is constructed with no options - **THEN** `export()` writes via `console.log` (verified by spying on the global) #### Scenario: custom logFn captures rendered output - **WHEN** `new ConsoleSpanExporter({ logFn: capture })` is constructed and a span is exported - **THEN** `capture` receives one call per exported span with a JSON string #### Scenario: pretty mode indents output - **WHEN** `pretty: true` (default) and a span is exported - **THEN** the captured string contains `"\n"` (multi-line indented JSON) #### Scenario: compact mode emits single-line JSON - **WHEN** `pretty: false` and a span is exported - **THEN** the captured string contains no `"\n"` (single-line JSON) #### Scenario: post-shutdown export rejected - **WHEN** `shutdown()` has been called and `export()` is invoked again - **THEN** the result callback receives `{ code: ExportResultCode.FAILED, error: }` **Verified by:** `packages/monitor/opentelemetry/src/exporters/console/console.test.ts::"defaults logFn to console.log"` AND `packages/monitor/opentelemetry/src/exporters/console/console.test.ts::"routes spans to the supplied logFn"` AND `packages/monitor/opentelemetry/src/exporters/console/console.test.ts::"emits indented JSON in pretty mode"` AND `packages/monitor/opentelemetry/src/exporters/console/console.test.ts::"emits single-line JSON in compact mode"` AND `packages/monitor/opentelemetry/src/exporters/console/console.test.ts::"rejects export after shutdown with OTEL.EXPORT_FAILED"` AND `packages/monitor/opentelemetry/src/exporters/console/console.spec-conformance.test.ts::"satisfies the SpanExporter contract from @opentelemetry/sdk-trace-base"` --- ### Requirement: W3C-compliant trace and span ID generator The package SHALL expose `generateTraceId()` returning a 32-character lower-hex string, `generateSpanId()` returning a 16-character lower-hex string, `isValidTraceId(id)` and `isValidSpanId(id)` predicates, plus the constants `TRACE_ID_HEX_LENGTH` (32), `SPAN_ID_HEX_LENGTH` (16), `INVALID_TRACE_ID` (32 zero chars) and `INVALID_SPAN_ID` (16 zero chars). Generation SHALL use `crypto.getRandomValues` exclusively — no `Math.random()` fallback (genesis determinism rule). If the generated value equals the all-zero invalid value, the generator SHALL re-roll, capped at 8 attempts; on exhaustion it SHALL throw `OpenTelemetryError(OTEL.INVALID_TRACE_ID, ...)` (or `OTEL.INVALID_SPAN_ID`). #### Scenario: generateTraceId produces a valid 32-char lower-hex string - **WHEN** `generateTraceId()` is called - **THEN** the returned string has length 32, matches `/^[0-9a-f]{32}$/`, and `isValidTraceId(returned) === true` #### Scenario: generateSpanId produces a valid 16-char lower-hex string - **WHEN** `generateSpanId()` is called - **THEN** the returned string has length 16, matches `/^[0-9a-f]{16}$/`, and `isValidSpanId(returned) === true` #### Scenario: isValidTraceId rejects the all-zero string - **WHEN** `isValidTraceId(INVALID_TRACE_ID)` is called - **THEN** the result is `false` #### Scenario: isValidTraceId rejects mixed-case input - **WHEN** `isValidTraceId("ABCDEF0000000000000000000000ABCD")` is called - **THEN** the result is `false` (lower-hex required) #### Scenario: isValidSpanId rejects wrong-length input - **WHEN** `isValidSpanId("123")` is called - **THEN** the result is `false` #### Scenario: round-trip property holds for 1000 samples - **WHEN** `fast-check` generates 1000 trace IDs via `generateTraceId()` - **THEN** every one passes `isValidTraceId` and is unique with high probability (no collisions in the 1000 sample) **Verified by:** `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts::"generateTraceId returns a 32-char lower-hex non-zero string"` AND `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts::"generateSpanId returns a 16-char lower-hex non-zero string"` AND `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts::"isValidTraceId rejects the all-zero invalid value"` AND `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts::"isValidTraceId rejects upper-case hex"` AND `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts::"isValidSpanId rejects wrong-length input"` AND `packages/monitor/opentelemetry/src/id-generator/id-generator.spec-conformance.test.ts::"every generated trace id passes isValidTraceId (1000-sample property)"` --- ### Requirement: Deterministic id-generator backed by an injectable Rng The package SHALL expose `createDeterministicIdGenerator(rng: Rng)` returning `{ generateTraceId, generateSpanId }`. Both methods SHALL fill the ID by reading bytes from `rng.bytes(n)` (16 for trace, 8 for span) rather than `crypto.getRandomValues`, so a seeded `Rng` produces reproducible IDs. The same all-zero retry behaviour applies — capped at 8 attempts, then `OTEL.INVALID_TRACE_ID` / `OTEL.INVALID_SPAN_ID`. #### Scenario: seeded Rng produces reproducible IDs - **WHEN** two `createDeterministicIdGenerator(rng)` instances are constructed with `Rng` instances seeded identically and `generateTraceId()` is called once on each - **THEN** both return the same 32-char hex string #### Scenario: zero-byte Rng exhausts retry budget - **WHEN** the supplied `Rng.bytes` always returns `Uint8Array.from({ length: n }, () => 0)` and `generateTraceId()` is called - **THEN** after 8 attempts the call throws `OpenTelemetryError` with code `OTEL.INVALID_TRACE_ID` and `context.attempts === 8` **Verified by:** `packages/monitor/opentelemetry/src/id-generator/deterministic.test.ts::"seeded Rng produces reproducible trace ids"` AND `packages/monitor/opentelemetry/src/id-generator/deterministic.test.ts::"seeded Rng produces reproducible span ids"` AND `packages/monitor/opentelemetry/src/id-generator/deterministic.spec-conformance.test.ts::"throws OTEL.INVALID_TRACE_ID after 8 zero-byte attempts"` AND `packages/monitor/opentelemetry/src/id-generator/deterministic.spec-conformance.test.ts::"throws OTEL.INVALID_SPAN_ID after 8 zero-byte attempts"` --- ### Requirement: W3C Baggage propagator for cross-process header injection The package SHALL expose `W3CBaggagePropagator` implementing `TextMapPropagator` from `@opentelemetry/api`, plus the helper functions `serializeBaggage(baggage)` / `parseBaggage(header)` and the constant `BAGGAGE_HEADER` (literal `"baggage"`). The propagator's `fields()` SHALL return `["baggage"]`. `inject()` SHALL read the active baggage via `propagation.getBaggage(context)`, URI-encode keys and values, and call `setter.set(carrier, BAGGAGE_HEADER, value)`. `extract()` SHALL read `carrier[BAGGAGE_HEADER]`, parse, and call `propagation.setBaggage(context, baggage)`. The serialiser/parser SHALL honour the constants `MAX_BAGGAGE_ENTRIES` (180), `MAX_BAGGAGE_KEY_LENGTH` (4096), `MAX_BAGGAGE_VALUE_LENGTH` (8192) — entries past these caps are silently dropped (W3C tolerant-parser behaviour, parity with legacy). #### Scenario: round-trip extract then inject preserves entries - **WHEN** `parseBaggage("k1=v1,k2=v2")` is called and the result is re-serialised via `serializeBaggage(...)` - **THEN** the round-tripped string equals `"k1=v1,k2=v2"` #### Scenario: URI-encoding survives special characters - **WHEN** baggage has key `"k 1"` and value `"v;2,3"` and is serialised, then parsed back - **THEN** the parsed entry has key `"k 1"` and value `"v;2,3"` (the inner `;` and `,` are URI-encoded on the wire and decoded on parse; metadata after `;` is parsed only at the top level) #### Scenario: inject writes the baggage header - **WHEN** an active context has baggage `{ user: { value: "alice" } }` and `propagator.inject(context, carrier, setter)` is called - **THEN** `setter.set` was called once with `(carrier, "baggage", "user=alice")` #### Scenario: extract reads the baggage header - **WHEN** `carrier = { baggage: "user=alice" }` and `propagator.extract(context, carrier, getter)` is called - **THEN** the returned context has `propagation.getBaggage(returnedContext)?.getEntry("user")?.value === "alice"` #### Scenario: empty active baggage is a no-op for inject - **WHEN** the active context has no baggage and `inject` is called - **THEN** `setter.set` is not invoked #### Scenario: MAX_BAGGAGE_ENTRIES cap drops the 181st entry - **WHEN** a header with 181 entries is parsed - **THEN** the resulting baggage has 180 entries (the last one dropped); no error thrown #### Scenario: oversized key is dropped - **WHEN** a header has an entry whose key length exceeds `MAX_BAGGAGE_KEY_LENGTH` - **THEN** that entry is silently dropped from the parsed result #### Scenario: fields returns ["baggage"] - **WHEN** `propagator.fields()` is called - **THEN** it returns the array `["baggage"]` **Verified by:** `packages/monitor/opentelemetry/src/baggage/baggage.test.ts::"round-trips simple key=value pairs"` AND `packages/monitor/opentelemetry/src/baggage/baggage.test.ts::"URI-encodes special characters in keys and values"` AND `packages/monitor/opentelemetry/src/baggage/baggage.test.ts::"inject writes the baggage header from active context"` AND `packages/monitor/opentelemetry/src/baggage/baggage.test.ts::"extract reads the baggage header into the returned context"` AND `packages/monitor/opentelemetry/src/baggage/baggage.test.ts::"inject is a no-op when active baggage is empty"` AND `packages/monitor/opentelemetry/src/baggage/baggage.spec-conformance.test.ts::"silently drops entries past MAX_BAGGAGE_ENTRIES"` AND `packages/monitor/opentelemetry/src/baggage/baggage.spec-conformance.test.ts::"silently drops entries with oversized keys"` AND `packages/monitor/opentelemetry/src/baggage/baggage.spec-conformance.test.ts::"fields returns the canonical baggage header name"`