Initial Commit
This commit is contained in:
121
openspec/changes/opentelemetry-completion/proposal.md
Normal file
121
openspec/changes/opentelemetry-completion/proposal.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# OpenTelemetry completion
|
||||
|
||||
**Package**: `@suaveplan/opentelemetry`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-04-19 archive `enterprise-opentelemetry/proposal.md` committed to migrating the legacy `packages/universal/opentelemetry/src` substrate (custom `Tracer` / `TracerProvider` / `Span` / `WritableSpan`, `Meter` / `Counter` / `Gauge` / `Histogram`, `Context` / `ContextManager`, `Baggage` / `W3CBaggagePropagator`, `ConsoleSpanExporter` / `InMemorySpanExporter`, error-bridge, and `id-generator`) into genesis at full parity. That migration shipped only the thin wrappers around `@opentelemetry/sdk-node` / `sdk-trace-node` / `sdk-metrics`. Five capabilities the legacy substrate provided are still missing in genesis:
|
||||
|
||||
1. `InMemorySpanExporter` — testing-only span exporter (legacy `src/exporters/memory/memory.ts` L1-L51, 51 L). Every package today ad-hocs its own when it needs to assert on emitted spans.
|
||||
2. `ConsoleSpanExporter` — pretty-printed development exporter (legacy `src/exporters/console/console.ts` L1-L85, 85 L).
|
||||
3. `id-generator` — `generateTraceId()` / `generateSpanId()` / `isValidTraceId()` / `isValidSpanId()` per W3C Trace Context (legacy `src/utils/id-generator.ts` L1-L74, 74 L).
|
||||
4. `W3CBaggagePropagator` — header-based baggage extract/inject (legacy `src/propagation/baggage/baggage.ts` L78-L173, 96 L of class plus parser/serializer helpers).
|
||||
5. Deterministic `id-generator-deterministic` — alternate generator backed by an injectable `Rng` from `@suaveplan/types`. Genesis-original; required because the legacy generator falls back to `Math.random()` when `crypto.getRandomValues` is missing — a violation of the genesis determinism rule.
|
||||
|
||||
Without these, every dependent test file (currently `@suaveplan/events-metrics` and any consumer that wants to assert spans were emitted) reaches into the OTel SDK packages directly, duplicates the in-memory exporter, or ships flaky assertions on real SDK output.
|
||||
|
||||
## Position on the legacy substrate
|
||||
|
||||
Genesis explicitly **does not** rebuild the custom `Tracer` / `TracerProvider` / `Span` / `Meter` / `Counter` / `Gauge` / `Histogram` classes. The reasoning:
|
||||
|
||||
- `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-trace-node`, and `@opentelemetry/sdk-metrics` are already pulled via the `observability` catalog and are the de-facto vendor-neutral substrate the legacy package was attempting to recreate. Re-implementing them would duplicate a maintained codebase with zero downstream benefit.
|
||||
- The legacy `error-bridge` (`src/error-bridge/error-bridge.ts`) is already covered by `@suaveplan/logger` (which records `SuaveplanError` instances as span events through the OTel adapter that ships there). No port required.
|
||||
- Precedent: `bitpacker-crypto/SUPERSEDED.md` documents the same "we deliberately rejected the substrate-from-scratch port" position; this proposal is the OpenTelemetry analogue of that rejection.
|
||||
|
||||
What this change adds is the **runtime-neutral pieces that `@opentelemetry/sdk-*` does not ship under a stable ESM-friendly surface**: the testing in-memory exporter, the pretty-printed console exporter, the W3C ID generator with explicit determinism toggles, and a baggage propagator wrapper sized for cross-process header injection. These five pieces close the verified gap without dragging the rest of the substrate along.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `InMemorySpanExporter` — implements `SpanExporter` from `@opentelemetry/sdk-trace-base`; methods `export(spans, resultCallback)`, `getFinishedSpans()`, `getSpansByName(name)`, `reset()`, `shutdown()`, `forceFlush()`; `spanCount` getter; `shutdown()` is idempotent and subsequent `export()` calls return `ExportResultCode.FAILED`.
|
||||
- ADDED: `ConsoleSpanExporter` — implements `SpanExporter`; constructor accepts `ConsoleSpanExporterOptions { logFn?: (line: string) => void; pretty?: boolean }`; default `logFn` is `console.log`, default `pretty` is `true`; renders span kind, status code, ISO start/end times, duration, attributes, events, links, and resource attributes.
|
||||
- ADDED: `ConsoleSpanExporterOptions` — interface for the constructor options above.
|
||||
- ADDED: `generateTraceId()` — returns a 32-char lower-hex W3C trace ID, guaranteed non-zero (`!== "00000000000000000000000000000000"`).
|
||||
- ADDED: `generateSpanId()` — returns a 16-char lower-hex W3C span ID, guaranteed non-zero.
|
||||
- ADDED: `isValidTraceId(id)` — boolean predicate for 32-char lower-hex non-zero.
|
||||
- ADDED: `isValidSpanId(id)` — boolean predicate for 16-char lower-hex non-zero.
|
||||
- ADDED: `INVALID_TRACE_ID` / `INVALID_SPAN_ID` constants — the all-zero values.
|
||||
- ADDED: `TRACE_ID_HEX_LENGTH` (32) / `SPAN_ID_HEX_LENGTH` (16) constants.
|
||||
- ADDED: `createDeterministicIdGenerator(rng)` — factory returning `{ generateTraceId, generateSpanId }` backed by an injectable `Rng` from `@suaveplan/types`. Used in tests where deterministic trace IDs are required.
|
||||
- ADDED: `W3CBaggagePropagator` — implements `TextMapPropagator` from `@opentelemetry/api`; methods `extract(context, carrier, getter)`, `inject(context, carrier, setter)`, `fields()` returning `["baggage"]`. URI-encodes keys/values per W3C Baggage spec; honours the `MAX_BAGGAGE_ENTRIES` (180) / `MAX_BAGGAGE_KEY_LENGTH` (4096) / `MAX_BAGGAGE_VALUE_LENGTH` (8192) limits.
|
||||
- ADDED: `BAGGAGE_HEADER` constant — the literal string `"baggage"`.
|
||||
- ADDED: `serializeBaggage(baggage)` / `parseBaggage(header)` helper functions exposed for direct use without a propagator.
|
||||
- ADDED: subpath exports — `./exporters`, `./id-generator`, `./baggage`, `./constants`.
|
||||
- ADDED: error code `OTEL.EXPORT_FAILED` — thrown by `InMemorySpanExporter` / `ConsoleSpanExporter` when called after `shutdown()`.
|
||||
- ADDED: error code `OTEL.INVALID_TRACE_ID` / `OTEL.INVALID_SPAN_ID` — thrown by deterministic generator when the supplied `Rng` produces an all-zero ID and exhausts retry attempts.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Custom `Tracer` / `TracerProvider` / `Span` / `WritableSpan` classes** — covered by `@opentelemetry/api` + `@opentelemetry/sdk-trace-node`, both already in the `observability` catalog. Re-implementing duplicates maintained code; consumers should import from `@opentelemetry/api` directly. Position documented in `## Position on the legacy substrate` above.
|
||||
- **Custom `Meter` / `MeterProvider` / `Counter` / `Gauge` / `Histogram` classes** — covered by `@opentelemetry/api` + `@opentelemetry/sdk-metrics`. Same rationale as above.
|
||||
- **`Context` / `ContextManager` / `setSpanInContext` / `getSpanFromContext`** — covered by `@opentelemetry/api`'s `context` namespace and `trace.setSpan` / `trace.getSpan`.
|
||||
- **`error-bridge` (`installSuaveplanErrorInterceptor`)** — already implemented by `@suaveplan/logger` via its OTel span-event adapter. Not re-introduced here. Migration: existing consumers should depend on `@suaveplan/logger` directly.
|
||||
- **`W3CTraceContextPropagator`** — the legacy class wraps `traceparent` / `tracestate` parsing. Genesis already exposes `buildTraceParent`, `parseTraceParent`, `injectTraceContext`, `extractTraceContext` from `./propagation` (live in `src/core/propagation/propagation.ts`). The wrapper class would duplicate this surface. If a `TextMapPropagator`-shaped binding is required later, it can be added as a tiny adapter; not in this change.
|
||||
- **`Baggage` class as a value object** — the legacy `Baggage` is an immutable `Map<string, BaggageEntry>` wrapper. `@opentelemetry/api` already exposes `propagation.createBaggage` / `propagation.getBaggage` / `propagation.setBaggage`; this change wraps the propagation step (header inject/extract) without re-introducing the class.
|
||||
- **Confluent / OTLP-grpc / OTLP-http exporter wrappers** — those exporters are first-class npm packages (`@opentelemetry/exporter-trace-otlp-http`, etc.). Out of scope here.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from the legacy `packages/universal/opentelemetry/src` tree at `../suaveplan/`. No `enterprise/opentelemetry.md` per-feature audit doc exists; source mapping cites the upstream files directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `InMemorySpanExporter` (export / getFinishedSpans / getSpansByName / reset / shutdown / spanCount) | `packages/universal/opentelemetry/src/exporters/memory/memory.ts` | L1-L51 | Verbatim port; retarget `SpanExporter` import to `@opentelemetry/sdk-trace-base` (legacy used in-package types); `forceFlush()` added per upstream-SDK contract. |
|
||||
| `ConsoleSpanExporter` + `ConsoleSpanExporterOptions` | `packages/universal/opentelemetry/src/exporters/console/console.ts` | L1-L85 | Verbatim port; same retarget; `pretty: true` default preserved. |
|
||||
| `generateTraceId` / `generateSpanId` / `isValidTraceId` / `isValidSpanId` | `packages/universal/opentelemetry/src/utils/id-generator.ts` | L36-L74 | Verbatim grammar; drop the `Math.random()` fallback (genesis determinism rule — see below). |
|
||||
| `INVALID_TRACE_ID` / `INVALID_SPAN_ID` / `TRACE_ID_HEX_LENGTH` / `SPAN_ID_HEX_LENGTH` | `packages/universal/opentelemetry/src/constants/index.ts` | L1-L40 | Verbatim. |
|
||||
| `W3CBaggagePropagator` (extract / inject / fields) | `packages/universal/opentelemetry/src/propagation/baggage/baggage.ts` | L140-L173 | Re-target the propagator interface to `TextMapPropagator` from `@opentelemetry/api`; preserve URI-encoding semantics. |
|
||||
| `serializeBaggage` / `parseBaggage` helper functions | `packages/universal/opentelemetry/src/propagation/baggage/baggage.ts` | L91-L134 | Verbatim. |
|
||||
| `BAGGAGE_HEADER` / `MAX_BAGGAGE_ENTRIES` / `MAX_BAGGAGE_KEY_LENGTH` / `MAX_BAGGAGE_VALUE_LENGTH` | `packages/universal/opentelemetry/src/constants/index.ts` | L41-L60 | Verbatim. |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `createDeterministicIdGenerator(rng)` | Genesis-original. Legacy `randomHex` (L14-L30 of `id-generator.ts`) silently falls back to `Math.random()` when `crypto.getRandomValues` is unavailable — a determinism-rule violation per CLAUDE.md and the genesis-wide rule that no module-scope `Math.random()` is permitted. The deterministic factory replaces the fallback with an explicit `Rng` injection point so tests can pin trace IDs without wiring the `crypto` polyfill. |
|
||||
| `OTEL.EXPORT_FAILED` / `OTEL.INVALID_TRACE_ID` / `OTEL.INVALID_SPAN_ID` error codes | CLAUDE.md §Workflow rule 2 — every thrown error routes through `@suaveplan/error`. Legacy returned plain `ExportResultCode.FAILED { error: new Error("...") }`; genesis wraps that `error` field in `OpenTelemetryError` with a stable code so consumers can `instanceof` discriminate. |
|
||||
| `forceFlush()` on `InMemorySpanExporter` | The `@opentelemetry/sdk-trace-base` `SpanExporter` interface requires `forceFlush()` even though the legacy in-package interface did not. Without it the exporter does not satisfy the upstream contract and `BatchSpanProcessor` rejects it at runtime. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/monitor/opentelemetry/src/exporters/memory/memory.test.ts` — covers `InMemorySpanExporter` (collect, query-by-name, reset, shutdown idempotency, post-shutdown export rejection, forceFlush)
|
||||
- `packages/monitor/opentelemetry/src/exporters/memory/memory.spec-conformance.test.ts` — pins the `SpanExporter` contract from `@opentelemetry/sdk-trace-base`
|
||||
- `packages/monitor/opentelemetry/src/exporters/console/console.test.ts` — covers `ConsoleSpanExporter` (default logFn, custom logFn, pretty / compact, post-shutdown export rejection)
|
||||
- `packages/monitor/opentelemetry/src/exporters/console/console.spec-conformance.test.ts` — pins SpanExporter contract
|
||||
- `packages/monitor/opentelemetry/src/id-generator/id-generator.test.ts` — covers `generateTraceId` / `generateSpanId` length, charset, non-zero invariant; `isValidTraceId` / `isValidSpanId` accept/reject grammar
|
||||
- `packages/monitor/opentelemetry/src/id-generator/id-generator.spec-conformance.test.ts` — runs `fast-check` round-trip property (∀ generated id, `isValidId(id)` is true)
|
||||
- `packages/monitor/opentelemetry/src/id-generator/deterministic.test.ts` — covers `createDeterministicIdGenerator` with seeded `Rng`
|
||||
- `packages/monitor/opentelemetry/src/id-generator/deterministic.spec-conformance.test.ts` — pins behaviour when supplied `Rng` produces all-zero output (throws `OTEL.INVALID_TRACE_ID` / `OTEL.INVALID_SPAN_ID` after retry budget)
|
||||
- `packages/monitor/opentelemetry/src/baggage/baggage.test.ts` — covers `W3CBaggagePropagator` extract/inject round-trip, `serializeBaggage` / `parseBaggage` round-trip, URI-encoding for special characters
|
||||
- `packages/monitor/opentelemetry/src/baggage/baggage.spec-conformance.test.ts` — pins the `MAX_BAGGAGE_ENTRIES` / key-length / value-length caps (entries past the cap are ignored, not thrown)
|
||||
|
||||
## Migration
|
||||
|
||||
This is an additive change. No legacy API is removed.
|
||||
|
||||
For consumers expecting the legacy substrate's custom `Tracer` / `Meter` / `Span` classes:
|
||||
|
||||
- **Old behavior:** `import { Tracer, Span } from "@suaveplan/opentelemetry"` returned hand-rolled classes implementing the OTel data model.
|
||||
- **New behavior:** the same surface comes from `@opentelemetry/api`. `@suaveplan/opentelemetry` re-exports `trace`, `metrics`, `context`, `propagation` from `@opentelemetry/api` for convenience.
|
||||
- **Search command consumers should run:** `grep -rln "from \"@suaveplan/opentelemetry\"" packages/ src/ | xargs grep -l "Tracer\|TracerProvider\|Meter\|Counter\|Gauge\|Histogram\|Span\b"`.
|
||||
- **Recommended replacement:** import `Tracer` / `Meter` / `Counter` / `Gauge` / `Histogram` from `@opentelemetry/api` directly. The api package is already pulled via the `observability` catalog and is `@suaveplan/opentelemetry`'s peer.
|
||||
|
||||
For consumers expecting `installSuaveplanErrorInterceptor` from the legacy `error-bridge`:
|
||||
|
||||
- **Old behavior:** legacy `installSuaveplanErrorInterceptor()` returned an uninstaller and wired `SuaveplanError` instances onto active spans as span events.
|
||||
- **New behavior:** `@suaveplan/logger` ships the OTel span-event adapter; `createLogger().error(err)` records the error onto the active span automatically.
|
||||
- **Search command:** `grep -rln "installSuaveplanErrorInterceptor\|isSuaveplanErrorInterceptorInstalled" packages/ src/`.
|
||||
- **Recommended replacement:** depend on `@suaveplan/logger` and use its `error()` method.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/opentelemetry` to enumerate. From `grep -rl "@suaveplan/opentelemetry" /home/ubuntu/web/genesis/packages/ --include="package.json"`:
|
||||
|
||||
- `@suaveplan/events-metrics` — [x] tests run / [x] docs reviewed / [ ] no action needed. The events-metrics test suite is the most likely consumer of `InMemorySpanExporter`; the package's existing OTel assertions (currently driven by the upstream SDK directly) should be migrated to use the new exporter so future tests don't have to recreate it. Any places that emit traceparent headers should adopt `injectTraceContext` (already exported); this change does not rename that helper.
|
||||
|
||||
For all other monorepo packages: no action — they consume `@opentelemetry/api` directly via the catalog, not through `@suaveplan/opentelemetry`. This change is additive only.
|
||||
@@ -0,0 +1,180 @@
|
||||
<!-- 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 -->
|
||||
112
openspec/changes/opentelemetry-completion/tasks.md
Normal file
112
openspec/changes/opentelemetry-completion/tasks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Tasks — OpenTelemetry completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Status: **DEFERRED**. `packages/monitor/opentelemetry/src/` contains `core` (with `propagation` + `sdk`), `errors`, `schemas`, and `utils` (`metrics-utils`, `resource`, `span-utils`). None of the five proposed additions exist: no `exporters/` directory (no `InMemorySpanExporter`, no `ConsoleSpanExporter`), no `id-generator/` (no `generateTraceId` / `generateSpanId` / `createDeterministicIdGenerator`), no `baggage/` (no `W3CBaggagePropagator`, no `serializeBaggage` / `parseBaggage`). The `OTEL.EXPORT_FAILED` / `OTEL.INVALID_TRACE_ID` / `OTEL.INVALID_SPAN_ID` error codes are also absent. 0% of work is done.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/archive/2026-04-19-enterprise-opentelemetry/proposal.md` end-to-end to confirm what was committed and what slipped
|
||||
- [ ] 1.2 Read `openspec/AGENTS.md` Sections 13 (spec-conformance audit) and 14 (source mapping for migrations); §14a (proposal must list affected dependents)
|
||||
- [ ] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md` — include the `## Position on the legacy substrate` rationale (out-of-scope items justified)
|
||||
- [ ] 1.4 Fill `## Source mapping` with upstream line ranges for each ADDED requirement; every "no source line" requirement justified
|
||||
- [ ] 1.5 Draft `specs/opentelemetry/spec.md` from `openspec/templates/spec-delta.md` — five ADDED requirements: in-memory exporter, console exporter, id-generator, deterministic id-generator, baggage propagator
|
||||
- [ ] 1.6 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.7 Draft `## Migration` section in `proposal.md` listing legacy `Tracer` / `Meter` / `error-bridge` consumers and their replacement path
|
||||
- [ ] 1.8 `bunx openspec change validate opentelemetry-completion` exits with no errors
|
||||
|
||||
## 2. Implementation — exporters
|
||||
|
||||
- [ ] 2.1 Add `@opentelemetry/sdk-trace-base` to `packages/monitor/opentelemetry/package.json` `dependencies` via `catalog:observability` (verify the catalog entry exists; add it if missing)
|
||||
- [ ] 2.2 Create `packages/monitor/opentelemetry/src/exporters/memory/memory.ts` implementing `InMemorySpanExporter` against the `SpanExporter` interface from `@opentelemetry/sdk-trace-base`
|
||||
- [ ] 2.3 Provide `getFinishedSpans()`, `getSpansByName(name)`, `reset()`, `shutdown()`, `forceFlush()`, `spanCount` getter
|
||||
- [ ] 2.4 Make `shutdown()` idempotent; subsequent `export()` calls return `ExportResultCode.FAILED` with `OpenTelemetryError(OTEL.EXPORT_FAILED, ...)` as the `error` field
|
||||
- [ ] 2.5 Create `packages/monitor/opentelemetry/src/exporters/console/console.ts` implementing `ConsoleSpanExporter` and `ConsoleSpanExporterOptions`
|
||||
- [ ] 2.6 Default `logFn` to `console.log`; default `pretty` to `true`; render span kind, status code, ISO start/end times, duration, attributes, events, links, resource attributes
|
||||
- [ ] 2.7 Apply the same post-shutdown `OTEL.EXPORT_FAILED` rule to the console exporter
|
||||
- [ ] 2.8 Add `OTEL.EXPORT_FAILED` to `packages/monitor/opentelemetry/src/errors/opentelemetry-error.ts` `OTEL_CODES`
|
||||
|
||||
## 3. Implementation — id-generator
|
||||
|
||||
- [ ] 3.1 Create `packages/monitor/opentelemetry/src/id-generator/id-generator.ts` exporting `generateTraceId`, `generateSpanId`, `isValidTraceId`, `isValidSpanId`
|
||||
- [ ] 3.2 Generation uses `crypto.getRandomValues` exclusively; throws `OpenTelemetryError(OTEL.INVALID_TRACE_ID, ...)` (or `INVALID_SPAN_ID`) if the host has no `crypto.getRandomValues` (genesis determinism rule — no `Math.random()` fallback)
|
||||
- [ ] 3.3 Re-roll on the all-zero value; cap at 8 attempts; if exceeded, throw `OTEL.INVALID_TRACE_ID` / `INVALID_SPAN_ID`
|
||||
- [ ] 3.4 Add `INVALID_TRACE_ID` / `INVALID_SPAN_ID` / `TRACE_ID_HEX_LENGTH` / `SPAN_ID_HEX_LENGTH` constants in `packages/monitor/opentelemetry/src/constants/trace.ts`
|
||||
- [ ] 3.5 Create `packages/monitor/opentelemetry/src/id-generator/deterministic.ts` exporting `createDeterministicIdGenerator(rng: Rng): { generateTraceId: () => string; generateSpanId: () => string }`
|
||||
- [ ] 3.6 Use the supplied `Rng.bytes(n)` to fill the trace / span ID; preserve the all-zero retry budget; route exhaustion through the same error codes
|
||||
- [ ] 3.7 Add `OTEL.INVALID_TRACE_ID` and `OTEL.INVALID_SPAN_ID` to `OTEL_CODES`
|
||||
|
||||
## 4. Implementation — baggage
|
||||
|
||||
- [ ] 4.1 Create `packages/monitor/opentelemetry/src/baggage/baggage.ts` exporting `W3CBaggagePropagator`, `serializeBaggage`, `parseBaggage`, `BAGGAGE_HEADER`
|
||||
- [ ] 4.2 `W3CBaggagePropagator` implements `TextMapPropagator` from `@opentelemetry/api`; `extract(context, carrier, getter)`, `inject(context, carrier, setter)`, `fields()` returning `["baggage"]`
|
||||
- [ ] 4.3 `inject` reads the active baggage via `propagation.getBaggage(context)`; serialises with URI-encoding; calls `setter.set(carrier, BAGGAGE_HEADER, value)`; no-op if the baggage is empty
|
||||
- [ ] 4.4 `extract` reads `carrier[BAGGAGE_HEADER]` (handling string-array carrier values); parses; sets via `propagation.setBaggage(context, baggage)`
|
||||
- [ ] 4.5 Honour `MAX_BAGGAGE_ENTRIES` / `MAX_BAGGAGE_KEY_LENGTH` / `MAX_BAGGAGE_VALUE_LENGTH` caps; entries past the cap are silently ignored (matches legacy and W3C tolerant-parser behaviour)
|
||||
- [ ] 4.6 Add the cap constants to `packages/monitor/opentelemetry/src/constants/baggage.ts`
|
||||
|
||||
## 5. Wiring
|
||||
|
||||
- [ ] 5.1 Update `packages/monitor/opentelemetry/src/index.ts` to re-export every new symbol
|
||||
- [ ] 5.2 Update `packages/monitor/opentelemetry/package.json` `exports` map: add `./exporters`, `./id-generator`, `./baggage`, `./constants` subpath entries
|
||||
- [ ] 5.3 Add a top-level re-export of `trace`, `metrics`, `context`, `propagation` from `@opentelemetry/api` (Migration §1 — the convenience layer for consumers expecting the legacy substrate's classes)
|
||||
- [ ] 5.4 Update root `README.md` (`packages/monitor/opentelemetry/README.md`) `## Modules` section to link the new co-located `.md` files
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [ ] 6.1 `src/exporters/memory/memory.test.ts` — collect, query-by-name, reset, shutdown idempotency, post-shutdown export rejection, forceFlush
|
||||
- [ ] 6.2 `src/exporters/memory/memory.spec-conformance.test.ts` — pin the `SpanExporter` contract from `@opentelemetry/sdk-trace-base` (export signature, shutdown returns Promise, forceFlush returns Promise)
|
||||
- [ ] 6.3 `src/exporters/console/console.test.ts` — default logFn, custom logFn, pretty / compact rendering, post-shutdown rejection
|
||||
- [ ] 6.4 `src/exporters/console/console.spec-conformance.test.ts` — same SpanExporter contract pin
|
||||
- [ ] 6.5 `src/id-generator/id-generator.test.ts` — length, charset, non-zero invariant; `isValidTraceId` / `isValidSpanId` accept/reject
|
||||
- [ ] 6.6 `src/id-generator/id-generator.spec-conformance.test.ts` — `fast-check` property `∀ id: isValidTraceId(generateTraceId())` over 1000 samples
|
||||
- [ ] 6.7 `src/id-generator/deterministic.test.ts` — seeded `Rng` produces reproducible IDs across two calls
|
||||
- [ ] 6.8 `src/id-generator/deterministic.spec-conformance.test.ts` — supplied `Rng` that always yields zero bytes throws `OTEL.INVALID_TRACE_ID` after the 8-attempt budget
|
||||
- [ ] 6.9 `src/baggage/baggage.test.ts` — `W3CBaggagePropagator` round-trip extract/inject, URI-encoding round-trip for `key with space=value;with semicolon`
|
||||
- [ ] 6.10 `src/baggage/baggage.spec-conformance.test.ts` — `MAX_BAGGAGE_ENTRIES` cap silently drops the 181st entry; key longer than `MAX_BAGGAGE_KEY_LENGTH` is dropped; value longer than `MAX_BAGGAGE_VALUE_LENGTH` is dropped
|
||||
- [ ] 6.11 Every `it(...)` in 6.1-6.10 carries `{ timeout: 5000 }`
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [ ] 7.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 7.2 README links every co-located `.md` under `## Modules`
|
||||
- [ ] 7.3 README `## Features` enumerates the five new capabilities
|
||||
- [ ] 7.4 Update `CLAUDE.md` package listing for `@suaveplan/opentelemetry` to include the new exports
|
||||
|
||||
## 8. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 8.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/opentelemetry` — green
|
||||
- [ ] 8.3 `bunx turbo test:coverage --filter=@suaveplan/opentelemetry` — 100/100/100/100
|
||||
- [ ] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/opentelemetry` — exit 0, no `error` findings
|
||||
- [ ] 8.5 `bunx turbo build --filter=@suaveplan/opentelemetry` — green
|
||||
|
||||
## 9. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 9.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (e.g. `OTEL_CODES` keys)
|
||||
- [ ] 9.2 Zod schemas wired at every declared boundary (constructor options for both exporters; baggage cap config)
|
||||
- [ ] 9.3 Every error code in `OTEL_CODES` thrown by at least one production path (`OTEL.EXPORT_FAILED`, `OTEL.INVALID_TRACE_ID`, `OTEL.INVALID_SPAN_ID` — covered above)
|
||||
- [ ] 9.4 Strict-mode behaviors tested on and off (post-shutdown export, retry-budget exhaustion)
|
||||
- [ ] 9.5 OTel instruments emitted from production paths and asserted with the new `InMemorySpanExporter` (eat your own dogfood — `withSpan` test in `src/utils/span-utils.test.ts` adopts it)
|
||||
- [ ] 9.6 Lifecycle dedup tested where claimed (idempotent `shutdown()` on both exporters)
|
||||
- [ ] 9.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 10. Dependent sweep
|
||||
|
||||
- [ ] 10.1 `bunx turbo test --filter=...@suaveplan/opentelemetry` — green; `@suaveplan/events-metrics` continues to pass
|
||||
- [ ] 10.2 Migrate any in-tree consumer that hand-rolled an in-memory span exporter to use the new `InMemorySpanExporter`. Run: `grep -rln "InMemorySpanExporter\|SimpleSpanProcessor\|BatchSpanProcessor" packages/ src/ --include="*.ts"` and verify no duplicate impl exists
|
||||
- [ ] 10.3 If any dependent currently mocks `@opentelemetry/sdk-trace-base`, replace with the real `InMemorySpanExporter` in a follow-up commit (per the genesis "no mocks" rule)
|
||||
|
||||
## 11. Archive
|
||||
|
||||
- [ ] 11.1 Fold `specs/opentelemetry/spec.md` into `openspec/specs/opentelemetry/spec.md`
|
||||
- [ ] 11.2 Move `openspec/changes/opentelemetry-completion/` → `openspec/archive/<yyyy-mm-dd>-opentelemetry-completion/`
|
||||
- [ ] 11.3 Refresh `MANIFEST.md` if package set or deps changed (this change adds `@opentelemetry/sdk-trace-base` as a dep)
|
||||
Reference in New Issue
Block a user