Skip to content

Check service status and read logs

Reference
  • operator

Scope: OpenTelemetry traces, Prometheus metrics, audit actions, and deployment recipes for the api and gateway services. Covers what is shipped as of the M4 close: the M3-F OTel baseline (M3-F1 trace propagation, M3-F2 domain spans, M3-F3 recipes and this doc) plus the M4 Autonomous Layer spans and audit actions. What is not yet shipped is listed in §6 — What’s not yet shipped with links to the tracking DE entries.

The governed-run spans, the worker tracing and the routing-log purpose labels for runs (ADR 0038, proposed) are implemented and are marked as such where they appear. They are in no release.

Two kinds of visibility are always on: structured logs, and Prometheus’s /metrics endpoints on the api and gateway. /metrics needs no configuration or authentication and is reachable from the Docker host’s loopback interface and from inside the Compose network. Traces are opt-in: nothing leaves the deployment until you set an OTLP endpoint, at which point the api and gateway emit domain-specific spans — citation checking, anonymization, skills, inference dispatch, playbooks, autonomous tool calls — alongside standard HTTP instrumentation. The autonomous-session audit log is separate from tracing and always on: M4 autonomous-session lifecycle events write rows to the local audit_log table regardless of OTel configuration. Anonymization spans carry only entity counts and type labels — never raw entity values — even when traces go to a third-party backend; other domain spans record their own structured attributes (resource IDs, enum/status labels, numeric metrics such as cost and confidence) but never raw document text or request parameters.

Coverage isn’t complete: the streaming inference path, some playbook nodes, and log-to-trace correlation aren’t instrumented yet, and two documented outcomes — a refused metric label and the autonomous_session.started audit action — are defined but never actually written. See §6 and the “Known gap” notes below before building an alert on either.

For the architectural context see docs/architecture.md §OBS and the “What the diagram doesn’t show” section. For the deployment recipes see deploy/observability/README.md.


LQ.AI initializes the OTel SDK only when at least one of the endpoint variables below is set. If none are set, the services start cleanly and spans are silently dropped — no traces leave the deployment (per PRD §5.7). Prometheus /metrics is always on regardless of OTel configuration.

Transport is OTLP/HTTP for all signals.

OTEL_EXPORTER_OTLP_ENDPOINT

Meaning
Master switch. Sets the base URL for all signals (traces + metrics when DE-301 lands). OTel initializes iff this OR a per-signal endpoint is set. Example: http://otel-collector:4318.
Default
unset — no OTel

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT

Meaning
Per-signal override for traces. Takes precedence over the master endpoint for trace export. Useful when splitting traces and metrics to different backends.
Default
unset

OTEL_EXPORTER_OTLP_METRICS_ENDPOINT

Meaning
Per-signal override for metrics (relevant once DE-301 lands).
Default
unset

OTEL_SERVICE_NAME

Meaning
Resource attribute service.name attached to every span.
Default
lq-ai-api (api service), lq-ai-gateway (gateway service). Under ADR 0038 the Compose files and both recipes also set lq-ai-arq-worker for the arq-worker service; see Governed runs.

OTEL_RESOURCE_ATTRIBUTES

Meaning
Arbitrary key=value,key=value pairs appended to the resource. Use for deployment environment, region, or cluster tags. Example: deployment.environment=production,k8s.cluster.name=prod-east.
Default
unset

OTEL_TRACES_SAMPLER

Meaning
Sampler name per the OTel SDK. The SDK default is parentbased_always_on (sample everything). For production, use parentbased_traceidratio and set OTEL_TRACES_SAMPLER_ARG=0.1 (10% head sampling).
Default
parentbased_always_on

OTEL_TRACES_SAMPLER_ARG

Meaning
Sampler argument. For parentbased_traceidratio, the sampling ratio (0.0–1.0).
Default
1.0

Recommended production configuration:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=lq-ai-api # or lq-ai-gateway per service
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production

W3C traceparent propagates across the full api → gateway → provider call chain (M3-F1). A single chat-send surfaces as one end-to-end trace, not three separate traces — verified by test_trace_propagation.py in both api/ and gateway/.

Beyond HTTP auto-instrumentation (FastAPI + httpx), the services emit domain spans for the high-value LQ.AI operations. These spans are no-ops when OTel is disabled.


