Story 1436: Projection Outbox Trace Links
The projection outbox is a batch boundary. One relay run may drain rows created by different upstream requests, so the relay must not pretend one row is the parent of the whole batch. The durable shape is: store each row's W3C trace carrier at insert time, then link the relay batch span back to every stored context.
Data Path
caller transaction
-> functions.projection_outbox.enqueue span
-> ops.projection_outbox(traceparent, tracestate)
-> projection relay batch
-> functions.projection_relay.batch span links
-> SQS envelope with traceparent/tracestate
What Changed
| Surface | Behavior |
|---|---|
ops.projection_outbox | New nullable traceparent and tracestate columns, added by migration 0104. |
ProjectionOutboxEnvelope | Carries the same trace fields into the deterministic SQS body for future projection consumers. |
| Insert path | Uses the shared observability helper to open functions.projection_outbox.enqueue and persist the active W3C carrier before the source span ends. |
| Relay path | Rehydrates stored carriers as OTel Link objects and passes them to functions.projection_relay.batch. |
Instrumentation Contract
T1 remains library-owned by ADOT and SQL/SQS instrumentations. T2 product attributes stay on the helper allowlist. T3 uses only the registry spans functions.projection_outbox.enqueue and functions.projection_relay.batch; no product code constructs a provider or calls raw tracer APIs.
Semconv check: the batch span emits standard messaging.batch.message_count. PRAMAAN custom dimensions used here are firm_id and matter_id. firm_id is a span attribute only, never baggage.
Verification
uv run pytest tests/unit/test_projection_outbox.py tests/unit/test_projection_outbox_migration.py tests/unit/shared/observability/test_manual_span.py -q
uv run ruff check <touched files>
uv run mypy <touched modules> --follow-imports=skip
.github/scripts/observability-guardrail-check.sh
isolated alembic downgrade e1f2a3b4c5d6 && upgrade head