Pulls errors and user/request flows into one canonical pair of master files, from whichever combination of sources a given job is configured to use:
openreplay_fe— session/error data directly from a self-hosted OpenReplay instance's Postgres, ClickHouse, and MinIO (frontend errors + user journeys).cloudrun_be— Cloud Run logs for a backend service via Google Cloud Logging (backend errors + request flows).sentry_fe/sentry_be— errors, Session Replay, and performance-trace data from the Sentry API, for an app instrumented with Sentry instead of OpenReplay/Cloud Run.
A "job" is just a Settings object (which sources to pull, connection
details, where to write output) built either from the default
.env.pipeline (today's original app: openreplay_fe + cloudrun_be,
zero config changes needed) or from a job spec JSON file passed via
--job (any other app/combination — see "Jobs" below). Each source
de-duplicates into the same pair of master files for that job
(error_master.json, test_journey_master.json), writes a Run Log per
execution, and generates human-readable write-ups in
ERROR_MASTER.md/TEST_JOURNEYS.md. Error Master File entries get fed
back into Claude Code to fix the underlying bugs via
python pipeline/fix_tasks.py — a separate, on-demand script (see
"Generating fix tasks" below), not part of the automatic data-pull loop
above.
Direct DB access is used for openreplay_fe instead of OpenReplay's REST
API because the public API's Sessions endpoint is scoped per-user with no
"all sessions since timestamp" endpoint, which doesn't fit the "all users"
requirement here.
events.pages/clicks/inputs/state_actions exist as Postgres tables on
this instance but are never written to — clicks/page-navigations/input
changes are only ever stored as OpenReplay's proprietary binary "mob"
recording files in MinIO (bucket mobs, one dom.mobs/dom.mobe pair per
session). Confirmed by reading the chalice API's source directly on the
instance: it has no server-side decoding endpoint anywhere, even via
OpenReplay's own REST API — the dashboard only ever hands the browser a
presigned URL to these same raw files, and decodes them client-side in its
JS player.
pipeline/mob_decoder.py is an independent implementation of that wire
format, based on the publicly documented schema at
openreplay/openreplay/mobs/messages.rb — that mobs/ directory carries
its own MIT license, distinct from and NOT the same as the
Enterprise-licensed generated decoder under ee/connectors/msgcodec/ in the
same repo (which requires a commercial agreement with OpenReplay and is
deliberately not used or copied here). See the module docstring for the
reverse-engineered framing details (zstd-compressed, varint/zigzag-encoded
messages, BatchMetadata messages re-establishing framing mode mid-stream).
The test-revizr backend runs on Google Cloud Run, in the same GCP
project (jino-poc) as the OpenReplay VM — not a plain Docker host. Cloud
Run automatically ships two log streams to Google Cloud Logging: structured
per-request logs (method/URL/status/latency) and the app's own
stdout/stderr text logs. pipeline/gcp_logging_client.py pulls both via the
google-cloud-logging Python client, authenticated with your local
gcloud Application Default Credentials — no SSH tunnel needed for this
part.
The backend has no app-level session/correlation ID (checked live response
headers and app logs — the only ID present anywhere is GCP's own
per-request Cloud Trace ID, which doesn't group requests together), so
pipeline/backend_journeys.py groups requests into a "journey" by client
IP + an idle-time gap (BACKEND_JOURNEY_IDLE_GAP_MINUTES, default 30 min)
— an explicit approximation, not a real session boundary.
For an app instrumented with Sentry instead of OpenReplay/Cloud Run.
pipeline/sentry_client.py calls the Sentry Web API directly (no SDK, no
tunnel) using a single auth token; pipeline/sentry_errors.py and
pipeline/sentry_journeys.py normalize the results into the same
NormalizedError/JourneyStep shapes the other two sources produce, so
master_files.py/docs.py/fix_tasks.py need no source-specific code at
all.
A few things that aren't obvious from the code:
- Sentry SaaS is regionalized per org (
sentry.io,us.sentry.io,de.sentry.io, ...). The org's dashboard vanity URL (https://<org-slug>.sentry.io/) does not tell you the region — the API host does matter and calling the wrong one just fails. The correct region is embedded in the auth token itself: decode the base64 segment between thesntrys_/sntryu_prefix and the trailing signature to find aregion_urlfield, and set it assentry.region_urlin the job spec (defaults tohttps://sentry.ioif omitted). - Auth token type matters. Sentry's dashboard offers three token kinds
under Developer Settings — Org, Personal, OAuth. Org tokens are
hardcoded to the
org:ciscope (source-map/release upload only) and will 403 on every read call this pipeline makes. Use a Personal Token withproject:read/event:read/org:readscopes instead — it acts with the creating user's own project visibility, which is fine as long as that user can see the target FE/BE projects. - FE journeys come from two different signals, not one:
- A replay's
urlsfield (fromGET /organizations/{org}/replays/) — page-navigation only, no clicks/inputs. Full rrweb recording decode (which would give the same click-level detailpipeline/mob_decoder.pyextracts from OpenReplay) is deliberately out of scope — it's a comparable-sized reverse-engineering effort and Sentry's replay recording format isn't documented as a supported API. - Each error event's breadcrumb trail (already present in the same
payload
fetch_eventspulls) — Sentry records recentnavigation/ui.click/ui.inputbreadcrumbs leading up to an error, giving click-level detail, but only for the lead-up to whichever errors were actually captured, not arbitrary complete sessions.
- A replay's
- BE journeys are inherently API-request sequences (method + path),
the same nature as the existing Cloud Run journeys — there's no
page/DOM concept server-side for either source. Sentry's
trace_idis a real distributed-tracing correlation ID though (unlike Cloud Run's IP+idle-gap approximation), so transactions are grouped by trace instead. - If a project's FE journeys or BE journeys come back empty, it usually
means Session Replay / Performance tracing isn't actually wired into
that app's Sentry SDK init code yet — neither is a dashboard toggle,
both are enabled from the app's own code (
Sentry.replayIntegration()replaysSessionSampleRate/replaysOnErrorSampleRatefor Replay; tracing sample rate config for Performance). Check the dashboard's Replays tab (not Settings) to see if anything is actually being captured for that project before assuming the pipeline is missing something.
Everything above assumes one hardcoded app. In practice you may have
several — e.g. one app on OpenReplay + Cloud Run, a different app on
Sentry — each needing its own connection details and its own isolated
output. pipeline/run.py and pipeline/fix_tasks.py both accept:
- no flags — today's original behavior, unchanged: reads
.env.pipeline, writes todata/and the repo root. This is what./scripts/run_pipeline.shruns by default. --config <path>— same dotenv format as.env.pipeline, just a different file (e.g. a second OpenReplay app with different credentials).--job <path.json>— a self-contained JSON job spec instead of a dotenv file. This is the natural fit for Sentry (or any second app), since a job spec bundles connection details, which sources to run, and where to write output all in one file. Seejobs/example.sentry.jsonandjobs/example.openreplay.jsonfor templates — copy one, fill in real values, and it's gitignored automatically (only theexample.*.jsontemplates are tracked).
Two fields in a job spec (or their dotenv equivalents) control isolation and behavior per job:
pipeline_sources— which fetchers run: any combination ofopenreplay_fe,cloudrun_be,sentry_fe,sentry_be. Determines which credentials are required, and whether the SSH tunnel preflight check runs at all (skipped entirely unlessopenreplay_feis present).data_dir/docs_dir— wherecheckpoint.json/error_master.json/test_journey_master.json/runs/(data_dir) andERROR_MASTER.md/TEST_JOURNEYS.md/FIX_TASKS.md(docs_dir) get written. Each job should point these at its own directory (e.g.data/<app-name>) so two jobs never collide on the same files. The default job (no flags) keeps writing todata/and the repo root, same as before this multi-job support existed.
fix_tasks.py has no source-awareness of its own — it just reads
whatever error_master.json lives at settings.data_dir, resolved the
same --job/--config/default way. Always pass the same flag you used
for the matching run.py call, or it'll silently operate on the wrong
job's data instead of erroring.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Credentials live in .env.pipeline (gitignored). Copy
.env.pipeline.example and fill in the real values if setting this up
fresh.
cloudrun_be additionally needs working gcloud credentials with read
access to Cloud Logging in the target GCP project:
gcloud auth login
gcloud auth application-default login
- Copy
jobs/example.sentry.jsonto a new file (e.g.jobs/<app-name>.json— anything underjobs/other than theexample.*.jsontemplates is gitignored automatically). - From the Sentry dashboard, collect:
- Org slug — the segment in your org's dashboard URL.
- Auth token — Developer Settings → Personal Tokens → create
one with scopes
project:read,event:read,org:read. Do not use an "Org" token (see the Sentry section above — it's locked to a scope this pipeline can't use and will 403). region_url— decode the base64 segment of the token you just created (betweensntrys_/sntryu_and the trailing signature) to find it, or check Settings → General Settings for a data-region field. Only needed if it isn't the defaulthttps://sentry.io.- FE/BE project slugs — from each project's URL in the dashboard.
- Fill those into the job spec's
sentryblock, and setdata_dir/docs_dirto wherever you want this app's output isolated (e.g.data/<app-name>). - No
gcloud/SSH tunnel setup needed for a Sentry-only job.
Default job (today's original OpenReplay + Cloud Run app) — a single
run pulls both sources. Postgres, ClickHouse, and MinIO only run inside
the OpenReplay k3s cluster (ClusterIP services) on the GCE VM behind
replay.cosmaya.co.in — none are reachable directly, so a run needs an
SSH tunnel up first; Cloud Logging just needs gcloud auth (see Setup
above), no tunnel. Not run on a schedule yet — trigger it by
hand when you want fresh data:
./scripts/run_pipeline.sh
This clears any stale remote kubectl port-forward from a previous
interrupted run, opens a fresh SSH tunnel (Postgres 5432, ClickHouse
8123/9000, MinIO 9010), waits for it to become reachable, runs
python pipeline/run.py, then always tears the tunnel down again (both
locally and on the remote VM) whether the run succeeded or failed. Safe to
run repeatedly on demand. pipeline/tunnel.py's preflight check
(Postgres/ClickHouse/MinIO reachability) runs whenever openreplay_fe is
one of the job's pipeline_sources — which it always is for the default
job, so a Cloud-Run-only debugging run of the default job still needs the
tunnel up. A job whose pipeline_sources omits openreplay_fe entirely
(e.g. a cloudrun_be-only or Sentry job via --job/--config) skips the
tunnel check completely instead.
If you'd rather manage the tunnel yourself (e.g. to run the pipeline
manually a few times in a row, or to poke at the DBs directly), use
./scripts/open_tunnels.sh instead — it opens the same tunnel but leaves it
running in the foreground until you Ctrl+C it, and then run
python pipeline/run.py yourself in another terminal.
pipeline/tunnel.py checks reachability first either way and fails fast
with a remediation message if the tunnel isn't up.
Each run:
- Phase 1: pulls sessions/errors from Postgres and ClickHouse since the
last successful checkpoint (
data/checkpoint.json). For each session in that batch, fetches itsdom.mobs/dom.mobefiles from MinIO and decodes them into ordered click/page/input steps (see above) — this is the only source of journey data on this instance. - Phase 2: pulls Cloud Run request logs + app logs for
test-revizrsince the last checkpoint, keeps request errors (status >= 500) and ERROR/CRITICAL-severity app log lines, and groups requests into IP+idle-gap journeys (see above). - Normalizes and de-duplicates errors from both phases against
data/error_master.json, and journeys from both phases againstdata/test_journey_master.json— one canonical pair of master files regardless of source. Every error message string is passed throughpipeline/redact.pyfirst, which strips PEM key blocks and JSON blobs containing aprivate_keyfield (added after a real Cloud Run traceback captured a live service-account key verbatim — see the module docstring). - Writes
data/runs/run_<timestamp>.jsonrecording what was pulled and which entries were new vs. already known. - Writes up any not-yet-documented error/journey entries as prose in
ERROR_MASTER.md/TEST_JOURNEYS.md(via aclaude -pcall — see below), and a per-run findings audit. - Only on success: saves both master files and advances the checkpoint. A failed run leaves the checkpoint and master files untouched, so it's safe to simply re-run.
Sentry job (or any other job spec / second dotenv config) — no tunnel
to manage, so call pipeline/run.py directly instead of going through
run_pipeline.sh:
source .venv/bin/activate
python pipeline/run.py --job jobs/<app-name>.json
(./scripts/run_pipeline.sh --job jobs/<app-name>.json also works — it
detects the job doesn't use openreplay_fe and skips the SSH tunnel
entirely — but is unnecessary overhead for a Sentry-only job.) Everything
else behaves identically to the default job: incremental pulls via that
job's own checkpoint.json, de-dup against that job's own master files,
descriptive docs generated the same way, safe to re-run on failure. First
run pulls the job spec's first_run_lookback_hours (default 168h/7 days)
of history; every run after that only pulls what's new.
The JSON master files are the deterministic source of truth for
de-duplication; pipeline/docs.py adds a human-readable narrative layer on
top, mirroring the DEFECTS.md/TEST_CONDITIONS.md style from the sibling
observability_poc project. For any record not yet marked documented in
the JSON (brand new this run, or left over from a run where the doc step
failed), it calls claude -p once with the structured entries and asks for
a prose write-up per entry, then appends those sections to ERROR_MASTER.md
(## E-N: ...) / TEST_JOURNEYS.md (### TC-N — ...) — sections are never
rewritten once written. A record is only marked documented: true after its
section is successfully appended, so a claude failure just retries next
run rather than losing the write-up. If nothing is pending, the claude
call is skipped entirely (no cost, no findings audit written).
python pipeline/fix_tasks.py # default job
python pipeline/fix_tasks.py --job jobs/<app-name>.json # any other job
A separate, on-demand script — not run automatically by run.py/
run_pipeline.sh, and needs no SSH tunnel or gcloud auth, just that
job's error_master.json and ERROR_MASTER.md. It has no source-awareness
of its own — always pass the same --job/--config you used for the
matching run.py call, or it'll silently read the wrong job's data
instead of erroring (see "Jobs" above). For every error not yet
marked fix_task_generated in the JSON, it calls claude -p once (one
call per error, not batched, so one bad response only costs that error and
each prompt can include a full traceback without truncation risk) and
writes a ready-to-run task prompt into FIX_TASKS.md
(## Fix Task — E-N: ..., with a fenced ```text block meant to be
copy-pasted as claude -p "<task>" inside the source application's own
repo — the frontend or test-revizr backend, not this observability
repo). Tasks are written to be honest about thin data: if there's no
stack trace, the task says the source location is unknown and describes
what to investigate instead of guessing. fix_task_generated is
independent of documented (one tracks ERROR_MASTER.md narrative,
the other tracks FIX_TASKS.md), and behaves the same way: only set
after its section is successfully written, so a claude failure just
retries that one error next run.
Paths below are for the default job (data_dir/docs_dir = data/ /
repo root). Any other job's equivalents live under its own configured
data_dir/docs_dir instead (e.g. data/<app-name>/error_master.json).
data/error_master.json— canonical, deduplicated error registry across all runs (tracked in git).data/test_journey_master.json— canonical, deduplicated user-journey registry, meant as a growing test-case repository (tracked in git).ERROR_MASTER.md/TEST_JOURNEYS.md— human-readable narrative view generated from the above (tracked in git).FIX_TASKS.md— ready-to-runclaude -ptask prompts per error, generated on demand bypipeline/fix_tasks.py(tracked in git).data/runs/*.json— one run log per execution (gitignored).data/runs/*_findings.md— per-run findings audit, written only when something new was documented (gitignored).data/checkpoint.json— last-processed cursor per data source (gitignored, local mutable state).jobs/*.json— job spec files (gitignored, may contain real credentials); only thejobs/example.*.jsontemplates are tracked.
pipeline/redact.py strips PEM private-key blocks and JSON blobs
containing a private_key field out of every error message before it's
stored or sent to claude -p. This exists because a real Cloud Run
traceback once captured a full plaintext GCP service-account key (the app
tried to load credentials from a value that resolved to a raw JSON string
instead of a file path) — it reached data/error_master.json and a
claude -p prompt before being caught and redacted by hand. It's applied
at two points: where each source normalizes into NormalizedError
(errors.py, backend_errors.py), and again in docs.py right before
anything is serialized into an LLM prompt. It's a blunt, best-effort regex
filter, not a substitute for fixing an app that logs credentials in the
first place.