feat(archive): capture all PostHog resource modules to DB + MinIO

Extends archival beyond events/persons/cohorts. New generic resource
archiver (posthog-resource-archive@*/6h) snapshots surveys, feature flags,
experiments, dashboards, insights, annotations, and actions into
posthog_resource_snapshots — change-detected (hash over non-volatile config),
lossless `data` payload — and dumps a full per-type daily copy to MinIO
(resources/<type>/<date>.json.gz). listResource() pages any PostHog REST
resource. Survey responses + flag calls are already captured as events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 20:31:19 +03:00
parent a9e0d93aa1
commit dd54f4983b
4 changed files with 156 additions and 1 deletions

View File

@@ -538,6 +538,29 @@ model PosthogCohortSnapshot {
@@map("posthog_cohort_snapshots") @@map("posthog_cohort_snapshots")
} }
// Generic change-history of every PostHog "resource" module beyond events:
// surveys, feature_flags, experiments, dashboards, insights, annotations,
// actions. One row is appended only when a resource's config changed
// (stateHash compare); the full payload is kept lossless in `data`, and a full
// per-type daily snapshot is also dumped to MinIO. Survey responses + flag
// calls are events (already in posthog_events) — this captures the definitions.
model PosthogResourceSnapshot {
id String @id @default(cuid())
projectKey String
resourceType String // survey | feature_flag | experiment | dashboard | insight | annotation | action
resourceId String // PostHog resource id (stringified)
name String?
stateHash String // change-detection over the non-volatile config
data Json // full resource payload, lossless
capturedAt DateTime @default(now())
dumpedAt DateTime?
@@index([projectKey, resourceType, capturedAt])
@@index([resourceType, resourceId, capturedAt])
@@index([dumpedAt])
@@map("posthog_resource_snapshots")
}
// ---------- Phase C: Sentry archive ---------- // ---------- Phase C: Sentry archive ----------
// Sentry issue (group) aggregate state. Upserted to the latest snapshot — the // Sentry issue (group) aggregate state. Upserted to the latest snapshot — the

View File

@@ -0,0 +1,91 @@
import { gzipSync } from "node:zlib";
import { prisma } from "../db";
import { listResource, isConfigured } from "../lib/posthog";
import { stableHash } from "../lib/hash";
import { putBuffer } from "../lib/minio";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
const ARCHIVE_BUCKET = process.env.INSIGHT_ARCHIVE_BUCKET ?? "posthog-archive";
// Top-level fields PostHog mutates on every fetch (server timestamps / cached
// results) — excluded from the change-detection hash so we only append a
// snapshot on a real config change. The full payload is still stored losslessly.
const VOLATILE = new Set([
"updated_at", "last_modified_at", "last_refresh", "last_used_at", "last_modified_by",
"is_cached", "result", "results", "next_allowed_client_refresh", "last_viewed_at",
"filters_hash", "effective_restriction_level", "effective_privilege_level", "_create_in_folder",
]);
// Every PostHog resource module beyond the event stream. Survey responses and
// flag-call events are already captured as events (posthog_events); this archives
// the definitions/config + their daily full state.
const RESOURCES: Array<{ type: string; path: string; nameField?: string }> = [
{ type: "survey", path: "surveys", nameField: "name" },
{ type: "feature_flag", path: "feature_flags", nameField: "key" },
{ type: "experiment", path: "experiments", nameField: "name" },
{ type: "dashboard", path: "dashboards", nameField: "name" },
{ type: "annotation", path: "annotations", nameField: "content" },
{ type: "insight", path: "insights", nameField: "name" },
{ type: "action", path: "actions", nameField: "name" },
];
function hashConfig(item: Record<string, unknown>): string {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(item)) if (!VOLATILE.has(k)) clean[k] = v;
return stableHash(clean);
}
export type ResourceArchiveResult = {
ok: boolean;
perType: Record<string, { scanned: number; snapshots: number; dumped: boolean; error?: string }>;
reason?: string;
};
export async function runArchivePosthogResources(): Promise<ResourceArchiveResult> {
if (!isConfigured()) return { ok: false, perType: {}, reason: "posthog_not_configured" };
const perType: ResourceArchiveResult["perType"] = {};
const day = new Date().toISOString().slice(0, 10);
for (const r of RESOURCES) {
const stat = { scanned: 0, snapshots: 0, dumped: false } as ResourceArchiveResult["perType"][string];
perType[r.type] = stat;
try {
const items = await listResource(r.path);
stat.scanned = items.length;
// Latest stored hash per resourceId (one query via DISTINCT ON).
const latest = await prisma.$queryRaw<Array<{ resourceId: string; stateHash: string }>>`
SELECT DISTINCT ON ("resourceId") "resourceId", "stateHash"
FROM posthog_resource_snapshots
WHERE "projectKey" = ${PROJECT_KEY} AND "resourceType" = ${r.type}
ORDER BY "resourceId", "capturedAt" DESC
`;
const latestHash = new Map(latest.map((x) => [x.resourceId, x.stateHash]));
const rows = items
.map((it) => ({
projectKey: PROJECT_KEY,
resourceType: r.type,
resourceId: String(it.id ?? it.short_id ?? ""),
name: r.nameField ? String(it[r.nameField] ?? "").slice(0, 300) : null,
stateHash: hashConfig(it),
data: it as object,
}))
.filter((row) => row.resourceId && latestHash.get(row.resourceId) !== row.stateHash);
if (rows.length) {
const res = await prisma.posthogResourceSnapshot.createMany({ data: rows as never });
stat.snapshots = res.count;
}
// Full lossless dump to MinIO — one object per type per day (idempotent).
const gz = gzipSync(Buffer.from(JSON.stringify(items)));
await putBuffer(ARCHIVE_BUCKET, `resources/${r.type}/${day}.json.gz`, gz, "application/gzip");
stat.dumped = true;
} catch (e) {
stat.error = (e as Error).message;
}
}
return { ok: true, perType };
}