Wraps the full Citation Engine cascade for one citation candidate. Child spans cover each stage that runs.

Child span Emitted when

citation.stage.exact_match
Always (stage 1)
citation.stage.tolerant_match
Stage 1 misses
citation.stage.paraphrase_judge
Stage 2 misses (single-judge path)
citation.stage.ensemble
Ensemble path active (replaces paraphrase_judge)

Attributes on each citation.stage.* child span:

Attribute Description

citation.stage.verified
Boolean; whether this stage verified the candidate
citation.stage.confidence
Float 0.0–1.0; this stage’s confidence for the candidate

Attributes on citation.verify:

Attribute Description

citation.method
Winning stage: exact_match, tolerant_match, paraphrase_judge, ensemble_strict, or ensemble_majority
citation.confidence
Float 0.0–1.0; maps to stage confidence (0.90/0.70/0.50 for the judge stages)
citation.partial
Boolean; true when the source partially supports the claim
citation.tier_envelope
Inference tier used for judge calls
document.id
ID of the cited source document

Additional attributes on citation.stage.ensemble:

Attribute Description

citation.ensemble.n_judges
Number of parallel judge calls
citation.ensemble.rule
Aggregation rule: strict or majority

Span events:

Event Meaning

exact_match.hit
Stage 1 short-circuit (no further stages run)
tolerant_match.hit
Stage 2 short-circuit
ensemble.budget_fallback
Pre-flight budget check tripped; ensemble skipped

Emitted by the gateway anonymization middleware (gateway/app/anonymization/middleware.py). anonymization.pre wraps the entity-detection + pseudonymization pass before the model call; anonymization.post wraps the rehydration pass on the response.

The domain attributes below are recorded on anonymization.pre only. anonymization.post is created by the @traced("anonymization.post") decorator and carries no domain attributes — it exists to time the rehydration pass and capture an exception if one is raised, nothing more. (The per-entity rehydration count is not recorded; capturing it on the post span is a future enhancement, not a shipped claim.)

Attributes on anonymization.pre:

Attribute Description

anonymization.enabled
Boolean; mirrors the gateway anonymization config enabled flag
anonymization.tier
Inference tier at the time of the anonymization decision (recorded on every pre span)
anonymization.skip_reason
Present when the pass is skipped; one of disabled (config off), tier_floor (routed tier not in apply_at_tiers), privileged (privileged matter), request_opt_out (anonymize=false on the request)
anonymization.entity_count
Integer count of entities detected (0 when skipped)
anonymization.entity_types
Sorted list of entity type labels (e.g., PERSON,MATTER_NUMBER). Never raw entity values — see §4. Recorded only when the pass actually runs (not on the skip path).

Span event on anonymization.pre:

Event Meaning

anonymization.skip.{reason}
Emitted on the skip path, where {reason} is the skip_reason value above (e.g., anonymization.skip.disabled)

Span events:

Event Meaning

anonymization.skip.{skip_reason}
Emitted when anonymization is skipped; the suffix is the skip_reason value (disabled, tier_floor, privileged, or request_opt_out)

One span per skill applied to the request.

Attribute Description

skill.slug
Skill identifier (e.g., nda-review)
skill.version
Skill semver
skill.author
Skill author field from SKILL.md frontmatter (may be null for built-ins until DE-316 lands)
project.id
Matter-scoped project UUID
project.privileged
Boolean; true for privileged matters
chat.id
Chat session UUID

Emitted by the gateway on the non-streaming chat_completions path. Carries the post-dispatch cost and outcome data. Note: the streaming path does not yet emit this span — tracked at DE-317.

Attribute Description

inference.provider
The provider’s configured name from gateway.yaml (for example anthropic-prod, ollama-local) — the operator-chosen entry name, not the adapter type
inference.model
Model ID as sent to the provider
inference.tier
Routed inference tier (1–5)
inference.outcome
success, provider_error, network_error, or unavailable (the last when no adapter could be instantiated). The provider_error / network_error labels come from outcome_label_from_error in gateway/app/router.py; unavailable is set on the NoAdapterAvailableError path in gateway/app/api/inference.py.
inference.tokens_in
Prompt token count (omitted when the provider returns no usage)
inference.tokens_out
Completion token count (omitted when the provider returns no usage)
inference.cost_usd
Computed cost in USD at configured per-model rates

