Story 1619: document.* — putting the business question onto the trace

Why this exists (the catastrophe it prevents). The document pipeline already emits spans at every hop — enqueue, relay, extraction batch, per-record extraction — and after Bug-1627 those spans even name the real SQS queue. But ask the one question an operator actually has — "is extraction slow for the BIG documents, the image-heavy scanned ones, or the Textract-OCR path?" — and the traces are mute. They carry firm_id and plumbing keys and nothing about the document the work is about. So a p99 latency spike forces a guess: is it a 400-page scanned PDF going through Textract, or a one-page born-digital memo? You cannot tell from the trace, so you reach for the DB, join across tables, and reconstruct by hand what the span could have told you for free. The cost is paid (every span is stored and billed) and the business signal is thrown on the floor — exactly the failure mode Story-1618 fixed for LLM spend. This Story teaches the ingest/extraction spans a small, bounded document.* vocabulary — page count, MIME type, byte size, extraction engine — read from values the pipeline already has in hand, so a trace answers the business question without a DB round-trip.

Scope & doctrine. Parent EPIC-007 (#498), implementing the pramaan-docs ADR-124 conventions-v2 (#1615, merged) registry extension A1/A4. No new span — this enriches the existing closed-registry spans (DOCUMENT_INGEST_OUTBOX_ENQUEUE, DOCUMENT_EXTRACTION_PROCESS_RECORD). Bounded / non-PII only: document.mime_type and extraction.engine are closed vocabularies, the sizes/counts are integers — no filename (PII), no matter_id-as-KPI (ADR-124 §D9), no content. document.size_bytes is deliberately never promoted to an X-Ray annotation — see Microscopic. One tracing system only (ADOT layer); this touches no tracer APIs, only the approved set_observability_attributes helper.

🛰️ Satellite: stamp each KPI where it first becomes cheaply known

API finalize ──▶ insert_document_ingest_event()      [DOCUMENT_INGEST_OUTBOX_ENQUEUE, PRODUCER]
   │                 known here: content_type
   │                   document.mime_type = content_type
   │               (page_count / size_bytes NOT known — bytes not fetched yet)
   │
   ▼  (outbox row → relay → SQS hop)
worker handler ──▶ per record               [DOCUMENT_EXTRACTION_PROCESS_RECORD, CONSUMER]
   │                 known at span open (from the envelope pointer):
   │                   document.mime_type = envelope.content_type
   │
   └─ process_extraction()  (DB txn; bytes fetched; pages extracted)
        now known → enrich the SAME open span via set_observability_attributes:
          document.size_bytes  = resolved object size   (omitted if unknown)
          document.page_count  = len(pages)
          extraction.engine    = "pdfium" | "textract"
   ─────────────────────────────────────────────────────────────────────────
   batch span (DOCUMENT_EXTRACTION_BATCH): UNCHANGED — keeps messaging.batch.message_count
   only. It spans MANY documents, so a per-document KPI there would be a lie.

The key idea: each KPI is stamped at the first place it is cheap. The MIME type rides the pointer envelope from the very start, so it lands at enqueue and at the per-record span with no work. Page count, byte size, and engine are only knowable after the worker has fetched the object and run extraction — so they are stamped onto the already-open per-record span from inside the processor, reusing the exact values the state machine just computed. No new lookup, no second source.

✈️ Airplane: the change surface

FileRole
src/shared/observability/manual_span.pyNew closed DOCUMENT_KPI_ATTRIBUTE_KEYS frozenset (the four keys) wired into _is_allowed_attribute_key. Kept separate from CUSTOM_PRODUCT_ATTRIBUTE_KEYS/STANDARD_SPAN_ATTRIBUTE_KEYS so the keys are structurally excluded from the X-Ray annotation candidate pool. Docstring updated.
src/shared/document_ingest_outbox.pydocument.mime_type = content_type added to the DOCUMENT_INGEST_OUTBOX_ENQUEUE span — the only document.* dimension cheaply known at enqueue time.
src/workers/document_extraction/handler.pydocument.mime_type = envelope.content_type added to the per-record DOCUMENT_EXTRACTION_PROCESS_RECORD span at open. Batch span left untouched.
src/workers/document_extraction/processor.pyAfter extraction resolves page count, byte size, and engine, enriches the open per-record span via set_observability_attributes (T2 enrichment through the shared allowlist). size_bytes omitted when unknown.
tests/unit/shared/observability/test_manual_span.pyNew: the four KPI keys pass the closed allowlist on the extraction span and through set_observability_attributes; none — size_bytes especially — is ever promoted to a searchable X-Ray annotation, even when a misconfigured SSM list names it.
tests/unit/test_document_extraction_observability.pyNew: the per-record consumer span carries document.mime_type from the envelope pointer.

🔬 Microscopic: why a separate frozenset is the whole safety argument

Title every line by its reason. The allowlist already had four buckets (custom product dims, standard semconv keys, the AWS remote-target key, semconv prefixes). The KPI keys go in a fifth, deliberately isolated bucket:

DOCUMENT_KPI_ATTRIBUTE_KEYS = frozenset({
    "document.page_count", "document.mime_type",
    "document.size_bytes", "extraction.engine",
})
# allowed AS span attributes ...
def _is_allowed_attribute_key(key):
    return (key in CUSTOM_PRODUCT_ATTRIBUTE_KEYS
            or key in STANDARD_SPAN_ATTRIBUTE_KEYS
            or key in AWS_REMOTE_ATTRIBUTE_KEYS
            or key in DOCUMENT_KPI_ATTRIBUTE_KEYS      # ◀── new
            or key.startswith(STANDARD_SPAN_ATTRIBUTE_PREFIXES))

# ... but the X-Ray annotation pool is CUSTOM ∪ STANDARD only:
def xray_annotation_keys():
    allowed = CUSTOM_PRODUCT_ATTRIBUTE_KEYS | STANDARD_SPAN_ATTRIBUTE_KEYS
    return sorted(k for k in configured if k in allowed and k not in DENYLIST)
Why isolation beats a denylist entry. The acceptance criterion is hard: size_bytes must never become a searchable X-Ray annotation (an unbounded numeric annotation is a high-cardinality index footgun). We could have added each KPI key to XRAY_ANNOTATION_DENYLIST, but that only subtracts from a pool the key was eligible for. Putting the KPI keys in their own frozenset means they are never in CUSTOM ∪ STANDARD in the first place — the annotation function cannot select them by construction, regardless of what the SSM-fed indexed list says. A test pins exactly that: even an SSM value that explicitly names document.size_bytes yields no promotion.

Three properties the enrichment holds, and why:

PropertyWhy it matters
Stamp at the first cheap siteMIME type is on the pointer from the start, so it lands at enqueue and at the per-record span for free; page/size/engine only exist post-extraction, so they enrich the already-open span from the processor. No span is given a value it would have to go fetch.
size_bytes omitted when unknownThe allowlist rejects a None attribute value. A document whose object HEAD reports no ContentLength simply gets no document.size_bytes key rather than a crash — the absent-value first-run state is handled by a conditional, not by stamping a misleading zero.
Batch span stays plumbing-onlyDOCUMENT_EXTRACTION_BATCH spans many documents at once; a per-document KPI there would be ambiguous-to-wrong, so it keeps only messaging.batch.message_count. Per-document KPIs live exclusively on the per-record span.
Verify gate. This Story changes only span attributes and the allowlist — no route, DTO, trust-boundary payload, or schema. Proof spans both the unit boundary the change lives at and the real system boundary (the extraction processor against a real DB through the actual pdfium/Textract paths):

One-sentence recall: the document ingest/extraction spans now carry a bounded, non-PII document.* vocabulary — MIME type, page count, byte size, and the pdfium/textract engine — stamped from values the pipeline already holds and held in a frozenset that is structurally barred from X-Ray annotation, so a trace answers "slow for which documents?" without a DB round-trip or any PII leak.