View File

@@ -117,6 +117,30 @@ export async function hogqlQuery(query: string): Promise<HogQLResponse> {
return { columns: data.columns ?? [], results: data.results ?? [] }; return { columns: data.columns ?? [], results: data.results ?? [] };
} }
// Generic paginated reader for PostHog REST resources (surveys, feature_flags,
// experiments, dashboards, insights, annotations, actions). Follows `next`.
export async function listResource(
path: string,
opts: { maxPages?: number; pageSize?: number } = {},
): Promise<Array<Record<string, unknown>>> {
const maxPages = opts.maxPages ?? 50;
const pageSize = opts.pageSize ?? 100;
const out: Array<Record<string, unknown>> = [];
let url: string | null = `${HOST}/api/projects/${PROJECT_ID}/${path}/?limit=${pageSize}`;
let pages = 0;
while (url && pages < maxPages) {
const res: Response = await fetch(url, { headers: headers() });
if (!res.ok) {
throw new Error(`posthog ${path} ${res.status}: ${(await res.text().catch(() => "")).slice(0, 160)}`);
}
const data = (await res.json()) as { results?: Array<Record<string, unknown>>; next?: string | null };
if (Array.isArray(data.results)) out.push(...data.results);
url = data.next ?? null;
pages++;
}
return out;
}
// ---------- Custom events ---------- // ---------- Custom events ----------
export type PHCustomEvent = { export type PHCustomEvent = {

View File

@@ -15,6 +15,7 @@ import { runSaseEveningBrief } from "../jobs/sase-evening-brief";
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive"; import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
import { runArchiveRecordings } from "../jobs/archive-recordings"; import { runArchiveRecordings } from "../jobs/archive-recordings";
import { runArchiveIdentity } from "../jobs/posthog-identity-archive"; import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
import { runArchivePosthogResources } from "../jobs/posthog-resource-archive";
import { runArchiveSentry } from "../jobs/sentry-archive"; import { runArchiveSentry } from "../jobs/sentry-archive";
const QUEUE = "insight-pipeline"; const QUEUE = "insight-pipeline";
@@ -134,6 +135,17 @@ async function runJob(job: Job) {
} }
return res; return res;
} }
case "posthog-resource-archive": {
const res = await runArchivePosthogResources();
const snaps = Object.values(res.perType).reduce((a, t) => a + t.snapshots, 0);
const errs = Object.entries(res.perType).filter(([, t]) => t.error).map(([k]) => k);
if (snaps > 0 || errs.length || !res.ok) {
console.log(
`[pipeline] resource-archive types=${Object.keys(res.perType).length} snapshots=${snaps}${errs.length ? ` errors=[${errs.join(",")}]` : ""}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
case "sentry-archive": { case "sentry-archive": {
const res = await runArchiveSentry(); const res = await runArchiveSentry();
if (res.eventsInserted > 0 || res.issuesSynced > 0 || res.dumped > 0 || res.error) { if (res.eventsInserted > 0 || res.issuesSynced > 0 || res.dumped > 0 || res.error) {
@@ -222,6 +234,11 @@ export async function startInsightPipeline() {
{ pattern: "0 3 * * *" }, { pattern: "0 3 * * *" },
{ name: "posthog-identity-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } }, { name: "posthog-identity-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
); );
await queue.upsertJobScheduler(
"posthog-resource-archive",
{ pattern: "15 */6 * * *" },
{ name: "posthog-resource-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler( await queue.upsertJobScheduler(
"sentry-archive", "sentry-archive",
{ pattern: "0 * * * *" }, { pattern: "0 * * * *" },
@@ -235,7 +252,7 @@ export async function startInsightPipeline() {
stalledInterval: 60_000, stalledInterval: 60_000,
}); });
console.log( console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, sase-evening-brief@17:00UTC, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly", "[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, sase-evening-brief@17:00UTC, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, posthog-resource-archive@*/6h, sentry-archive@hourly",
); );
return worker; return worker;
} }