feat(archive): add groups + recordings to the PostHog archive

- groups: posthog-resource-archive now also snapshots group types + group
  instances (groups_types/ + groups/?group_type_index) into
  posthog_resource_snapshots (change-detected).
- recordings: new posthog-recording-archive job (@*/6h) captures complete
  metadata for EVERY recording into posthog_recordings (skipDuplicates over a
  rolling 90d window) + MinIO dump — a superset of the insight pipeline's
  promoted SessionMeta subset. rrweb blobs still archived by archive-recordings.
- /posthog-archive browser: Groups / Group Types tabs + a Recordings tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 21:19:54 +03:00
parent e953f5fc34
commit 400ed224c5
7 changed files with 373 additions and 45 deletions

View File

@@ -561,6 +561,35 @@ model PosthogResourceSnapshot {
@@map("posthog_resource_snapshots")
}
// Complete metadata for EVERY session recording (not just the insight-pipeline's
// promoted subset in SessionMeta). One row per recording; full list-item payload
// kept lossless. rrweb blobs themselves are archived separately to MinIO by the
// archive-recordings job for ingested sessions.
model PosthogRecording {
id String @id // PostHog recording / session id
projectKey String
distinctId String?
personName String?
startTime DateTime?
endTime DateTime?
durationSec Int?
activeSeconds Int?
clickCount Int?
keypressCount Int?
mouseActivityCount Int?
consoleErrorCount Int?
startUrl String?
ongoing Boolean @default(false)
properties Json // full recording list item, lossless
capturedAt DateTime @default(now())
dumpedAt DateTime?
@@index([projectKey, startTime])
@@index([distinctId])
@@index([dumpedAt])
@@map("posthog_recordings")
}
// ---------- Phase C: Sentry archive ----------
// Sentry issue (group) aggregate state. Upserted to the latest snapshot — the

View File

