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:
@@ -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
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user