On the unavailable path (no adapter could be instantiated for the resolved target), only inference.provider and inference.outcome are set — inference.model, inference.tier, inference.tokens_in/inference.tokens_out, and inference.cost_usd are absent, because no native model or tier was resolved.


Top-level spans wrapping a full Playbook or Tabular Review execution.

playbook.execute attributes (api/app/playbooks/executor.py): playbook.id, playbook.contract_type, position.count, document.id. Has per-position child spans (playbook.position) carrying playbook.position.id and playbook.position.order (api/app/playbooks/nodes.py).

tabular.execute attributes (api/app/tabular/executor.py): tabular.document_count, tabular.column_count. Has per-cell child spans (tabular.cell) carrying tabular.document.id and tabular.column.name (api/app/tabular/nodes.py).

Note on playbook.position coverage: child spans are emitted in the classify node but not yet in the redline node — tracked at DE-318.


Emitted by the M4 Autonomous Layer. autonomous.execute (api/app/autonomous/executor.py) wraps a full autonomous-session graph invocation; autonomous.tool_call (api/app/autonomous/guard.py) wraps the per-tool chokepoint that enforces the autonomy brakes (halt, phase-grant, cost cap) and dispatches the tool. Both record counts, type labels, IDs, and enum values only — never raw parameters or document text.

Attributes on autonomous.execute:

Attribute Description

autonomous.session_id
Session UUID
autonomous.trigger_kind
What initiated the session (e.g., watch / schedule / manual trigger kind)
autonomous.halt_state
Halt-state latch value at execution start

Attributes on autonomous.tool_call:

Attribute Description

autonomous.session_id
Session UUID
autonomous.phase
Current session phase
autonomous.tool
The tool intent label (never the params)
autonomous.halt_state
Halt-state latch value at the tool boundary
autonomous.outcome
Set on every exit path: external_halt (R5 halt latch), tool_not_granted (R6 phase-grant miss), cost_cap_reached (R4 cap), or — on the dispatch path — the ToolResult.outcome from the executed tool
autonomous.cost_usd
Tool cost in USD; recorded only on the successful dispatch path

The autonomous spans also write audit rows — see §2.4 — Audit actions.


orchestration.job / orchestration.run / orchestration.effect

Section titled “orchestration.job / orchestration.run / orchestration.effect”

ADR 0038 D2, proposed; implemented and in no release. A governed run executes on the arq worker, not in the API request that accepted or approved it. These spans put the worker’s part of a run in the same trace as that request. Attributes are identifiers and labels only — never prompt, document or answer text. autonomous.tool_call, above, still wraps the chokepoint beneath each orchestration.effect.

orchestration.job

Emitted by
api/app/workers/orchestration_worker.py
Wraps
One arq job for one session of a run. It continues the trace carried in the job (below).

orchestration.run

Emitted by
api/app/autonomous/orchestration/executor.py
Wraps
One claimed execution of one session: the planning call, one task, or the combined answer. A child of orchestration.job.

orchestration.effect

Emitted by
api/app/autonomous/orchestration/effects.py
Wraps
One durable effect: a model call, a document read, a knowledge-base search, or a working-note read or write, each sent through the chokepoint. A tool call the application refuses is a step too: it has an effect span with orchestration.intent refused and orchestration.step_kind tool_refused, and no autonomous.tool_call beneath it, because nothing runs. It also leaves a zero-cost effect row, a step shown to the owner and an orchestration.tool_refused audit row.

orchestration.root_id

On
all three
Description
UUID of the run’s root session

orchestration.session_id

On
all three
Description
UUID of the session the span acts for (the root, or one task)

orchestration.outcome

On
job, run
Description
What the invocation ended as. On orchestration.run: completed, waiting_children, busy or stopped; not set when the run span fails. On orchestration.job: one of those, or disabled (the run’s profile is switched off) or interrupted (an error ended the invocation; the recovery sweep resumes from stored state). Every job that opens a span records one. A job that arrives when no profile is enabled at all returns disabled before any span opens.

error.type

