feat(insights): permanent archive of PostHog (events+recordings+identity) + Sentry (Phase A+B+C) #2

Merged
root merged 4 commits from feat/observability-archive into main 2026-05-27 22:13:31 +03:00
Owner

Context

PostHog Cloud free tier deletes data on a rolling window (events ~1yr, session recordings ~30d). This mirrors both into our own infra permanently — hot Postgres (queryable) + cold MinIO (gzip JSONL). Phase A of a broader observability archive; Sentry & Grafana are deferred (see plan).

Verified live during planning: PostHog Cloud EU project 127747, ~22.7K events all-time, low volume (~500–1300/day). Grafana Cloud is dormant (OTEL disabled, no agent) → deferred. Sentry not yet configured → deferred.

What this adds

PostHog raw event archive

  • PosthogEvent model — uuid PK (natural dedup), full properties JSONB (lossless), hot columns (event/distinctId/sessionId/timestamp) promoted + indexed.
  • posthog-event-archive job (*/15min): watermark-paged HogQL pull. Cursor stored in the existing IngestionWatermark under a "sase:events" stream key (no collision with recording ingest). createMany({ skipDuplicates }) → idempotent. 365-day backfill on first run.
  • Daily closed-day cold dump to MinIO posthog-archive/{project}/YYYY/MM/DD.jsonl.gz; dumpedAt flag makes it idempotent and deterministic.
  • New hogqlQuery helper. Note: cursor uses parseDateTimeBestEffort() — ClickHouse 500s on a raw ISO8601 string literal (confirmed live).

Raw rrweb recording preservation

  • Separate from compress (which only covers scored sessions and caps at 12 blobs). This covers every recording at full fidelity before the 30-day deletion.
  • SessionMeta.rrwebArchivedAt / rrwebArchiveKey.
  • archive-recordings job (*/6h): recordings within 25d (margin), full blob range, gzip → MinIO rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz.

Wiring & safety

  • putBuffer gzip helper in minio.ts; both jobs registered in pipeline.ts.
  • retention.ts untouched → the new table + both buckets are never pruned (the explicit goal).
  • Schema applies via the existing prisma db push on web start (purely additive: new table + nullable columns).

Test plan

  • pnpm --filter worker typecheck
  • HogQL query shape verified read-only against the live project (column order + properties JSON parsing + parseDateTimeBestEffort fix)
  • prisma db push (or web start) creates posthog_events + new SessionMeta columns
  • First runArchivePosthogEvents(): inserted > 0; immediate re-run → all duplicates (idempotency). SELECT count(*) FROM posthog_events ≈ 22.7K after backfill
  • A closed-day posthog-archive/sase/YYYY/MM/DD.jsonl.gz object exists; rows have dumpedAt
  • runArchiveRecordings() writes a rrweb-archive/.../{sessionId}.jsonl.gz; gunzip first line is valid rrweb JSON

Deferred (spec only)

  • Phase B: person/cohort snapshots (web vitals & exceptions arrive for free as event rows).
  • Phase C: Sentry (sentry.io) — needs org/project/token.
  • Phase D: Grafana Cloud — blocked until telemetry flows (enable OTEL / deploy agent).

🤖 Generated with Claude Code

