[Layer 2] Align ai_observability OTel emission to GenAI semantic conventions (gen_ai.*)
## Problem
`ai_observability` has emitted OTel spans + metrics (since the 1.3.x series) with ad-hoc names
(`provider`, `operation_type`, `model`, `token_usage` as a serialized array;
counters `ai_token_usage_<key>`) rather than the OTel
[GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
The Layer 2 envelope tracker (#3586453) needs one attribute schema across modules so
the translator reads a single shape.
## Proposed approach — additive, dual-emit, no config toggle
Emit `gen_ai.*` **alongside** the existing ad-hoc attributes/metrics, unconditionally,
when OTel export is enabled. Ad-hoc emission stays (documented legacy; removal targeted
2.0.x). (`gen_ai.*` are Experimental in the spec.)
**Spans** (`AiOtelSpansEventSubscriber`):
| ad-hoc | gen_ai.* | source | note |
|---|---|---|---|
| `provider` | `gen_ai.provider.name` | `getProviderId()` **via mapper** | → well-known value (`openai`/`anthropic`/`gcp.vertex_ai`/`gcp.gemini`/`aws.bedrock`…); fall back to raw id |
| `operation_type` | `gen_ai.operation.name` | `getOperationType()` **via mapper** | emit only well-known values (`chat`/`text_completion`/`embeddings`); omit non-standard (e.g. `text_to_image`) |
| `model` | `gen_ai.request.model` | `getModelId()` | |
| `token_usage[input]` | `gen_ai.usage.input_tokens` | `getTokenUsage()->input` | scalar; **preserve explicit 0** (no `array_filter`) |
| `token_usage[output]` | `gen_ai.usage.output_tokens` | `->output` | scalar; preserve 0 |
| — (opt) | `gen_ai.response.model` | `getRawOutput()['model']` when present | best-effort |
| new | `gen_ai.response.finish_reasons` (array) | non-stream: `getRawOutput()` (OpenAI `finish_reason` / Anthropic `stop_reason`); streaming: preserved iterator finish reason | **depends on #3586473** |
**Metrics** (`AiOtelMetricsEventSubscriber`): emit histogram `gen_ai.client.token.usage`
with `gen_ai.token.type` (`input`/`output` only) + `gen_ai.provider.name` /
`gen_ai.request.model` dimensions, alongside the legacy counters.
## Out of scope (follow-up — do not expand this issue)
- Prompt/completion **content** capture — needs a research spike. `gen_ai.prompt`/
`gen_ai.completion` were removed; the current semconv replaces them with opt-in
structured attributes `gen_ai.input.messages` / `gen_ai.output.messages` (+
`gen_ai.system_instructions`), all Opt-In and still Development/unsettled.
- **2.0.x:** span rename to `"{gen_ai.operation.name} {gen_ai.request.model}"` +
removal of legacy ad-hoc attrs/counters (breaking; dashboard migration).
## Definition of done
- `gen_ai.*` span attrs emitted alongside ad-hoc, unconditionally.
- Provider + operation mappers with documented fallback.
- Scalar usage attrs preserve `0`.
- `gen_ai.response.finish_reasons` (array) for non-stream + stream (latter after the
bug issue).
- `gen_ai.client.token.usage` histogram (input/output) alongside legacy counters.
- Unit tests extending `AiOtelSpansEventSubscriberTest` + `AiOtelMetricsEventSubscriberTest`
(both attribute sets + streaming order).
- Docs: sample `gen_ai.*` span dump; ad-hoc documented legacy (removal 2.0.x).
## Related
- Umbrella #3586445 · envelope tracker #3586453 · `ai_eval` OTel input #3591188
(consumes these spans) · **depends on** #3586473 (streaming-span finalization)
- OTel GenAI semconv: https://opentelemetry.io/docs/specs/semconv/gen-ai/
---
AI-Generated: Yes (Claude Code; mapping verified against `drupal/ai` 1.4.0 + current OTel GenAI semconv; scope hardened via adversarial review.)
issue