Files
suaveplan/openspec/changes/opentelemetry-completion/specs/opentelemetry/spec.md
2026-05-27 23:29:53 +01:00

181 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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: <OpenTelemetryError with code OTEL.EXPORT_FAILED> }`
#### 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: <OpenTelemetryError with code OTEL.EXPORT_FAILED> }`
**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"`
<!-- markdownlint-enable MD024 -->