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