@@ -13,6 +13,8 @@ import {
import {
getArchiveCounts,
getLatestResources,
getRecordingCount,
getRecentRecordings,
RESOURCE_TYPES,
TYPE_LABEL,
type ResourceType,
@@ -26,24 +28,34 @@ function isType(v: string | undefined): v is ResourceType {
return !!v && (RESOURCE_TYPES as readonly string[]).includes(v);
}
function fmtDur(sec: number | null): string {
if (!sec || sec <= 0) return "—";
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export default async function PosthogArchivePage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const sp = await searchParams;
const isRecording = sp.type === "recording";
const type: ResourceType = isType(sp.type) ? sp.type : "feature_flag";
const [counts, resources] = await Promise.all([
const [counts, recCount, resources, recordings] = await Promise.all([
getArchiveCounts(PROJECT),
getLatestResources(PROJECT, type),
getRecordingCount(PROJECT),
isRecording ? Promise.resolve([]) : getLatestResources(PROJECT, type),
isRecording ? getRecentRecordings(PROJECT, 150) : Promise.resolve([]),
]);
return (
<PanelShell title="PostHog Arşiv">
<p className="text-sm text-muted-foreground">
PostHog&apos;daki tüm kaynak modülleri (survey, feature flag, experiment, dashboard, insight,
annotation, action) değişiklik geçmişiyle birlikte arşivleniyor (~6 saat tazelik, DB + MinIO).
PostHog&apos;daki tüm modüller feature flag, experiment, survey, insight, dashboard, annotation,
action, group ve recording değişiklik geçmişiyle birlikte arşivleniyor (~6 saat tazelik, DB + MinIO).
</p>
<div className="flex flex-wrap gap-1.5">
@@ -51,7 +63,7 @@ export default async function PosthogArchivePage({
<Link
key={c.type}
href={`/posthog-archive?type=${c.type}`}
className={buttonVariants({ variant: c.type === type ? "default" : "outline", size: "sm" })}
className={buttonVariants({ variant: !isRecording && c.type === type ? "default" : "outline", size: "sm" })}
>
{TYPE_LABEL[c.type] ?? c.type}
<Badge variant="secondary" className="ml-1.5">
@@ -59,48 +71,97 @@ export default async function PosthogArchivePage({
</Badge>
</Link>
))}
<Link
href="/posthog-archive?type=recording"
className={buttonVariants({ variant: isRecording ? "default" : "outline", size: "sm" })}
>
{TYPE_LABEL.recording}
<Badge variant="secondary" className="ml-1.5">
{recCount.toLocaleString("tr-TR")}
</Badge>
</Link>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad</TableHead>
<TableHead className="w-28">ID</TableHead>
<TableHead className="w-24 text-right">Versiyon</TableHead>
<TableHead className="w-36 text-right">Son yakalama</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((r) => (
<TableRow key={r.resourceId}>
<TableCell className="max-w-[460px] truncate font-medium">
<Link
href={`/posthog-archive/${type}/${encodeURIComponent(r.resourceId)}`}
className="underline"
>
{r.name || `(adsız ${type})`}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{r.resourceId}</TableCell>
<TableCell className="text-right tabular-nums">
{r.versions > 1 ? <Badge variant="outline">{r.versions}</Badge> : r.versions}
</TableCell>
<TableCell className="text-right font-mono text-xs text-muted-foreground">
{r.capturedAt.toISOString().slice(0, 16).replace("T", " ")}
</TableCell>
</TableRow>
))}
{resources.length === 0 && (
{isRecording ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
Bu tip için henüz arşiv yok.
</TableCell>
<TableHead>Kullanıcı</TableHead>
<TableHead className="max-w-[360px]">Başlangıç URL</TableHead>
<TableHead className="w-20 text-right">Süre</TableHead>
<TableHead className="w-16 text-right">Tık</TableHead>
<TableHead className="w-16 text-right">Hata</TableHead>
<TableHead className="w-36 text-right">Başlangıç</TableHead>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{recordings.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.personName || r.distinctId?.slice(0, 14) || "anon"}</TableCell>
<TableCell className="max-w-[360px] truncate text-xs text-muted-foreground">{r.startUrl || "—"}</TableCell>
<TableCell className="text-right tabular-nums">{fmtDur(r.durationSec)}</TableCell>
<TableCell className="text-right tabular-nums">{r.clickCount ?? 0}</TableCell>
<TableCell className="text-right tabular-nums">
{r.consoleErrorCount ? <span className="text-red-600">{r.consoleErrorCount}</span> : 0}
</TableCell>
<TableCell className="text-right font-mono text-xs text-muted-foreground">
{r.startTime ? r.startTime.toISOString().slice(0, 16).replace("T", " ") : "—"}
</TableCell>
</TableRow>
))}
{recordings.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
Henüz recording arşivlenmedi (ilk çalışma sonrası dolar).
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad</TableHead>
<TableHead className="w-28">ID</TableHead>
<TableHead className="w-24 text-right">Versiyon</TableHead>
<TableHead className="w-36 text-right">Son yakalama</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((r) => (
<TableRow key={r.resourceId}>
<TableCell className="max-w-[460px] truncate font-medium">
<Link
href={`/posthog-archive/${type}/${encodeURIComponent(r.resourceId)}`}
className="underline"
>
{r.name || `(adsız ${type})`}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{r.resourceId}</TableCell>
<TableCell className="text-right tabular-nums">
{r.versions > 1 ? <Badge variant="outline">{r.versions}</Badge> : r.versions}
</TableCell>
<TableCell className="text-right font-mono text-xs text-muted-foreground">
{r.capturedAt.toISOString().slice(0, 16).replace("T", " ")}
</TableCell>
</TableRow>
))}
{resources.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
Bu tip için henüz arşiv yok.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</PanelShell>
);
}

View File

@@ -8,6 +8,8 @@ export const RESOURCE_TYPES = [
"dashboard",
"annotation",
"action",
"group",
"group_type",
] as const;
export type ResourceType = (typeof RESOURCE_TYPES)[number];
@@ -19,6 +21,9 @@ export const TYPE_LABEL: Record<string, string> = {
dashboard: "Dashboards",
annotation: "Annotations",
action: "Actions",
group: "Groups",
group_type: "Group Types",
recording: "Recordings",
};
export type ArchiveCount = { type: string; resources: number; snapshots: number; lastCaptured: Date | null };
@@ -97,3 +102,40 @@ export async function getResourceHistory(
});
return rows;
}
// ── Recordings (separate table: posthog_recordings — full recording metadata) ──
export async function getRecordingCount(projectKey: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ c: bigint }>>`
SELECT count(*)::bigint AS c FROM posthog_recordings WHERE "projectKey" = ${projectKey}
`;
return Number(rows[0]?.c ?? 0);
}
export type RecordingRow = {
id: string;
distinctId: string | null;
personName: string | null;
durationSec: number | null;
clickCount: number | null;
consoleErrorCount: number | null;
startUrl: string | null;
startTime: Date | null;
};
export async function getRecentRecordings(projectKey: string, limit = 100): Promise<RecordingRow[]> {
return prisma.posthogRecording.findMany({
where: { projectKey },
orderBy: { startTime: "desc" },
take: limit,
select: {
id: true,
distinctId: true,
personName: true,
durationSec: true,
clickCount: true,
consoleErrorCount: true,
startUrl: true,
startTime: true,
},
});
}