On
all three, and autonomous.tool_call
Description
Set only when the span failed: the exception’s class name, which is also the status description. These spans record no exception event and no exception message, because a message can quote SQL parameters, a provider’s reply or document text (quiet_span in api/app/observability_helpers.py).

orchestration.profile

On
run
Description
Registry profile identifier: model_demo_v1 (the 0.8.0 fictional profile) or new_harness_v1 (New harness)

orchestration.role

On
run
Description
root or child

orchestration.stage

On
run
Description
planning before a plan exists, execution after

orchestration.phase

On
effect
Description
Autonomous phase the effect ran in

orchestration.intent

On
effect
Description
The tool intent label, never its parameters

orchestration.skill

On
effect
Description
Name of what the session is pinned to: an installed skill, or for a profile that pins engine prompt files the prompt’s name, such as new_harness/orchestrator.v2. A prompt file is not a skill, and the name is the file’s name.

orchestration.step_kind

On
effect
Description
The step label shown to the owner, such as model_turn or document_read; empty when the effect has none

How a run’s trace is connected.

  1. The API request that accepts a run (POST /api/v1/chats/{chat_id}/runs) or approves it is traced like any other request.
  2. When the API queues the worker job it puts the request’s W3C trace context in the job (enqueue_orchestration_job in api/app/workers/queue.py). orchestration.job continues that context, so the worker’s spans share the request’s trace. A job queued from inside a worker job, such as the wake-up of a run’s tasks, carries that job’s context the same way. The carrier is empty when tracing is not configured. A job woken by the recovery sweep, which runs outside any request, has nothing to continue and starts its own trace. So does a job whose carrier is malformed: the carrier is data from the queue, it grants nothing, and it cannot fail the job.
  3. Inside the worker, orchestration.run wraps each claimed session and orchestration.effect wraps each effect. A model call leaves the worker through the same gateway client code the API uses, so its context continues to the gateway’s spans.
  4. The arq worker must have OTEL_EXPORTER_OTLP_ENDPOINT set. The worker initializes tracing from the same variables as the API; without an endpoint its spans are dropped and a run’s trace ends at the API request. docker-compose.yml and docker-compose.release.yml pass OTEL_EXPORTER_OTLP_ENDPOINT (empty by default) and OTEL_SERVICE_NAME (default lq-ai-arq-worker, overridden by LQ_AI_ARQ_WORKER_OTEL_SERVICE_NAME) to arq-worker. Both recipes under deploy/observability/ set arq-worker to http://otel-collector:4318 with service name lq-ai-arq-worker and the same sampler default as the API.

What a span does not carry. A span holds identifiers and labels. It holds no prompt, no opening message, no model reply, no tool-call argument, no document text and no search query or passage. How a run’s calls were set up and what its model wrote back are read from the run’s own records through a route for the run’s owner (and, with an audit row for each read, an admin or an auditor), GET /api/v1/autonomous/orchestration/{root_id}/sessions/{session_id}/trace, described in the backend OpenAPI sketch. That read is not a span, a log or an export to a Collector. It rebuilds each call’s opening from the records and returns each turn’s text and tool calls to that reader. It does not hold document text a read returned or passages a search returned, nor a later turn’s full request.

What was checked. The plan’s Implementation status lists test_runs_in_chat.py and test_run_tracing.py as the evidence for these spans and for the trace context carried through queued jobs. It also lists no full Docker stack smoke, so a run’s trace through a Collector on a running Compose stack with these recipes has not been observed.


