docs/plans/2026-06-07-001-feat-observability-diagnostics-plan.md
Add a shared server-side diagnostics layer for OpenAI-compatible chat, HTTP TTS, streaming TTS WebSocket, router attempts, upstream adapters, billing, request logs, product events, logs, traces, and metrics. The immediate acceptance sample is the CosyVoice incident where AIRI returned 502 while UnSpeech/DashScope returned 400, but the implementation should cover the full generation flow rather than only TTS errors.
During the TTS incident, Grafana showed a concentrated burst of POST /api/v1/audio/speech 502 responses for alibaba/cosyvoice-v1 while Tempo exposed an internal POST https://unspeech-production.up.railway.app/v1/audio/speech span returning upstream HTTP 400. Product events recorded speech_failed with final http_status: 502 and reason BAD_GATEWAY, but Loki did not contain structured fields such as upstream HTTP status, upstream provider, upstream error code, response body snippet, or request input snippet.
The result is an operational dead end: we can identify that one user repeatedly triggered the failure, but we cannot answer why DashScope returned 400 without replaying, guessing, or obtaining upstream-side logs. This plan turns each generation request into a correlated diagnostic record that survives across logs, traces, product events, and request logs.
Correlation
requestId through route logs, spans, product events, request logs, router attempts, adapter failures, billing, and final response handling.requestId, trace_id, userId, product event row, or request log row and reconstruct the generation flow.Upstream Failure Detail
fallbackHttpCodes.Input Diagnostics
Storage And Metrics
llm_request_log must become drilldown-capable by storing request id, operation/source, provider, reason, input length, upstream status, and structured diagnostics for failures.product_events metadata must receive scalar drilldown fields for failure diagnosis while respecting the current primitive metadata type.mapUpstreamError can still return client-safe 502/503 errors, while ApiError.cause, structured logs, spans, product events, and request logs keep the upstream 400/429/500 details.upstream_attempt_count, upstream_http_status, upstream_error_code, and input_snippet because ProductEventMetadata currently allows only primitive values.app.ts global onError remains a safety net, but each route should emit request started, blocked, upstream failed, billing failed, succeeded, and failed events with the same diagnostic envelope.fallbackCount before knowing whether a status will actually fallback. The implementation should either move the increment behind the fallback decision or add a distinct attempt-failure counter so dashboards do not call non-fallback 400s "fallbacks".flowchart TB
REQ[Route receives generation request] --> CTX[Create DiagnosticContext]
CTX --> START[Log and product event: requested]
CTX --> ROUTER[LLM/TTS router]
ROUTER --> ADAPTER[Provider adapter]
ADAPTER --> UPSTREAM[UnSpeech / DashScope / other upstream]
UPSTREAM -->|non-2xx / error| ATTEMPT[Build UpstreamDiagnostic]
ATTEMPT --> ROUTER
ROUTER -->|exhausted / non-fallback| FINAL[Build final DiagnosticEnvelope]
FINAL --> LOGS[Loki structured logs]
FINAL --> TRACE[Tempo span attrs/events]
FINAL --> PRODUCT[product_events scalar metadata]
FINAL --> REQLOG[llm_request_log diagnostics jsonb]
FINAL --> METRICS[Prometheus low-cardinality metrics]
FINAL --> CLIENT[Client-safe response]
sequenceDiagram
participant Route as Route handler
participant Diag as Diagnostics module
participant Router as LLM/TTS router
participant Adapter as Adapter
participant Upstream as Upstream API
participant Sinks as Logs/Trace/DB/Metrics
Route->>Diag: newContext(requestId, userId, model, source, inputSummary)
Route->>Sinks: requested event
Route->>Router: route with DiagnosticContext
Router->>Adapter: dispatch attempt
Adapter->>Upstream: HTTP request
Upstream-->>Adapter: HTTP 400 with JSON body
Adapter-->>Router: UpstreamDiagnostic(status, code, message, bodySnippet)
Router-->>Diag: attempt failed, fallback decision
Diag->>Sinks: upstream_failed diagnostics
Router-->>Route: ApiError 502 with diagnostic cause
Route->>Sinks: failed event + request log + span attrs
Route-->>Client: sanitized 502
apps/server/src/services/domain/observability-diagnostics.ts or apps/server/src/services/domain/observability-diagnostics/index.tsapps/server/src/services/domain/observability-diagnostics.test.tsapps/server/src/utils/observability.tsapps/server/docs/ai-context/observability-conventions.mdDiagnosticContext, InputDiagnostic, UpstreamDiagnostic, GenerationFailureDiagnostic, and projection helpers for logs, span attributes, product event metadata, request-log diagnostics, and metric labels. Keep destination rules in code, not scattered at call sites.apps/server/src/utils/observability.ts for existing airi.* attribute naming; apps/server/docs/ai-context/observability-conventions.md for low-cardinality rules.input_chars.userId, requestId, raw input, error message, and body snippet.apps/server/src/services/adapters/tts/unspeech.tsapps/server/src/services/domain/llm-router/router.tsapps/server/src/services/domain/llm-router/error-mapping.tsapps/server/src/services/domain/llm-router/tests/router.test.tsUnSpeechAPIError.responseBody as JSON when possible and extract provider error code/message. Keep raw bodySnippet bounded. Ensure non-fallback 400s are logged and recorded before the router breaks. Revisit fallbackCount so it records real fallback decisions rather than all failed attempts.apps/server/src/services/domain/llm-router/router.ts, which already reads bodySnippet; existing UpstreamAttempt cause shape in apps/server/src/services/domain/llm-router/error-mapping.ts.upstream_http_status: 400, parsed upstream_error_code, parsed upstream_error_message, and bounded upstream_body_snippet.fallbackHttpCodes still emits an upstream failure log and attaches the attempt to ApiError.cause.bodySnippet and now projects the same diagnostic field names.apps/server/src/routes/openai/v1/middlewares/telemetry.tsapps/server/src/routes/openai/v1/operations/chat-completions/index.tsapps/server/src/routes/openai/v1/operations/speech-generation/index.tsapps/server/src/services/domain/openai-speech/index.tsapps/server/src/routes/openai/v1/route.test.tscreateRouteTelemetry so both chat and speech can create a DiagnosticContext, record lifecycle events, and write failure request logs. Move duplicated TTS analytics fields into the shared helper where practical. Preserve existing success accounting and billing semantics.createRouteTelemetry in apps/server/src/routes/openai/v1/middlewares/telemetry.ts; current TTS product event sequence in apps/server/src/services/domain/openai-speech/index.ts.speech_failed metadata with request id, input chars, input snippet, upstream provider, upstream status, error code/message, body snippet, final status 502, and duration.completion_failed metadata with request id, model, input summary, upstream status/body snippet, final status, and duration.routes/audio-speech-ws to the same diagnostic standard as HTTP TTS.apps/server/src/routes/audio-speech-ws/session.tsapps/server/src/routes/audio-speech-ws/types.tsapps/server/src/routes/audio-speech-ws/route.test.tsrequestId into start, upstream dial, upstream control event, upstream error, billing failure, close, success, product event, and request-log paths. Accumulate a bounded input snippet from text frames and record input character counts. Map upstream control errors into the shared diagnostic envelope.apps/server/src/routes/audio-speech-ws/session.ts; existing request-log success write near the end of the session lifecycle.speech_failed product metadata with diagnostic fields.apps/server/src/schemas/llm-request-log.tsapps/server/src/services/domain/request-log.tsapps/server/drizzle/0016_*.sqlapps/server/drizzle/meta/_journal.jsonapps/server/drizzle/meta/0016_snapshot.jsonapps/server/src/schemas/product-events.tsapps/server/src/routes/openai/v1/route.test.tsapps/server/src/routes/audio-speech-ws/route.test.tsrequest_id, operation, source, provider, reason, input_chars, upstream_status, and diagnostics jsonb. Add indexes for request_id, (user_id, created_at), and (provider, upstream_status, created_at) if query plans warrant them. Keep product_events schema stable unless type widening is needed; write scalar diagnostic metadata through U1 projections.apps/server/src/schemas/*.ts; existing migration numbering under apps/server/drizzle/.apps/server/src/otel/index.tsapps/server/src/utils/observability.tsapps/server/docs/ai-context/observability-conventions.mdapps/server/docs/ai-context/observability-metrics.mdapps/server/src/services/domain/llm-router/tests/router.test.tsGatewayMetrics in apps/server/src/otel/index.ts; existing metric naming conventions in apps/server/src/utils/observability.ts.apps/server/docs/ai-context/observability-runbook.mdapps/server/docs/ai-context/observability-conventions.mdapps/server/docs/ai-context/.product_events, and llm_request_log expose request id, user id, source, trigger, model, voice, input chars, input snippet, upstream provider, upstream HTTP 400, parsed upstream code/message, body snippet, final 502, and duration.completion_failed and request logs preserve upstream diagnostics while the client response stays sanitized.userId, then they can query product events/request logs for request ids, jump to Loki by request id, and jump to Tempo by trace id without relying on raw application memory.This change touches the generation hot path, observability conventions, Postgres schema, and dashboard semantics. It also changes the meaning or interpretation of fallback metrics if fallbackCount is corrected. The implementation should update metric docs in the same unit as metric behavior to avoid confusing existing dashboards.
The request-log migration must be backward compatible with existing rows. New columns should be nullable unless there is a safe default. Indexes should be chosen for incident queries, not for every possible metadata field.
llm_request_log changes require Drizzle migration files and test updates across HTTP and WebSocket routes.apps/server/src/app.ts currently has global onError logging, but route-level upstream diagnostics are not guaranteed.apps/server/src/services/domain/openai-speech/index.ts already emits TTS request logs and product events, but failure metadata only carries final status/duration/trigger.apps/server/src/routes/openai/v1/operations/chat-completions/index.ts emits chat lifecycle product events, but router failures do not expose upstream diagnostics in product metadata.apps/server/src/routes/audio-speech-ws/session.ts has WebSocket product events and request logs, but upstream errors do not consistently include request id or input diagnostics.apps/server/src/services/domain/llm-router/router.ts already captures chat upstream bodySnippet; the TTS path mostly collapses adapter errors into strings and can skip logging non-fallback 400s.apps/server/src/services/adapters/tts/unspeech.ts sees UnSpeechAPIError.responseBody, but does not expose parsed upstream code/message as structured fields.apps/server/src/services/domain/llm-router/error-mapping.ts keeps upstream attempts server-side in ApiError.cause, which is the right place to preserve detail while sanitizing client responses.apps/server/src/schemas/product-events.ts stores product event metadata as primitive jsonb values and already has indexes for feature/action/time and user/time queries.apps/server/src/schemas/llm-request-log.ts is currently too thin for incident drilldown: no request id, operation/source, provider, reason, upstream status, or diagnostics jsonb.apps/server/src/utils/observability.ts, apps/server/src/otel/index.ts, and apps/server/docs/ai-context/observability-conventions.md define the existing OTel and metric conventions this plan should extend.