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

13 KiB

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"