Prometheus metrics are served by the api (:8000/metrics) and gateway (:8001/metrics). These endpoints are always on and carry no authentication (the gateway’s key check is attached to its /v1 routers, not to /metrics). They are not container-internal: docker-compose.yml publishes both services on the host’s loopback interface by default (127.0.0.1:8000, 127.0.0.1:8001), so any process on the Docker host can scrape them. They stay off other interfaces unless the operator changes API_BIND_ADDR / GATEWAY_BIND_ADDR or routes them through a reverse proxy; the shipped proxy recipes forward only /lq-ai-api/v1/* to the api, so /metrics is not on the proxied path unless added.

lq_ai_api_http_requests_total

Type Counter Labels method, route, status

Description
Total HTTP requests handled by the api service

lq_ai_api_http_request_duration_seconds

Type Histogram Labels method, route, status

Description
Request latency distribution on the api service

lq_ai_gateway_http_requests_total

Type Counter Labels method, route, status

Description
Total HTTP requests handled by the gateway service

lq_ai_gateway_http_request_duration_seconds

Type Histogram Labels method, route, status

Description
Request latency distribution on the gateway service

lq_ai_gateway_inference_requests_total

Type Counter Labels provider, tier, outcome

Description
Inference dispatches by provider, tier, and outcome. Incremented by _record_inference_outcome in gateway/app/router.py, which emits success, provider_error, or network_error. See the note below on the refused label.

Known gap — the refused outcome label is never emitted. The INFERENCE_REQUESTS_TOTAL docstring in gateway/app/observability.py lists refused as a possible outcome value, but no code path passes it: _record_inference_outcome is called only with success or the two outcome_label_from_error values (provider_error / network_error). The tier-floor refusal path (_write_refusal in gateway/app/api/inference.py) returns a tier_below_minimum gateway error without incrementing this counter. Treat refused as documentation-only until a refusal counter increment is wired up.


The M4 Autonomous Layer writes structured audit rows to the audit_log table for session lifecycle events, distinct from OTel spans. Coverage is not complete: the started action is reserved but never written at session creation (see the known gap below). All autonomous-session writes flow through autonomous_audit (api/app/autonomous/audit.py), which stamps a canonical autonomous_session.<event> action and gates the details dict to counts, type labels, IDs, costs, and enums — never raw entity values.

The details dict carries only structured scalars (e.g., to_phase, reason, tool, outcome, cost_total_usd, findings_count, artifacts_count, projected_usd).

autonomous_session.phase_transition

Emitted by
api/app/autonomous/phases.py
When
On each session phase transition (details.to_phase)

autonomous_session.tool_call

Emitted by
api/app/autonomous/guard.py
When
At the tool chokepoint — outcome ∈ started, the executed tool’s outcome, or tool_not_granted

autonomous_session.halted

Emitted by
api/app/autonomous/guard.py (external halt), api/app/workers/autonomous_worker.py (idle timeout)
When
On a halt (details.reason = external_halt or idle_timeout)

autonomous_session.cost_cap_reached

Emitted by
api/app/autonomous/guard.py
When
When projected cost would exceed the session cap (details.projected_usd)

autonomous_session.completed

Emitted by
api/app/autonomous/nodes.py
When
On normal session completion (details.cost_total_usd, findings_count, artifacts_count)

autonomous_session.halt_requested

Emitted by
api/app/api/autonomous.py
When
On POST /sessions/{id}/halt — the API-layer request that precedes the executor’s halted event

Known gap — autonomous_session.started is defined but never emitted. The closed-action set _ACTIONS in api/app/autonomous/audit.py includes started, and the module docstring shows it as an example, but no production code path writes it (the only "started" string in the executor is the outcome="started" detail on a tool_call row, a different field). Session creation does not currently write a started audit row. Treat started as a reserved-but-unused action until a creation-time write is wired up.

Governed-run audit rows. Governed runs (ADR 0035) write orchestration.<event> rows through the run store (api/app/autonomous/orchestration/store.py), with resource_type = autonomous_session and the run’s root session as resource_id. details hold identifiers, counts, labels and amounts, never prompt, document or answer text. ADR 0038 (proposed) adds orchestration.tool_refused (a call the application refused: the registry tool name or unregistered, and a code, never the model’s own text) and orchestration.run_closed (an owner closed an uncertain run: the number of effects closed and the amount charged). A run the orchestrator answered by itself, with no plan, writes orchestration.answered_directly in place of orchestration.root_delivered, with verification: unverified and the chat and message identifiers; a plan that fails writes orchestration.planning_failed with the stop reason as a label (invalid_proposal, unfunded_plan, plan_not_handed_over or plan_has_placeholders). For a run hosted in a chat, orchestration.root_delivered carries the chat and message identifiers. For a profile whose answers are checked against the run’s sources (the documents selected for it, and files a knowledge-base search found) it also records verification (citations_checked or unverified), the counts of quotations verified, unverified and unresolved, the number of quoted passages that carry no source marker and so were not checked (citations_unmarked), and the fiduciary-gate status. If the checks themselves fail, the answer is still delivered and the row records verification: unverified and checks: failed. The chat receipts feed reads the orchestration.* rows through the run message’s link to its root session, so they appear in the chat’s receipts drawer. Attaching a chat attachment to the matter for a run writes project.file_attached with source: run_selection; that row is keyed to the matter, so it is in the admin audit log and not in the chat’s receipts.


The gateway writes one inference_routing_log row for every inference request: provider, model, tier, tokens, latency, cost and whether anonymization ran. Two things tie a row to what caused it. chat_id and message_id come from the lq_ai_chat_id and lq_ai_message_id fields the backend sends. purpose is a label for the kind of traffic, from the request’s lq_ai_purpose field (embedding on the embeddings path). The gateway keeps a closed set of labels. A request with no label records chat, and so does a label outside the set. The column is varchar(32), so a label must fit it.

purpose Recorded for

chat
Ordinary chat completions; also the fallback for no label or an unknown one
embedding
Embeddings calls
judge_paraphrase
Citation Engine paraphrase and ensemble judge calls
judge_case_content
Citation Engine whole-opinion judge for case law
judge_authority_content
The same judge for other authority sources
judge_treatment
Case-law treatment-layer judge
tabular_extraction
Tabular Review cell extraction
tabular_bulk_op
Tabular Review bulk operations: redline reports and summary memos
playbook_executor
Playbook executor
playbook_easy_extract
Easy Playbook extraction
autonomous_executor
A legacy single autonomous session’s model calls
orchestration_run
A governed run’s model calls: planning (which may be the orchestrator’s own answer), tasks and the combined answer

Proposed (ADR 0038 D2). Only chat, embedding, judge_paraphrase and tabular_bulk_op were in the set before. The other labels were already sent by the backend and recorded as chat; recording them as themselves, and the chat and message identifiers on a run’s model calls, are implemented. Two Easy Playbook assembly labels, playbook_easy_assemble_describe_position and playbook_easy_assemble_describe_tier, are longer than the column and are not in the set, so those rows still record chat. The set is _KNOWN_PURPOSES in gateway/app/api/inference.py. Chat and message identifiers are also passed on tool_call_log rows, but that table records only calls to external tool providers and neither shipped run profile grants one, so a run writes none.


Structured logs are emitted by both services. Log-trace correlation (injecting trace_id / span_id into log lines) is not yet shipped — tracked at DE-300. Until DE-300 lands, pivoting from a span in Tempo or Honeycomb to the logs for that request requires matching on timestamp + service.name manually.

Before attributing a timeout in the logs to the model, check which layer timed out: the gateway’s provider adapters (Anthropic, OpenAI and Ollama) default to 600 s, but the api’s own client to the gateway waits 60 s and its streaming call inherits that. See Something is wrong for both layers.


Two ready-made Docker Compose overlays are in deploy/observability/. Read deploy/observability/README.md first — it explains how to switch between them and what each adds to the base stack.

Self-hosted: Grafana + Tempo + Loki + Prometheus

Section titled “Self-hosted: Grafana + Tempo + Loki + Prometheus”

Recipe: deploy/observability/grafana-tempo-loki/

Use this when you are starting fresh and want a self-contained observability stack with no third-party SaaS accounts. The overlay adds Grafana, Tempo (traces), Loki (logs), Prometheus (metrics), and an OTel Collector — all running alongside the main LQ.AI Compose stack. The OTel Collector is the collection point; api and gateway send spans to it over OTLP/HTTP; it fans out to Tempo. Prometheus scrapes /metrics directly.

Time-to-first-trace once the overlay is running: under 15 minutes with the bundled Grafana dashboard.

Existing backend: OTel Collector standalone

Section titled “Existing backend: OTel Collector standalone”

Recipe: deploy/observability/otel-collector-standalone/

Use this when you already run Honeycomb, Datadog, Lightstep, Splunk, or any other OTLP-compatible backend and you do not want Grafana or Tempo locally. The overlay adds only an OTel Collector configured to forward spans to your backend. You supply the backend endpoint and API key via environment variables in the Collector config; the api and gateway are wired to the local Collector, which handles batching and export.


The Anonymization Layer (Inference Gateway; docs/security/anonymization.md) pseudonymizes sensitive entities — names, matter numbers, organization names, contact details — before they reach a cloud inference provider. The same posture extends to telemetry: span attributes carry entity counts and type labels only, never raw entity values.

Concretely: if a request triggers anonymization of three person names and one matter number, the anonymization.entity_count attribute is 4 and anonymization.entity_types is the sorted array ["MATTER_NUMBER", "PERSON"]. The actual names and the matter number do not appear in any span attribute, span event, or metric label.

This invariant is enforced by gateway/tests/test_anonymization_observability.py.

Operators forwarding trace data to a third-party backend (Honeycomb, Datadog, Lightstep) receive the same guarantee: the telemetry side-channel cannot leak what the Anonymization Layer is designed to protect.


Per PRD §5.7: until OTEL_EXPORTER_OTLP_ENDPOINT or a per-signal endpoint is set, no traces leave the deployment. The OTel SDK is present in the service images but the TracerProvider is never initialized, and spans are silently dropped.

Prometheus /metrics is always on and unauthenticated. With the default Compose bindings it is reachable from the Docker host’s loopback interface (127.0.0.1:8000 for api, 127.0.0.1:8001 for gateway) as well as from inside the Compose network; reaching it from anywhere else requires the operator to rebind the port or route it through a reverse proxy. The metrics endpoint does not emit data to any external destination — it is a pull surface, not a push surface.

This behavior is pinned by tests/test_observability.py (cross-cutting integration test, added alongside this doc in M3-F3).


The items below are honestly deferred. Each links to its PRD §9 tracking entry with the acceptance criteria and contributor profile.

OTel Deepening DEs (PRD §9, added at M3-F close):

DE-299

Title
OTel instrumentation for SQLAlchemy + ARQ workers
Notes
DB query and background-job latency are blind spots today. ADR 0038 (proposed) gives the governed-run job path its own orchestration.job span, which is not general arq instrumentation.

DE-300

Title
Log-trace correlation via structured-logger trace_id / span_id injection
Notes
Pivoting from a span to its logs requires manual timestamp correlation until this lands

DE-301

Title
OTel MeterProvider for metrics export
Notes
Metrics today go only to Prometheus; OTel-native metrics export (Honeycomb-only, Datadog APM-only) is additive

DE-302

Title
Reconcile OTel with the OpenWebUI fork’s inherited telemetry
Notes
The OWUI-inherited layer runs as a parallel service.name; alignment or disable decision pending

DE-303

Title
Browser RUM via OpenTelemetry SDK
Notes
Frontend spans not yet emitted; opt-in env var + CSP review required

Streaming coverage gaps:

DE-315

Title
Streaming-rehydration per-chunk spans
Notes
StreamingRehydrator path not yet instrumented

DE-317

Title
inference.dispatch span on the streaming path
Notes
Non-streaming path only today

Playbook coverage gap:

DE-318

Title
playbook.position child spans on the redline node
Notes
Classify-node positions are spanned; redline-node positions are not

LLM-specific tracing sink (Langfuse) — not implemented. PRD §5.4 describes an optional self-hosted Langfuse sink for LLM-specific tracing. Nothing in api/ or gateway/ implements it. The telemetry: block in gateway.yaml.example, with its opentelemetry and langfuse entries, is accepted by the gateway’s configuration loader and read by no code. The LANGFUSE_* variables in .env.example and the Compose files are read by no code either, so setting them has no effect. Trace export is controlled only by the OTEL_* variables in §1. Spans, audit rows and routing-log rows carry identifiers, counts and labels, not prompt or document text, and the autonomous, tabular, orchestration and citation spans record a failure’s class and never its message (the other spans still record it). Under ADR 0038 (proposed), a run’s owner can read how each call was set up and what the model wrote back through the trace read described under the governed-run spans; that read draws on the run’s own records and is not this sink.

SLO + RUM catalog (from docs/proposals/opentelemetry-deepening.md): DE-E (Browser RUM, same as DE-303 above), DE-F (Published SLO catalog), and DE-G (Performance regression tracking) are downstream of the shipped M3-F signals and are deferred to whichever milestone first makes the signal inventory stable enough to define meaningful targets.


Maintained by the maintainer team. Updates land alongside observability changes. If a claim in this doc doesn’t match the code, the code is canonical — please open an issue.