## Context PostHog Cloud **free tier deletes data on a rolling window** (events ~1yr, session recordings ~30d). This mirrors both into our own infra **permanently** — hot Postgres (queryable) + cold MinIO (gzip JSONL). Phase A of a broader observability archive; Sentry & Grafana are deferred (see plan). **Verified live during planning:** PostHog Cloud EU project 127747, ~22.7K events all-time, low volume (~500–1300/day). Grafana Cloud is dormant (OTEL disabled, no agent) → deferred. Sentry not yet configured → deferred. ## What this adds ### PostHog raw event archive - **`PosthogEvent`** model — `uuid` PK (natural dedup), full `properties` JSONB (lossless), hot columns (`event`/`distinctId`/`sessionId`/`timestamp`) promoted + indexed. - **`posthog-event-archive`** job (`*/15min`): watermark-paged HogQL pull. Cursor stored in the existing `IngestionWatermark` under a `"sase:events"` stream key (no collision with recording ingest). `createMany({ skipDuplicates })` → idempotent. 365-day backfill on first run. - Daily **closed-day cold dump** to MinIO `posthog-archive/{project}/YYYY/MM/DD.jsonl.gz`; `dumpedAt` flag makes it idempotent and deterministic. - New `hogqlQuery` helper. **Note:** cursor uses `parseDateTimeBestEffort()` — ClickHouse 500s on a raw ISO8601 string literal (confirmed live). ### Raw rrweb recording preservation - Separate from `compress` (which only covers *scored* sessions and caps at 12 blobs). This covers **every** recording at **full** fidelity before the 30-day deletion. - `SessionMeta.rrwebArchivedAt` / `rrwebArchiveKey`. - **`archive-recordings`** job (`*/6h`): recordings within 25d (margin), full blob range, gzip → MinIO `rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz`. ### Wiring & safety - `putBuffer` gzip helper in `minio.ts`; both jobs registered in `pipeline.ts`. - `retention.ts` untouched → the new table + both buckets are **never** pruned (the explicit goal). - Schema applies via the existing `prisma db push` on web start (purely additive: new table + nullable columns). ## Test plan - [x] `pnpm --filter worker typecheck` - [x] HogQL query shape verified read-only against the live project (column order + `properties` JSON parsing + `parseDateTimeBestEffort` fix) - [ ] `prisma db push` (or web start) creates `posthog_events` + new `SessionMeta` columns - [ ] First `runArchivePosthogEvents()`: `inserted > 0`; immediate re-run → all duplicates (idempotency). `SELECT count(*) FROM posthog_events` ≈ 22.7K after backfill - [ ] A closed-day `posthog-archive/sase/YYYY/MM/DD.jsonl.gz` object exists; rows have `dumpedAt` - [ ] `runArchiveRecordings()` writes a `rrweb-archive/.../{sessionId}.jsonl.gz`; gunzip first line is valid rrweb JSON ## Deferred (spec only) - **Phase B**: person/cohort snapshots (web vitals & exceptions arrive for free as event rows). - **Phase C**: Sentry (sentry.io) — needs org/project/token. - **Phase D**: Grafana Cloud — blocked until telemetry flows (enable OTEL / deploy agent). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
root added 1 commit 2026-05-27 20:15:53 +03:00
Phase A of the external observability archive. PostHog Cloud free tier
deletes data on a rolling window (events ~1yr, session recordings ~30d);
this mirrors both into our own infra permanently (hot Postgres + cold
MinIO gzip JSONL).

PostHog raw event archive:
- New `PosthogEvent` model (uuid PK → dedup, full properties JSONB,
  hot columns promoted + indexed).
- `posthog-event-archive` job: watermark-paged HogQL pull (cursor in the
  existing IngestionWatermark via a "sase:events" stream key), createMany
  + skipDuplicates for idempotency, 365d backfill. Daily closed-day cold
  dump to MinIO `posthog-archive/{project}/YYYY/MM/DD.jsonl.gz`.
- `hogqlQuery` helper added to posthog.ts. Timestamp cursor uses
  parseDateTimeBestEffort() — ClickHouse 500s on a raw ISO8601 literal
  (verified live against the project).

Raw rrweb recording preservation (separate from compress, which only
covers scored sessions and caps blobs):
- `SessionMeta.rrwebArchivedAt` / `rrwebArchiveKey`.
- `archive-recordings` job: every recording within 25d (margin before
  30d deletion), full blob range (no 12-cap), gzip → MinIO
  `rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz`.

- `putBuffer` gzip helper in minio.ts.
- Both jobs wired into pipeline.ts (event-archive@*/15min, recordings@*/6h).
- retention.ts untouched → new table + buckets persist forever (the goal).

