Skip to content

Commit 7f0063f

Browse files
authored
analytics: replays event markers (#1210)
https://www.loom.com/share/09a89533039d4bd4814332ec0728a30f <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added batch analytics event submission API endpoint * Enhanced session replay timeline with visual markers for page views and click events * Display click event counts on replay list items * Implemented client-side event tracking for page views and clicks * **Bug Fixes** * Session replay now properly errors when analytics feature is disabled * **Tests** * Added end-to-end tests for analytics events batch API with validation and querying * Updated session replay test expectations for analytics error handling <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent dff0ddd commit 7f0063f

4 files changed

Lines changed: 233 additions & 69 deletions

File tree

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx

Lines changed: 166 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { cn } from "@/lib/utils";
1717
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
1818
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
19-
import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react";
19+
import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react";
2020
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
2121
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2222
import { AppEnabledGuard } from "../../app-enabled-guard";
@@ -113,6 +113,32 @@ function formatTimelineMs(ms: number) {
113113
return `${m}:${s.toString().padStart(2, "0")}`;
114114
}
115115

116+
type TimelineEvent = {
117+
eventType: string,
118+
eventAtMs: number,
119+
data: Record<string, unknown>,
120+
};
121+
122+
type TimelineMarker = {
123+
timeMs: number,
124+
eventType: string,
125+
label: string,
126+
};
127+
128+
function formatEventTooltip(event: TimelineEvent): string {
129+
const d = event.data;
130+
if (event.eventType === "$click") {
131+
const tag = (d.tag_name as string) || "element";
132+
return `Clicked ${tag}`;
133+
}
134+
if (event.eventType === "$page-view") {
135+
const path = (d.path as string | undefined) ?? (d.url as string | undefined) ?? "/";
136+
const truncated = path.length > 30 ? path.slice(0, 27) + "..." : path;
137+
return truncated;
138+
}
139+
return event.eventType;
140+
}
141+
116142
function DisplayDate({ date }: { date: Date }) {
117143
const fromNow = useFromNow(date);
118144
return <span>{fromNow}</span>;
@@ -175,6 +201,7 @@ function Timeline({
175201
onSeek,
176202
playerSpeed,
177203
onSpeedChange,
204+
markers,
178205
}: {
179206
getCurrentTimeMs: () => number,
180207
playerIsPlaying: boolean,
@@ -183,8 +210,10 @@ function Timeline({
183210
onSeek: (timeOffset: number) => void,
184211
playerSpeed: number,
185212
onSpeedChange: (speed: number) => void,
213+
markers?: TimelineMarker[],
186214
}) {
187215
const [currentTime, setCurrentTime] = useState(0);
216+
const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState<number | null>(null);
188217
const trackRef = useRef<HTMLDivElement | null>(null);
189218
const rafRef = useRef<number>(0);
190219

@@ -208,8 +237,11 @@ function Timeline({
208237
onSeek(timeOffset);
209238
}, [totalTimeMs, onSeek]);
210239

240+
const hasMarkers = (markers?.length ?? 0) > 0;
241+
const hoveredMarker = hoveredMarkerIndex !== null ? markers?.[hoveredMarkerIndex] ?? null : null;
242+
211243
return (
212-
<div className="border-t border-border/30 bg-background px-3 py-2 flex items-center gap-3">
244+
<div className={cn("border-t border-border/30 bg-background px-3 flex items-center gap-3", hasMarkers ? "py-1.5" : "py-2")}>
213245
<Button
214246
variant="ghost"
215247
size="icon"
@@ -223,16 +255,62 @@ function Timeline({
223255
{formatTimelineMs(currentTime)}
224256
</span>
225257

226-
<div
227-
ref={trackRef}
228-
onClick={handleTrackClick}
229-
className="flex-1 h-5 flex items-center cursor-pointer group"
230-
>
231-
<div className="w-full h-1.5 rounded-full bg-muted relative overflow-hidden">
232-
<div
233-
className="absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors"
234-
style={{ width: `${progress * 100}%` }}
235-
/>
258+
<div className="flex-1 flex flex-col justify-center">
259+
{/* Event markers lane */}
260+
{hasMarkers && (
261+
<div className="relative h-3.5 mb-0.5">
262+
{markers?.map((marker, i) => {
263+
const left = totalTimeMs > 0 ? (marker.timeMs / totalTimeMs) * 100 : 0;
264+
if (left < 0 || left > 100) return null;
265+
const isClick = marker.eventType === "$click";
266+
return (
267+
<div
268+
key={i}
269+
className={cn(
270+
"absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer",
271+
"transition-colors",
272+
isClick
273+
? "bg-blue-500/70 hover:bg-blue-400"
274+
: "bg-emerald-500/70 hover:bg-emerald-400",
275+
)}
276+
style={{ left: `${left}%`, marginLeft: "-1.5px" }}
277+
onMouseEnter={() => setHoveredMarkerIndex(i)}
278+
onMouseLeave={() => setHoveredMarkerIndex((prev) => prev === i ? null : prev)}
279+
onClick={() => onSeek(marker.timeMs)}
280+
/>
281+
);
282+
})}
283+
284+
{/* Custom tooltip */}
285+
{hoveredMarker && (() => {
286+
const left = totalTimeMs > 0 ? (hoveredMarker.timeMs / totalTimeMs) * 100 : 0;
287+
return (
288+
<div
289+
className="absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50"
290+
style={{ left: `${left}%` }}
291+
>
292+
<div className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground whitespace-nowrap max-w-52">
293+
<div className="truncate">{hoveredMarker.label}</div>
294+
<div className="text-[10px] opacity-70">{formatTimelineMs(hoveredMarker.timeMs)}</div>
295+
</div>
296+
</div>
297+
);
298+
})()}
299+
</div>
300+
)}
301+
302+
{/* Progress bar track (clickable) */}
303+
<div
304+
ref={trackRef}
305+
onClick={handleTrackClick}
306+
className="h-5 flex items-center cursor-pointer group"
307+
>
308+
<div className="w-full h-1.5 rounded-full bg-muted relative overflow-hidden">
309+
<div
310+
className="absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors"
311+
style={{ width: `${progress * 100}%` }}
312+
/>
313+
</div>
236314
</div>
237315
</div>
238316

@@ -346,6 +424,8 @@ export default function PageClient() {
346424
const [loadingInitial, setLoadingInitial] = useState(true);
347425
const [loadingMore, setLoadingMore] = useState(false);
348426
const [listError, setListError] = useState<string | null>(null);
427+
const [clickCountsByReplayId, setClickCountsByReplayId] = useState<Map<string, number>>(new Map());
428+
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
349429

350430
const listBoxRef = useRef<HTMLDivElement | null>(null);
351431

@@ -392,6 +472,28 @@ export default function PageClient() {
392472
runAsynchronously(() => loadPage(null), { noErrorLogging: true });
393473
}, [loadPage]);
394474

475+
useEffect(() => {
476+
if (recordings.length === 0) return;
477+
const ids = recordings.map(r => r.id);
478+
runAsynchronously(async () => {
479+
const res = await adminApp.queryAnalytics({
480+
query: `SELECT session_replay_id, count() as cnt
481+
FROM default.events
482+
WHERE event_type = '$click'
483+
AND session_replay_id IN ({ids:Array(String)})
484+
GROUP BY session_replay_id`,
485+
params: { ids },
486+
include_all_branches: false,
487+
timeout_ms: 15000,
488+
});
489+
const map = new Map<string, number>();
490+
for (const row of res.result) {
491+
map.set(row.session_replay_id as string, Number(row.cnt));
492+
}
493+
setClickCountsByReplayId(map);
494+
}, { noErrorLogging: true });
495+
}, [recordings, adminApp]);
496+
395497
const onListScroll = useCallback(() => {
396498
const el = listBoxRef.current;
397499
if (!el) return;
@@ -967,6 +1069,41 @@ export default function PageClient() {
9671069
runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true });
9681070
}, [loadChunksAndDownload, selectedRecordingId, selectedRecording]);
9691071

1072+
useEffect(() => {
1073+
if (!selectedRecordingId) {
1074+
setTimelineEvents([]);
1075+
return;
1076+
}
1077+
let cancelled = false;
1078+
setTimelineEvents([]);
1079+
runAsynchronously(async () => {
1080+
const res = await adminApp.queryAnalytics({
1081+
query: `SELECT event_type,
1082+
toUnixTimestamp64Milli(event_at) as event_at_ms,
1083+
data
1084+
FROM default.events
1085+
WHERE session_replay_id = {id:String}
1086+
AND event_type IN ('$click', '$page-view')
1087+
ORDER BY event_at ASC
1088+
LIMIT 2000`,
1089+
params: { id: selectedRecordingId },
1090+
include_all_branches: false,
1091+
timeout_ms: 15000,
1092+
});
1093+
if (cancelled) return;
1094+
setTimelineEvents(res.result.map((r: any) => ({
1095+
eventType: r.event_type as string,
1096+
eventAtMs: Number(r.event_at_ms),
1097+
data: typeof r.data === "string"
1098+
? JSON.parse(r.data)
1099+
: (r.data ?? {}),
1100+
})));
1101+
}, { noErrorLogging: true });
1102+
return () => {
1103+
cancelled = true;
1104+
};
1105+
}, [selectedRecordingId, adminApp]);
1106+
9701107
useEffect(() => {
9711108
return () => {
9721109
genCounterRef.current += 1;
@@ -1144,6 +1281,15 @@ export default function PageClient() {
11441281

11451282
const showMainTabLabel = renderableStreamCount > 1;
11461283

1284+
const timelineMarkers = useMemo(() => {
1285+
if (timelineEvents.length === 0 || ms.globalTotalMs <= 0) return [];
1286+
return timelineEvents.map((e): TimelineMarker => ({
1287+
timeMs: e.eventAtMs - ms.globalStartTs,
1288+
eventType: e.eventType,
1289+
label: formatEventTooltip(e),
1290+
})).filter(m => m.timeMs >= 0 && m.timeMs <= ms.globalTotalMs);
1291+
}, [timelineEvents, ms.globalStartTs, ms.globalTotalMs]);
1292+
11471293
// ---- Rendering ----
11481294

11491295
return (
@@ -1208,8 +1354,14 @@ export default function PageClient() {
12081354
{duration}
12091355
</span>
12101356
</div>
1211-
<div className="text-xs text-muted-foreground">
1357+
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
12121358
<DisplayDate date={r.lastEventAt} />
1359+
{(clickCountsByReplayId.get(r.id) ?? 0) > 0 && (
1360+
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70">
1361+
<CursorClickIcon className="h-3 w-3" />
1362+
{clickCountsByReplayId.get(r.id)}
1363+
</span>
1364+
)}
12131365
</div>
12141366
</button>
12151367
);
@@ -1430,6 +1582,7 @@ export default function PageClient() {
14301582
onSeek={handleSeek}
14311583
playerSpeed={ms.settings.playerSpeed}
14321584
onSpeedChange={updateSpeed}
1585+
markers={timelineMarkers}
14331586
/>
14341587
)}
14351588
</div>

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -532,14 +532,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
532532
}
533533

534534
this._analyticsOptions = resolvedOptions.analytics;
535+
const getAnalyticsAccessToken = async (): Promise<string | null> => {
536+
this._ensurePersistentTokenStore();
537+
return await (await this.getUser({ or: "anonymous" })).getAccessToken();
538+
};
539+
535540
if (isBrowserLike() && this._analyticsOptions?.replays?.enabled === true) {
536541
this._sessionRecorder = new SessionRecorder({
537542
projectId: this.projectId,
538-
getAccessToken: async () => {
539-
const session = await this._getSession();
540-
const tokens = await session.getOrFetchLikelyValidTokens(20_000, 75_000);
541-
return tokens?.accessToken.token ?? null;
542-
},
543+
getAccessToken: getAnalyticsAccessToken,
543544
sendBatch: async (body, opts) => {
544545
return await this._interface.sendSessionReplayBatch(body, await this._getSession(), opts);
545546
},
@@ -551,11 +552,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
551552
if (isBrowserLike() && this.projectId === "internal") {
552553
this._eventTracker = new EventTracker({
553554
projectId: this.projectId,
554-
getAccessToken: async () => {
555-
const session = await this._getSession();
556-
const tokens = await session.getOrFetchLikelyValidTokens(20_000, 75_000);
557-
return tokens?.accessToken.token ?? null;
558-
},
555+
getAccessToken: getAnalyticsAccessToken,
559556
sendBatch: async (body, opts) => {
560557
return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), opts);
561558
},
@@ -2392,7 +2389,8 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
23922389
case undefined:
23932390
case "anonymous-if-exists[deprecated]":
23942391
case "return-null": {
2395-
// do nothing
2392+
crud = null;
2393+
break;
23962394
}
23972395
}
23982396
}
@@ -2890,6 +2888,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
28902888
}
28912889

28922890
protected async _signOut(session: InternalSession, options?: { redirectUrl?: URL | string }): Promise<void> {
2891+
// Clear analytics buffers before sign-out to prevent cross-user event leakage
2892+
this._eventTracker?.clearBuffer();
2893+
this._sessionRecorder?.clearBuffer();
2894+
28932895
await storeLock.withWriteLock(async () => {
28942896
await this._interface.signOut(session);
28952897
if (options?.redirectUrl) {

packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ const FLUSH_INTERVAL_MS = 10_000;
77
const MAX_EVENTS_PER_BATCH = 50;
88
const MAX_APPROX_BYTES_PER_BATCH = 64_000;
99

10-
const MAX_PREAUTH_BUFFER_EVENTS = 500;
11-
const MAX_PREAUTH_BUFFER_BYTES = 500_000;
12-
1310
export type EventTrackerDeps = {
1411
projectId: string,
1512
getAccessToken: () => Promise<string | null>,
@@ -30,7 +27,6 @@ export class EventTracker {
3027
private _events: TrackedEvent[] = [];
3128
private _approxBytes = 0;
3229
private _lastKnownAccessToken: string | null = null;
33-
private _wasAuthenticated = false;
3430
private _lastUrl: string | null = null;
3531
private readonly _sessionReplaySegmentId: string;
3632
private readonly _deps: EventTrackerDeps;
@@ -65,18 +61,17 @@ export class EventTracker {
6561
this._teardown();
6662
}
6763

64+
clearBuffer() {
65+
this._events = [];
66+
this._approxBytes = 0;
67+
}
68+
6869
private _pushEvent(event: TrackedEvent) {
6970
this._events.push(event);
7071
this._approxBytes += JSON.stringify(event).length;
7172
if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
7273
runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true });
7374
}
74-
75-
// Cap pre-auth buffer
76-
if (!this._lastKnownAccessToken && (this._events.length > MAX_PREAUTH_BUFFER_EVENTS || this._approxBytes > MAX_PREAUTH_BUFFER_BYTES)) {
77-
this._events = [];
78-
this._approxBytes = 0;
79-
}
8075
}
8176

8277
private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") {
@@ -266,12 +261,6 @@ export class EventTracker {
266261
}, { noErrorLogging: true });
267262

268263
const hasAuth = !!this._lastKnownAccessToken;
269-
// Clear buffer on logout to prevent cross-user event leakage
270-
if (this._wasAuthenticated && !hasAuth) {
271-
this._events = [];
272-
this._approxBytes = 0;
273-
}
274-
this._wasAuthenticated = hasAuth;
275264
if (hasAuth && this._events.length > 0) {
276265
runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true });
277266
}

0 commit comments

Comments
 (0)