diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 98ff4e60fa..14f503db06 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -16,7 +16,7 @@ import { import { cn } from "@/lib/utils"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; @@ -113,6 +113,32 @@ function formatTimelineMs(ms: number) { return `${m}:${s.toString().padStart(2, "0")}`; } +type TimelineEvent = { + eventType: string, + eventAtMs: number, + data: Record, +}; + +type TimelineMarker = { + timeMs: number, + eventType: string, + label: string, +}; + +function formatEventTooltip(event: TimelineEvent): string { + const d = event.data; + if (event.eventType === "$click") { + const tag = (d.tag_name as string) || "element"; + return `Clicked ${tag}`; + } + if (event.eventType === "$page-view") { + const path = (d.path as string | undefined) ?? (d.url as string | undefined) ?? "/"; + const truncated = path.length > 30 ? path.slice(0, 27) + "..." : path; + return truncated; + } + return event.eventType; +} + function DisplayDate({ date }: { date: Date }) { const fromNow = useFromNow(date); return {fromNow}; @@ -175,6 +201,7 @@ function Timeline({ onSeek, playerSpeed, onSpeedChange, + markers, }: { getCurrentTimeMs: () => number, playerIsPlaying: boolean, @@ -183,8 +210,10 @@ function Timeline({ onSeek: (timeOffset: number) => void, playerSpeed: number, onSpeedChange: (speed: number) => void, + markers?: TimelineMarker[], }) { const [currentTime, setCurrentTime] = useState(0); + const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState(null); const trackRef = useRef(null); const rafRef = useRef(0); @@ -208,8 +237,11 @@ function Timeline({ onSeek(timeOffset); }, [totalTimeMs, onSeek]); + const hasMarkers = (markers?.length ?? 0) > 0; + const hoveredMarker = hoveredMarkerIndex !== null ? markers?.[hoveredMarkerIndex] ?? null : null; + return ( -
+
); @@ -1430,6 +1582,7 @@ export default function PageClient() { onSeek={handleSeek} playerSpeed={ms.settings.playerSpeed} onSpeedChange={updateSpeed} + markers={timelineMarkers} /> )}
diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 0050335d80..0b11de4632 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -532,14 +532,15 @@ export class _StackClientAppImplIncomplete => { + this._ensurePersistentTokenStore(); + return await (await this.getUser({ or: "anonymous" })).getAccessToken(); + }; + if (isBrowserLike() && this._analyticsOptions?.replays?.enabled === true) { this._sessionRecorder = new SessionRecorder({ projectId: this.projectId, - getAccessToken: async () => { - const session = await this._getSession(); - const tokens = await session.getOrFetchLikelyValidTokens(20_000, 75_000); - return tokens?.accessToken.token ?? null; - }, + getAccessToken: getAnalyticsAccessToken, sendBatch: async (body, opts) => { return await this._interface.sendSessionReplayBatch(body, await this._getSession(), opts); }, @@ -551,11 +552,7 @@ export class _StackClientAppImplIncomplete { - const session = await this._getSession(); - const tokens = await session.getOrFetchLikelyValidTokens(20_000, 75_000); - return tokens?.accessToken.token ?? null; - }, + getAccessToken: getAnalyticsAccessToken, sendBatch: async (body, opts) => { return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), opts); }, @@ -2392,7 +2389,8 @@ export class _StackClientAppImplIncomplete { + // Clear analytics buffers before sign-out to prevent cross-user event leakage + this._eventTracker?.clearBuffer(); + this._sessionRecorder?.clearBuffer(); + await storeLock.withWriteLock(async () => { await this._interface.signOut(session); if (options?.redirectUrl) { diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 74ec6d710d..bf48d4b23e 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -7,9 +7,6 @@ const FLUSH_INTERVAL_MS = 10_000; const MAX_EVENTS_PER_BATCH = 50; const MAX_APPROX_BYTES_PER_BATCH = 64_000; -const MAX_PREAUTH_BUFFER_EVENTS = 500; -const MAX_PREAUTH_BUFFER_BYTES = 500_000; - export type EventTrackerDeps = { projectId: string, getAccessToken: () => Promise, @@ -30,7 +27,6 @@ export class EventTracker { private _events: TrackedEvent[] = []; private _approxBytes = 0; private _lastKnownAccessToken: string | null = null; - private _wasAuthenticated = false; private _lastUrl: string | null = null; private readonly _sessionReplaySegmentId: string; private readonly _deps: EventTrackerDeps; @@ -65,18 +61,17 @@ export class EventTracker { this._teardown(); } + clearBuffer() { + this._events = []; + this._approxBytes = 0; + } + private _pushEvent(event: TrackedEvent) { this._events.push(event); this._approxBytes += JSON.stringify(event).length; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); } - - // Cap pre-auth buffer - if (!this._lastKnownAccessToken && (this._events.length > MAX_PREAUTH_BUFFER_EVENTS || this._approxBytes > MAX_PREAUTH_BUFFER_BYTES)) { - this._events = []; - this._approxBytes = 0; - } } private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") { @@ -266,12 +261,6 @@ export class EventTracker { }, { noErrorLogging: true }); const hasAuth = !!this._lastKnownAccessToken; - // Clear buffer on logout to prevent cross-user event leakage - if (this._wasAuthenticated && !hasAuth) { - this._events = []; - this._approxBytes = 0; - } - this._wasAuthenticated = hasAuth; if (hasAuth && this._events.length > 0) { runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index e7f0b4db6c..2eb497bca6 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -86,9 +86,6 @@ const FLUSH_INTERVAL_MS = 5_000; const MAX_EVENTS_PER_BATCH = 200; const MAX_APPROX_BYTES_PER_BATCH = 512_000; -const MAX_PREAUTH_BUFFER_EVENTS = 10_000; -const MAX_PREAUTH_BUFFER_BYTES = 5_000_000; - export type StoredSession = { session_id: string, created_at_ms: number, @@ -148,8 +145,10 @@ export class SessionRecorder { private _lastPersistActivity = 0; private _recording = false; private _rrwebModule: typeof import("rrweb") | null = null; + private _lastBrowserSessionId: string | null = null; + private _takingSnapshot = false; + private _flushInProgress = false; private _lastKnownAccessToken: string | null = null; - private _wasAuthenticated = false; private readonly _sessionReplaySegmentId: string; private readonly _storageKey: string; private readonly _deps: SessionRecorderDeps; @@ -188,17 +187,29 @@ export class SessionRecorder { this._stopCurrentRecording(); } - private _persistActivity(nowMs: number) { + clearBuffer() { + this._events = []; + this._approxBytes = 0; + } + + private _persistActivity(nowMs: number): StoredSession { const stored = getOrRotateSession({ key: this._storageKey, nowMs }); - if (nowMs - this._lastPersistActivity < 5_000) return; + if (nowMs - this._lastPersistActivity < 5_000) return stored; this._lastPersistActivity = nowMs; const updated: StoredSession = { ...stored, last_activity_ms: nowMs }; localStorage.setItem(this._storageKey, JSON.stringify(updated)); + return stored; } private async _flush(options: { keepalive: boolean }) { if (!this._lastKnownAccessToken) return; if (this._events.length === 0) return; + // Prevent concurrent in-flight HTTP requests. When a flush is already + // in-flight, a second batch could race on the server (both call + // findRecentSessionReplay before either upsert commits) and create + // duplicate SessionReplay records. Events stay in _events and will be + // picked up by the next tick or batch-size check. + if (this._flushInProgress) return; const nowMs = Date.now(); const stored = getOrRotateSession({ key: this._storageKey, nowMs }); @@ -216,18 +227,23 @@ export class SessionRecorder { this._events = []; this._approxBytes = 0; - const res = await this._deps.sendBatch( - JSON.stringify(payload), - { keepalive: options.keepalive }, - ); + this._flushInProgress = true; + try { + const res = await this._deps.sendBatch( + JSON.stringify(payload), + { keepalive: options.keepalive }, + ); - if (res.status === "error") { - console.warn("SessionRecorder flush failed:", res.error); - return; - } + if (res.status === "error") { + console.warn("SessionRecorder flush failed:", res.error); + return; + } - if (!res.data.ok) { - console.warn("SessionRecorder flush failed:", res.data.status, await res.data.text()); + if (!res.data.ok) { + console.warn("SessionRecorder flush failed:", res.data.status, await res.data.text()); + } + } finally { + this._flushInProgress = false; } } @@ -250,19 +266,29 @@ export class SessionRecorder { this._stopRecording = this._rrwebModule.record({ emit: (event) => { const nowMs = Date.now(); - this._persistActivity(nowMs); + const stored = this._persistActivity(nowMs); + + // Detect session rotation: after 3+ minutes idle, getOrRotateSession + // creates a new session ID. We need to inject a FullSnapshot so the + // new server-side SessionReplay record is playable. + if (this._lastBrowserSessionId === null) { + this._lastBrowserSessionId = stored.session_id; + } else if (stored.session_id !== this._lastBrowserSessionId && !this._takingSnapshot) { + this._lastBrowserSessionId = stored.session_id; + // Inject a FullSnapshot for the new session (calls emit synchronously) + this._takingSnapshot = true; + try { + this._rrwebModule!.record.takeFullSnapshot(); + } finally { + this._takingSnapshot = false; + } + } this._events.push(event); this._approxBytes += JSON.stringify(event).length; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); } - - // Cap pre-auth buffer to prevent unbounded memory growth - if (!this._lastKnownAccessToken && (this._events.length > MAX_PREAUTH_BUFFER_EVENTS || this._approxBytes > MAX_PREAUTH_BUFFER_BYTES)) { - this._events = []; - this._approxBytes = 0; - } }, maskAllInputs: this._replayOptions.maskAllInputs ?? true, ...(this._replayOptions.blockClass !== undefined ? { blockClass: this._replayOptions.blockClass } : {}), @@ -305,12 +331,6 @@ export class SessionRecorder { }, { noErrorLogging: true }); const hasAuth = !!this._lastKnownAccessToken; - // Clear buffer on logout to prevent cross-user event leakage - if (this._wasAuthenticated && !hasAuth) { - this._events = []; - this._approxBytes = 0; - } - this._wasAuthenticated = hasAuth; if (hasAuth && this._events.length > 0) { runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); }