Schema applies via the existing `prisma db push` on web start (additive:
new table + nullable columns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
root added 1 commit 2026-05-27 20:40:45 +03:00
Live verification surfaced two issues in archive-recordings:
- blob_v2 rejects wide blob-key ranges (a 23-key span 400s; ~5 is fine).
  Fetch in contiguous chunks of BLOB_CHUNK (default 10) and concatenate.
- PostHog's snapshot API is aggressively rate-limited; back-to-back
  sessions tripped 429. Pace requests (per-session + per-chunk delays)
  and stop the run on sustained 429 — unarchived rows keep
  rrwebArchivedAt=null and retry next cycle (oldest-first ordering
  protects soon-to-be-deleted recordings first). Result now carries
  rateLimited.

Verified against prod: 87- and 23-blob recordings reconstruct fully
(no 400), failed=0, graceful 429 stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
root added 1 commit 2026-05-27 20:47:22 +03:00
Append-only history of person properties and cohort definitions, since
the free tier overwrites the live person / prunes data. A new row is
written only when the payload changes (hash compare), so the tables stay
a compact change-timeline rather than a daily full copy.

- `PosthogPersonSnapshot` (personId join key to PosthogEvent.personId,
  all distinct_ids, properties JSONB, propertiesHash).
- `PosthogCohortSnapshot` (cohortId, name, count, filters, stateHash).
- `listPersons` / `listCohorts` REST helpers (next-pagination) in posthog.ts.
- `stableHash` (sorted-key JSON hash) in hash.ts for change detection.
- `posthog-identity-archive` job wired @03:00 daily. Latest-hash lookup
  via one DISTINCT ON query, inserts only changed rows via createMany.

Verified against prod: 242 persons → 242 snapshots on first run, 0 on
immediate re-run (change detection). 0 cohorts currently → graceful no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
root changed title from feat(insights): permanent archive of PostHog events + raw recordings (Phase A) to feat(insights): permanent archive of PostHog events + recordings + person/cohort snapshots (Phase A+B) 2026-05-27 20:47:36 +03:00
Author
Owner

Phase B added — person + cohort snapshots

Append-only history of PostHog persons & cohorts (free tier overwrites the live person / prunes data). Change-detected: a new row is written only when the payload hash changes, so the tables are a compact timeline, not a daily full copy.

  • PosthogPersonSnapshotpersonId (joins PosthogEvent.personId), all distinctIds, properties JSONB, propertiesHash.
  • PosthogCohortSnapshotcohortId, name, count, filters, stateHash.
  • listPersons / listCohorts REST helpers (next-pagination); stableHash (sorted-key JSON) for change detection.
  • posthog-identity-archive job @03:00 daily. Latest-hash lookup via one DISTINCT ON query; only changed rows inserted.

Verified against prod: 242 persons → 242 snapshots on first run, 0 on immediate re-run (change detection works). 0 cohorts currently → graceful no-op (future-proofed for when cohorts are created).

Note: web vitals & exceptions need no separate work — they already land as event rows via Phase A. Remaining deferred: Phase C (Sentry), Phase D (Grafana, blocked on telemetry).

## Phase B added — person + cohort snapshots Append-only history of PostHog persons & cohorts (free tier overwrites the live person / prunes data). Change-detected: a new row is written only when the payload hash changes, so the tables are a compact timeline, not a daily full copy. - **`PosthogPersonSnapshot`** — `personId` (joins `PosthogEvent.personId`), all `distinctIds`, `properties` JSONB, `propertiesHash`. - **`PosthogCohortSnapshot`** — `cohortId`, `name`, `count`, `filters`, `stateHash`. - `listPersons` / `listCohorts` REST helpers (next-pagination); `stableHash` (sorted-key JSON) for change detection. - **`posthog-identity-archive`** job @03:00 daily. Latest-hash lookup via one `DISTINCT ON` query; only changed rows inserted. **Verified against prod:** 242 persons → 242 snapshots on first run, **0 on immediate re-run** (change detection works). 0 cohorts currently → graceful no-op (future-proofed for when cohorts are created). Note: web vitals & exceptions need no separate work — they already land as event rows via Phase A. Remaining deferred: **Phase C (Sentry)**, **Phase D (Grafana, blocked on telemetry)**.
root added 1 commit 2026-05-27 21:32:52 +03:00
Mirrors Sentry into our DB before the free tier prunes events (~30d).

- `SentryIssue` (aggregate state, upserted to latest) + `SentryEvent`
  (raw occurrences, lossless full payload JSONB, dedup by eventId, cold
  dump to MinIO `sentry-archive/{project}/YYYY/MM/DD.jsonl.gz`).
- `lib/sentry.ts`: read-only client, Link-header cursor pagination,
  listIssuesPage / listEventsPage. EU-region aware (SENTRY_API_BASE).
- `sentry-archive` job @hourly: issues upsert + events newest-first with
  skipDuplicates, stops once a page is all-duplicates (caught up).
- Config via env: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT /
  SENTRY_API_BASE.

Verified live against otolog/python (EU): a test event archived on run 1,
0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently.

Note: Sase API currently sends to an inaccessible org's DSN (hardcoded
fallback); repointing SENTRY_DSN to otolog/python is a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
root changed title from feat(insights): permanent archive of PostHog events + recordings + person/cohort snapshots (Phase A+B) to feat(insights): permanent archive of PostHog (events+recordings+identity) + Sentry (Phase A+B+C) 2026-05-27 21:42:01 +03:00
Author
Owner

