ADR-094 — Server-side observability for lab-api and mcp (errors, traces, metrics, access logs, analytics, LLM)
Status · Accepted · 2026-10-16 · Operator-directed ("add all o11y elements to lab-api and mcp") · amends the OTLP non-goal in RFC-025 and the errors-only scope of ADR-067 for the two server processes only (the browser stays errors-only) · builds on ADR-081 (Umami) and ADR-082 (telemetry env ladder)
Context
RFC-025 ruled out OTLP traces because Orrery had "no traceable workload (static SPA, no server)". It said to revisit "when the VPS adds… a daemon". It now has two: orrery-lab-api (OAuth AS + /ask over the LiteLLM gateway) and orrery-mcp (64 physics tools over MCP). On 2026-10-16 their whole signal was one startup log line each:
- no error tracking (there was no server-side Sentry);
- no traces (VictoriaTraces had no
orrery-*services); - no request metrics;
- no per-request log line;
- no record of which MCP tools are used;
/ask's LLM calls reached Langfuse with nothing to join them to a request.
The podcast services on the same VPS already ship all six, and the homelab stack (VictoriaLogs/Metrics/Traces, GlitchTip, Umami, Langfuse) is the shared sink.
Decision
Both servers get the same six signals, modelled on podcast's player-api / player-mcp, all off unless configured. With every variable unset, nothing initialises and nothing leaves the process. That keeps tests, CI image smoke tests and forks silent.
- Errors → GlitchTip via
@sentry/node. Projects 31orrery-lab-apiand 32orrery-mcp; DSNs are deploy secrets. Errors only:tracesSampleRateunset and Sentry's own OTel setup skipped, so it doesn't fight the tracer in (2).environment=prod,release=sha-<7>(homelab ADR-0010; the browser'sgha-<8>is left as is). PII: no user email, no request bodies, no prompt text. - Traces → VictoriaTraces, OTLP http/protobuf to
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. The tracer is set up manually: the servers ship as one esbuild bundle with nonode_modules, so require-hook auto-instrumentation is not available.- Resource:
service.name=orrery-lab-api|orrery-mcp(the same value as the logs'servicelabel),deployment.environment=prod,service.version=sha-<7>. - Spans: one server span per request (inbound
traceparenthonoured), one span per LLM call, one span per MCP //asktool call. /healthand/metricsare never traced.
- Resource:
- Metrics → VictoriaMetrics via
prom-client, served on a separate internal port (METRICS_PORT, not published to the host, so never reachable through Caddy).- Request count and latency by route and status.
- For mcp,
mcp_tool_calls_total{tool,outcome}andmcp_tool_duration_seconds{tool};toolis bounded by the registry. - Scraped by the VPS Alloy from
orrery.alloy, using the Docker-discovery → bridge-IP patternbase.alloyuses for the podcast APIs.
- Access logs: one line per request:
METHOD route -> status in N.Nms trace_id=<32hex>. Alloy's existing trace-id stage turns it into a link from a log line to its trace./healthand/metricsare not logged. - Analytics → Umami: mcp sends one server-side event per tool call to the Orrery site (hostname
mcp.orrerylearn.com) with{tool, outcome}. It never carries user ids, arguments or prompt text. - LLM → Langfuse via LiteLLM metadata:
/asksendsmetadatawith:trace_id= the OTel trace id;trace_nameorrery ask;session_id= a per-request id (not a user id);generation_name= the tool round;trace_release=sha-<7>.
Consequences
- New runtime dependencies:
@sentry/node(pinned to the same version as the rest of@sentry/*, which@sentry/capacitorenforces),@opentelemetry/{api,sdk-trace-base,exporter-trace-otlp-proto,resources,semantic-conventions,core}(on the line already installed), andprom-client. - Deploy: new GH
prodsecrets for the two DSNs. The OTLP endpoint and Umami site id are non-secret. Each value has to be added in three places (secret env,.envprintf, composeenvironment:). - Scrape order: deploy the images first, then the
orrery.alloyscrape, orupreads 0 until the new images serve/metrics. This is the podcast lesson recorded inbase.alloy.
Alternatives considered
- Auto-instrumentation (
@opentelemetry/auto-instrumentations-node): needs unbundlednode_modulesand require hooks. Rejected for the single-file images. - Sentry tracing instead of OTel: it would send traces to GlitchTip, not VictoriaTraces, so they wouldn't join with logs and metrics.
/metricson the public port, blocked at Caddy: one Caddyfile mistake away from public. Rejected for the separate internal port.