Observability architecture — emit open formats, ship pluggably¶
How the podcast app (and orrery) are observed on the self-hosted stack, and — more importantly — the abstraction that keeps the app vendor-neutral so any fork can point observability at a different backend without touching app code.
- Decision record: ADR-119 (this guide is the how-to; ADR-119 is the why) under ADR-117 (multi-tenant split).
- Full plan / phases: this guide (the phased app-surface rollout is folded in below).
- Sibling guides: OBSERVABILITY_CONTROL_PLANE
(the
podcast_obscross-source probe CLI/MCP — "what is a deploy doing now?"), OBSERVABILITY_EXTENSIONS (alerting / Sentry / Grafana). - Operate / debug prod + current live state: OBSERVABILITY_RUNBOOK
— the verified coverage matrix, debugging flows, and gaps. This design guide has drifted
in places (backend host, dashboards, GlitchTip player project); the runbook is the
verified current-state. Notably: the reference backend moved DGX →
homelab(Mac mini, tailnet<HOMELAB_IP>) — anywhere below that saysdgx-llm-1:8428/9428/10428, readhomelab:….
The one idea¶
The app emits observability in open, vendor-neutral formats and depends on nothing else. Shipping is a separate, pluggable layer. The reference backend is self-hosted Grafana + VictoriaMetrics/VictoriaLogs/VictoriaTraces on the DGX (tailnet-only), but that's a sink, not a coupling.
APP (producer) SHIPPING (pluggable) BACKEND (swappable)
/metrics (Prometheus) ─┐ Alloy scrape ───────────▶ VictoriaMetrics
emit_event(...) JSONL/stdout┼──▶ Alloy tail (stdout+files) ─▶ VictoriaLogs
OTLP spans (auto-instrument)┤ OTEL SDK → OTLP HTTP ──────▶ VictoriaTraces
Sentry SDK ┤ DSN ──────────────────────▶ GlitchTip
Langfuse span ┘ SDK ──────────────────────▶ Langfuse
depends on: open formats only config / DSN, not app code
Signal taxonomy — which signal goes where¶
| Signal | Open standard the app emits | Reference backend | How it ships |
|---|---|---|---|
| Metrics | Prometheus text (/metrics) |
VictoriaMetrics | Alloy scrape (api:8000/metrics, host, cAdvisor) |
| Logs / events | canonical JSONL (emit_event) |
VictoriaLogs | Alloy tails pipeline stdout + corpus JSONL files |
| Traces (spans) | OTLP (OpenTelemetry) | VictoriaTraces | OTEL SDK / opentelemetry-instrument → OTLP HTTP |
| Errors | Sentry protocol | GlitchTip (self-hosted) | Sentry SDK (DSN; Sentry Cloud swappable) |
| LLM prompt/cost | Langfuse span | Langfuse | emit_langfuse_span (in-code choke point) |
Rule of thumb: metrics = numbers over time; logs/events = discrete structured facts (a cost record, a search, a job); traces = one request's span waterfall; errors = exceptions with stacktraces; LLM = prompt/response/cost detail. Keep them straight — don't cram a trace into a log or a metric into an event.
Events / logs — the emit_event SDK¶
src/podcast_scraper/obs/events.py. One function, one envelope, two channels.
from podcast_scraper.obs.events import emit_event
# sink="log" (default): one JSON line to stdout — for pipeline / ephemeral contexts.
emit_event("llm_cost", provider="openai", model="gpt-4o", cost_usd=0.012, run_id=rid)
# sink="file": append to a persistent corpus JSONL — for serve-side events that must
# survive with no agent attached (search, listen, job).
emit_event("search_query", sink="file", corpus_dir=corpus, query_type="semantic")
Envelope on every event: {"ts", "schema", "event_type", ...fields}. None
fields are dropped (lean). Telemetry never raises — a broken emit can't break
the caller. logger= preserves a caller's logger name; ts= backdates.
Event catalog (the contract — grep emit_event( for the live set):
| event_type | sink | emitter | key fields |
|---|---|---|---|
llm_cost |
log | workflow/cost_monitoring.py |
provider, model, tokens, estimated_cost_usd, run_id, stage |
pipeline_progress |
log | workflow/stages/processing.py |
episodes_done, run_id (per-run progress; chart last_value by run_id) |
search_query |
file | search/query_log.py |
query_type (no raw text) |
listen |
file | server/app_user_state.py |
slug, feed_id, ts (epoch — not yet on emit_event, see ADR-119) |
job |
file | .viewer/jobs.jsonl |
job lifecycle |
Adding an event: call emit_event("<name>", ...) at the emission site and add a
row here. That's it — no shipping/backend change.
Collection (the reference sink — infra)¶
The prod-podcast Alloy collector (homelab repo
infra/observability/hosts/prod-podcast/, deployed at /opt/vps-observability/):
- Metrics: scrapes host node-exporter + cAdvisor +
api:8000/metrics(60s). - Logs/events:
loki.source.dockerscoped to the ephemeralpipeline/pipeline-llmrunner containers (capturesemit_eventstdout) +local.file_match→loki.source.filefor the corpus JSONL (search/query_log.jsonl,users/*/listen.jsonl,.viewer/jobs.jsonl). → VictoriaLogs. - Security logs: sshd/fail2ban journal + Caddy access → VictoriaLogs.
- Traces: not via Alloy by default — the app exports OTLP directly (env-var
driven). See the homelab o11y handover docs (
agentic-ai-homelab).
Dashboards are owned in-repo (config/grafana/dashboards/vps/) and pushed to the
shared Grafana VPS — Podcast folder with scripts/ops/push-grafana-dashboards.sh
(token in gitignored .env).
How to point observability somewhere else (forkability)¶
Because the app emits only open formats, a fork changes config, not code:
- Logs → a different store: repoint Alloy's
loki.write(or swap Alloy for Vector/Fluent Bit; add anotelcol/Kafka/S3 output). The app still just writes JSONL. - Metrics → a different TSDB: point any Prometheus-compatible scraper at
/metrics. - Traces → Jaeger/Tempo/Honeycomb: change
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. - Errors → Sentry Cloud (or any Sentry-compatible): change the DSN.
- No backend at all: events still land in stdout + the corpus JSONL files; the
emit_eventfile sink works with no agent attached.
Dev — in-app push, no daemon (environment=dev)¶
Prod ships via the node Alloy collector (it reads the Docker socket and tails containers). A developer running a server or CLI from a checked-out worktree has no container for Alloy to tail — so dev pushes every signal from the process itself, and needs nothing installed or kept running:
- Errors / LLM / traces already self-push via their SDKs (Sentry / Langfuse / OTLP).
- Logs / metrics push direct too, gated on two env vars (VictoriaLogs
jsonlineand VictoriaMetricsimport/prometheusboth accept a plain POST):src/podcast_scraper/obs/dev_push.py.emit_eventalso POSTs each event; the api pushes its Prometheus registry on a timer.
This is inert in the packaged image. dev_push is a true no-op unless
PODCAST_LOGS_PUSH_URL / PODCAST_METRICS_PUSH_URL are set — the Docker/prod deploy
leaves them unset, so Alloy remains the one shipper there (consistent with ADR-119's
"shipping is pluggable": dev just plugs in a different, daemon-less shipper).
Setup: cp .env.obs.dev.example .env.obs.dev, fill the homelab keys/URLs (gitignored),
then make serve (auto-loads it) or, for a raw CLI run,
set -a; source .env.obs.dev; set +a. Every signal is tagged
environment=dev, instance=<worktree>-<port>, so N servers on N ports across N
worktrees never collide — each is a distinct instance in Grafana. Kill the process,
the stream stops.
Component taxonomy — the universal component tag¶
Every signal carries a component value (Sentry tag = metric job = log label
= trace service.name), so one vocabulary filters and joins across metrics, logs,
traces, and errors. It's the "which unit" axis (signal type above is the "which kind").
Podcast estate → GlitchTip project podcast:
component |
what | host | side | → GlitchTip |
|---|---|---|---|---|
api |
FastAPI backend (serves player + operator) | VPS | server | ✓ |
pipeline |
processing pipeline | VPS | server | ✓ |
moss |
moss model server (podcast ML, GPU-hosted) | DGX | server | ✓ |
pyannote |
pyannote diarization (podcast ML, GPU-hosted) | DGX | server | ✓ (when instrumented) |
player |
consumer web player (frontend) | VPS | browser | — client-side; backend errors land under api |
operator |
operator / admin UI (frontend) | VPS | browser | — client-side |
moss and pyannote are podcast components — they only run on the DGX for
GPU. They belong to the podcast project, not a DGX one.
Generic (non-podcast) DGX infra → its own project dgx (senders wire in when
error-instrumented): vllm-autoresearch and other non-podcast DGX services.
orrery → no GlitchTip (client-side static site; the browser can't reach the tailnet-only backend — see the orrery handover).
GlitchTip project map: podcast = id 1 · orrery = id 2 (unused — no orrery
GlitchTip) · dgx = id 3. The DSN keys are secrets — secret store, never git.
Client-side surfaces (player, operator, orrery) are browsers, so they can't
reach the tailnet-only GlitchTip; their errors aren't captured there, while their
server-side counterpart (api) is.
Correlation — navigate one incident across every surface¶
The whole point: from any signal, pivot to the others for the same run / request / episode / person. IDs the app propagates:
| ID | Scope | Carried on |
|---|---|---|
run_id |
a whole pipeline run | logs ([run=…]), events, errors (Sentry tag), Langfuse (run_seed) |
episode_id |
one episode | logs, events, errors |
trace_id / span_id |
one request / span | traces (VictoriaTraces), events (emit_event), logs ([trace=…]), errors (Sentry tag) |
user_id |
one person | listen / playback events |
request_id |
one provider LLM call | llm_cost events |
Navigation recipes:
- Log → trace: click the
trace_idon a log line in Explore — the VictoriaLogs derivedField renders a "View trace" link into VictoriaTraces. (Live for logs that carry a trace id — pipeline logs today; API access logs once the G1 middleware ships.) - Trace → logs: the Tempo datasource's
tracesToLogsV2(G3a, 2026-07-24) — one click from a span to its logs; no more manual copy-paste of the trace id. - Error → trace: the GlitchTip event's
trace_idtag opens the same trace. - Whole run: filter every signal by
run_id— logsrun_id:<id>, cost events, Langfuserun_seed, Sentryrun_idtag — to see the run end-to-end. - Per person / per provider call:
user_id(serving) /request_id(LLM).
run_id joins at run granularity (always present in a pipeline run); trace_id
joins at request granularity (present when OTEL traces are on). Together they
cover coarse and fine navigation across metrics, logs, traces, errors, and LLM.
emit_event + the CorrelationFormatter + Sentry before_send all stamp
trace_id automatically (guarded, no-op without a span).
Operating procedures (runbook)¶
The minimal surface — 4 dashboards in the VPS — Podcast Grafana folder, each
answering one question; raw logs/traces via Explore (no storage cost):
| Dashboard | Question | When |
|---|---|---|
| VPS Overview | Is the box healthy? | daily glance; first stop in any incident |
Podcast App ($surface) |
Is it serving / what's it doing + costing? | daily; after deploy; weekly cost check |
| Edge Security | Is anyone attacking? | weekly; on a fail2ban alert |
| Containers | Per-container resource? | deep-dive (needs the cAdvisor fix) |
Procedures:
- Daily 30-sec glance: VPS Overview green (CPU/mem/disk < thresholds)? Podcast App → API 5xx rate flat?
- After a deploy: Podcast App → watch API 5xx + p95 for a spike → any failure,
open Explore → Traces (service
podcast-api) for the slow/failing span → GlitchTip for the exception + stacktrace. - Weekly: Podcast App →
$surface=pipeline→ LLM cost trend; Edge Security → fail2ban bans / ssh failures. - Incident flow (down/slow): VPS Overview (resource pressure?) → Podcast App
(which
$surface/route?) → Explore Traces (which span?) → Explore Logs (instance:prod-podcast AND job:~"<surface>") → GlitchTip (stacktrace). Therun_idtag joins a cost log, its trace, and its error across all three. - Differentiating the 3 podcast surfaces:
$surfacedropdown on Podcast App (logs); OTELservice.name(podcast-apivspodcast-pipeline) +http.routeon traces; the api route/handler label on metrics.
Deep-dive dashboards (Node Exporter Full, cAdvisor) stay in the Homelab folder — not the daily surface. Keep the set at ~4; add a dashboard only when a recurring question has no home.
Verify (tailnet, backend host homelab)¶
# metrics: api RED + host + cadvisor for prod-podcast
curl -s "http://homelab:8428/api/v1/query?query=up{instance='prod-podcast'}"
# logs: recent podcast api lines
curl -sG "http://homelab:9428/select/logsql/query" \
--data-urlencode 'query={app="podcast",surface="api"}' --data-urlencode limit=10
# traces: services reporting
curl -s "http://homelab:10428/select/jaeger/api/services"
Then Grafana http://homelab:3000 → the Podcast Operator / Podcast Player
folders (dashboards), Explore (logs/metrics), Explore Traces (spans). See
OBSERVABILITY_RUNBOOK for the full debugging flows + gaps.