feat(analytics): recordings intelligence — signal filters + replay links

Recordings tab filters by signal (errors / long >=5m / active >=30 clicks)
with live counts, highlights error sessions, and adds a one-click PostHog
replay player link per recording. Turns the 5k+ archived recordings into a
triage surface for watching problem sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 23:25:45 +03:00
parent fffc81f132
commit 79b8b6b356
2 changed files with 84 additions and 13 deletions

View File

@@ -13,11 +13,12 @@ import {
import {
getArchiveCounts,
getLatestResources,
getRecordingCount,
getRecordingSignalCounts,
getRecentRecordings,
RESOURCE_TYPES,
TYPE_LABEL,
type ResourceType,
type RecordingFilter,
} from "@/lib/analytics/posthog-archive";
export const dynamic = "force-dynamic";
@@ -35,20 +36,36 @@ function fmtDur(sec: number | null): string {
return `${m}:${String(s).padStart(2, "0")}`;
}
const POSTHOG_APP = process.env.POSTHOG_APP_URL ?? "https://eu.posthog.com";
const POSTHOG_PID = process.env.POSTHOG_PROJECT_ID ?? "";
const replayUrl = (id: string) => `${POSTHOG_APP}/project/${POSTHOG_PID}/replay/${id}`;
const REC_FILTERS: Array<{ key: RecordingFilter; label: string }> = [
{ key: "all", label: "Tümü" },
{ key: "errors", label: "Hatalı" },
{ key: "long", label: "Uzun (5dk+)" },
{ key: "active", label: "Çok aktif" },
];
export default async function PosthogArchivePage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
searchParams: Promise<{ type?: string; filter?: string }>;
}) {
const sp = await searchParams;
const isRecording = sp.type === "recording";
const type: ResourceType = isType(sp.type) ? sp.type : "feature_flag";
const recFilter: RecordingFilter = (["all", "errors", "long", "active"] as const).includes(
(sp.filter ?? "all") as RecordingFilter,
)
? (sp.filter as RecordingFilter)
: "all";
const [counts, recCount, resources, recordings] = await Promise.all([
const [counts, recSignals, resources, recordings] = await Promise.all([
getArchiveCounts(PROJECT),
getRecordingCount(PROJECT),
getRecordingSignalCounts(PROJECT),
isRecording ? Promise.resolve([]) : getLatestResources(PROJECT, type),
isRecording ? getRecentRecordings(PROJECT, 150) : Promise.resolve([]),
isRecording ? getRecentRecordings(PROJECT, 150, recFilter) : Promise.resolve([]),
]);
return (
@@ -77,12 +94,25 @@ export default async function PosthogArchivePage({
>
{TYPE_LABEL.recording}
<Badge variant="secondary" className="ml-1.5">
{recCount.toLocaleString("tr-TR")}
{recSignals.all.toLocaleString("tr-TR")}
</Badge>
</Link>
</div>
{isRecording ? (
<>
<div className="mb-2 flex flex-wrap gap-1.5">
{REC_FILTERS.map((f) => (
<Link
key={f.key}
href={`/posthog-archive?type=recording&filter=${f.key}`}
className={buttonVariants({ variant: f.key === recFilter ? "secondary" : "ghost", size: "sm" })}
>
{f.label}
{f.key !== "all" && <Badge variant="outline" className="ml-1.5">{recSignals[f.key]}</Badge>}
</Link>
))}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
@@ -93,33 +123,38 @@ export default async function PosthogArchivePage({
<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>
<TableHead className="w-12 text-right">İzle</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordings.map((r) => (
<TableRow key={r.id}>
<TableRow key={r.id} className={r.consoleErrorCount ? "bg-red-500/5" : undefined}>
<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}
{r.consoleErrorCount ? <span className="font-medium 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>
<TableCell className="text-right">
<a href={replayUrl(r.id)} target="_blank" rel="noopener noreferrer" className="text-primary underline" title="PostHog player'da izle"></a>
</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 colSpan={7} className="text-center text-sm text-muted-foreground">
Bu filtrede recording yok.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : (
<div className="rounded-md border">
<Table>

View File

@@ -122,10 +122,46 @@ export type RecordingRow = {
startTime: Date | null;
};
export async function getRecentRecordings(projectKey: string, limit = 100): Promise<RecordingRow[]> {
export type RecordingFilter = "all" | "errors" | "long" | "active";
export async function getRecordingSignalCounts(
projectKey: string,
): Promise<{ all: number; errors: number; long: number; active: number }> {
const rows = await prisma.$queryRaw<Array<{ all: bigint; errors: bigint; long: bigint; active: bigint }>>`
SELECT count(*)::bigint AS all,
count(*) FILTER (WHERE "consoleErrorCount" > 0)::bigint AS errors,
count(*) FILTER (WHERE "durationSec" >= 300)::bigint AS long,
count(*) FILTER (WHERE "clickCount" >= 30)::bigint AS active
FROM posthog_recordings WHERE "projectKey" = ${projectKey}
`;
const r = rows[0];
return { all: Number(r?.all ?? 0), errors: Number(r?.errors ?? 0), long: Number(r?.long ?? 0), active: Number(r?.active ?? 0) };
}
export async function getRecentRecordings(
projectKey: string,
limit = 150,
filter: RecordingFilter = "all",
): Promise<RecordingRow[]> {
const where =
filter === "errors"
? { projectKey, consoleErrorCount: { gt: 0 } }
: filter === "long"
? { projectKey, durationSec: { not: null } }
: filter === "active"
? { projectKey, clickCount: { not: null } }
: { projectKey };
const orderBy =
filter === "errors"
? [{ consoleErrorCount: "desc" as const }, { startTime: "desc" as const }]
: filter === "long"
? { durationSec: "desc" as const }
: filter === "active"
? { clickCount: "desc" as const }
: { startTime: "desc" as const };
return prisma.posthogRecording.findMany({
where: { projectKey },
orderBy: { startTime: "desc" },
where,
orderBy,
take: limit,
select: {
id: true,