Phase C added — Sentry issues + events archive

Mirrors Sentry into our DB before the free tier prunes events (~30d).

  • SentryIssue (aggregate state, upserted to latest) + SentryEvent (raw occurrences, lossless full payload JSONB, dedup by eventId, cold-dumped to MinIO sentry-archive/{project}/YYYY/MM/DD.jsonl.gz).
  • lib/sentry.ts read-only client with Link-header cursor pagination; EU-region aware (SENTRY_API_BASE).
  • sentry-archive job @hourly: issues upsert + events newest-first with skipDuplicates, stops once a page is all-duplicates.
  • Config via env: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT / SENTRY_API_BASE.

Verified live against otolog/python (EU): test event archived on run 1, 0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently.

Discovery + repoint (operational)

The Sase API's hardcoded fallback DSN pointed to an inaccessible org (4511360959250432), while the account's token only reaches org otolog (which was empty). Per decision, repointed Sase to otolog/python:

  • Set SENTRY_DSN (otolog/python) on the Sase Coolify app + restarted it → real errors now flow to an org we can read.
  • Set SENTRY_AUTH_TOKEN/ORG/PROJECT/API_BASE on panel-worker so the hourly job archives once this PR deploys.

Grafana (Phase D) still deferred — telemetry not flowing (OTEL disabled, no agent).

## Phase C added — Sentry issues + events archive Mirrors Sentry into our DB before the free tier prunes events (~30d). - **`SentryIssue`** (aggregate state, upserted to latest) + **`SentryEvent`** (raw occurrences, lossless full payload JSONB, dedup by eventId, cold-dumped to MinIO `sentry-archive/{project}/YYYY/MM/DD.jsonl.gz`). - **`lib/sentry.ts`** read-only client with Link-header cursor pagination; EU-region aware (`SENTRY_API_BASE`). - **`sentry-archive`** job @hourly: issues upsert + events newest-first with `skipDuplicates`, stops once a page is all-duplicates. - Config via env: `SENTRY_AUTH_TOKEN` / `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_API_BASE`. **Verified live against `otolog/python` (EU):** test event archived on run 1, 0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently. ### Discovery + repoint (operational) The Sase API's hardcoded fallback DSN pointed to an **inaccessible org** (`4511360959250432`), while the account's token only reaches org `otolog` (which was empty). Per decision, **repointed Sase** to `otolog/python`: - Set `SENTRY_DSN` (otolog/python) on the Sase Coolify app + restarted it → real errors now flow to an org we can read. - Set `SENTRY_AUTH_TOKEN`/`ORG`/`PROJECT`/`API_BASE` on **panel-worker** so the hourly job archives once this PR deploys. Grafana (Phase D) still deferred — telemetry not flowing (OTEL disabled, no agent).
root merged commit 7eca679ab8 into main 2026-05-27 22:13:31 +03:00
root deleted branch feat/observability-archive 2026-05-27 22:13:32 +03:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: root/sp#2