Prod validation & observability — quick reference¶
When to read this: you (or an agent) just did something to prod — deployed, swapped the corpus, restarted a stack — and need to answer "is it actually good?" in 2 minutes, not 15. Almost none of this needs prod SSH. Full detail: Prod Runbook, Observability Runbook.
Surfaces (how to reach prod)¶
| Surface | Reach | Gate |
|---|---|---|
| Operator/compose API | https://prod-podcast.<TAILNET>.ts.net (tailnet) |
ungated — use this for all corpus checks |
| Public player | https://closelistening.app |
/preview cookie gate |
| Player MCP | https://mcp.closelistening.app |
OAuth (tool calls need a token) |
| Homelab (obs) | homelab = <HOMELAB_IP> (tailnet) |
services below are tailnet-open |
| Prod box (only for mutations) | ssh -i ~/.ssh/podcast_prod_operator -o IdentitiesOnly=yes deploy@prod-podcast.<TAILNET>.ts.net |
key is transient — operator re-adds on request. make is NOT on the box; use docker exec compose-api-1 python -m podcast_scraper.cli … |
In-container corpus path is /app/output (pass path=/app/output to API probes).
"I changed the corpus — is prod serving the RIGHT one?" (no SSH)¶
B=https://prod-podcast.<TAILNET>.ts.net; Q=path=/app/output
# 1. Identity — is the INTENDED corpus served? (not just "healthy")
curl -s "$B/api/health" | jq '{code:.code_version, produced:.corpus_produced_by.produced_at, corpus_ver:.corpus_code_version, warn:.corpus_version_warning}'
# 2. Episode count
curl -s "$B/api/corpus/episodes?$Q&limit=500" | jq '.items|length'
# 3. Index freshness + completeness — AUTHORITATIVE, no reindex-guessing
curl -s "$B/api/index/stats?$Q" | jq '{reindex:.reindex_recommended, reasons:.reindex_reasons, last_updated:.stats.last_updated, artifact_newest:.artifact_newest_mtime, counts:.stats.doc_type_counts}'
# reindex_recommended:false + counts sane => index is fine. It compares index
# last_updated vs newest gi.json/kg/transcript mtime (index_staleness.py).
# REINDEX (remote, no SSH) — builds the two-tier index; poll stats for rebuild_in_progress:
# curl -X POST "$B/api/index/rebuild?$Q&rebuild=true" # 202; 409 if already running
# 4. Topic clusters present (smoke fails without this) + search works
curl -s "$B/api/corpus/topic-clusters?$Q" | jq '.clusters|length'
curl -s "$B/api/search?q=markets&$Q&limit=3" | jq '.results|length'
# 5. Or just run the 6-surface smoke (what deploy-prod runs):
bash scripts/ops/post_deploy_smoke.sh prod-podcast.<TAILNET>.ts.net --corpus-path /app/output
Observability checkup (no SSH except GlitchTip/Umami DB)¶
Signals all land on homelab; query over tailnet. Log labels: app=podcast|player,
surface=api|mcp|web|scheduler. Note: API access logs now carry trace_id (shipped in
PR 1527) and player-api IS traced (older docs say otherwise).
# Metrics — api up?
curl -s "http://homelab:8428/api/v1/query?query=up{instance='prod-podcast'}" | jq '.data.result[].value[1]'
# Traces — services reporting
curl -s http://homelab:10428/select/jaeger/api/services | jq .data
# Logs — errors across a surface, deploy window
curl -sG http://homelab:9428/select/logsql/query \
--data-urlencode 'query={app="player"} _time:3h (error OR Exception OR Traceback OR " 500 ")' --data-urlencode limit=20
GlitchTip (errors) + Umami (UX) need their DB (docker is at /usr/local/bin/docker, NOT on
the non-interactive PATH; GlitchTip pg user = glitchtip, projects 1=podcast/operator,
5=player; use make_interval() to dodge SSH quote-mangling):
D=/usr/local/bin/docker
# New errors since deploy in podcast+player (empty / 0 rows = clean)
ssh -i ~/.ssh/homelab_mini homelab "$D exec glitchtip-postgres-1 psql -U glitchtip -d glitchtip -tAc \
'select project_id,count(*),max(first_seen) from issue_events_issue where first_seen > now() - make_interval(hours=>14) group by 1 order by 1'"
# Umami — player UX traffic last 24h
ssh -i ~/.ssh/homelab_mini homelab "$D exec umami-db psql -U umami -d umami -tAc \
'select count(*) from website_event where created_at > now() - make_interval(hours=>24)'"
Grafana UI: http://homelab:3000 (folders Podcast Operator, Podcast Player).
Gotchas that cost time (one-liners)¶
- Corpus = a bind-backed docker volume
compose_corpus_data→/srv/podcast-scraper/corpus, shared by compose+operator+player. Nevermvthe dir to swap it (the mount follows the inode → serves the.bak); replace contents in place. Re-resolving the bind needs ALL consumers down +docker volume rm→ redeploy. See Corpus snapshot & restore. - Secrets are VIA_FILES in host
/dev/shm(empty between deploys; only the deploy workflows restage from GH Secrets). Never hand-runcompose upwith the secrets overlay. - Deploy scripts use
up -d(no--force-recreate) → a same-image redeploy is a no-op. A corpus cutover needs an image change (or the volume-rm dance). topic_clusters.jsonis a separate step nothing runs automatically —python -m podcast_scraper.cli topic-clusters --output-dir <corpus> --threshold 0.75(0.75 = profile value; the viewer-validation Make target's 0.35 is a fixture override).deploy-prodhas aprodGitHub environment gate; approve viagh api repos/chipi/podcast_scraper/actions/runs/<id>/pending_deployments -f state=approved -F environment_ids[]=<id>(operator-authorized only).docker manifest inspectbefore deploying a sha —stack-test.ymlpublishes images;docker.ymlispush:false.deploy-prodpreflight validates api+viewer+pipeline-llm.
When you actually need prod SSH¶
Most mutations are already remote APIs — reindex is POST /api/index/rebuild (above),
no SSH. The genuine box-only exceptions today:
- Generating
topic_clusters.json— CLI-only, NO API/button (gap; the fix is to have/api/index/rebuildregenerate it). Until then:docker exec compose-api-1 python -m podcast_scraper.cli topic-clusters --output-dir /app/output --threshold 0.75. - Invoking the gated player MCP tools (needs the MCP OAuth token instead, ideally).
- Raw container ops / inspecting the on-disk corpus dir.
All validation above is doable without SSH.