Conversation
…uth/stack-auth into analytics-event-tracking
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughThe PR enhances analytics replay visualization with timeline event markers and click counts, refactors analytics token management into a centralized resolver, and implements session rotation detection with concurrency guards in the session replay recorder. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds analytics event tracking (page views and clicks) to the session replays feature, with event markers displayed on the replay timeline. Key changes:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Browser as Browser (EventTracker)
participant API as Backend API
participant CH as ClickHouse
participant PG as PostgreSQL (Prisma)
participant Dashboard as Dashboard UI
Note over Browser: User clicks / navigates pages
Browser->>Browser: _pushEvent($click / $page-view)
Browser->>Browser: Buffer events (max 50 or 64KB)
Note over Browser: Every 10s (_tick)
Browser->>API: POST /api/v1/analytics/events/batch
API->>API: Validate auth + schema
API->>PG: findRecentSessionReplay(tenancyId, refreshTokenId)
PG-->>API: session_replay_id (or null)
API->>CH: INSERT INTO analytics_internal.events
CH-->>API: OK
API-->>Browser: { inserted: N }
Note over Dashboard: User views replay
Dashboard->>CH: SELECT event_type, event_at_ms, data FROM events WHERE session_replay_id = ?
CH-->>Dashboard: Timeline events
Dashboard->>Dashboard: Render markers on timeline
Last reviewed commit: e7a050a |
packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts
Show resolved
Hide resolved
.../dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
Show resolved
Hide resolved
.../dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts (1)
758-772:⚠️ Potential issue | 🟡 Minor
pausedAtGlobalMsmay be stale when pausing frombufferingentered viaSEEKWhen
SEEKtakes the buffering path (lines 895–910),currentGlobalTimeMsForUiis not updated — onlypausedAtGlobalMsis set to the seek target. If the user presses pause before the nextTICKreconcilescurrentGlobalTimeMsForUi(normally within ~16 ms),pausedAtGlobalMswill be silently overwritten with the pre-seek stale value, causing resume to start from the wrong position.The SEEK non-buffering path (line 924) correctly updates
currentGlobalTimeMsForUi; the buffering branch should mirror it:🐛 Proposed fix — keep the two SEEK return paths symmetric
// SEEK, buffering return (≈ line 895) return { state: { ...stateWithNewTab, playbackMode: "buffering", pausedAtGlobalMs: seekState.pausedAtGlobalMs, + currentGlobalTimeMsForUi: action.globalOffsetMs, gapFastForward: null, bufferingAtGlobalMs: action.globalOffsetMs, autoResumeAfterBuffering: true, prematureFinishRetryLocalMs: null, playingWithoutProgressSinceMs: null, playerError: null, }, effects, };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts around lines 758 - 772, The pause transition currently unconditionally sets pausedAtGlobalMs to state.currentGlobalTimeMsForUi which can be stale if we entered buffering via SEEK; change the pause logic in the branch handling isPlaying || isBuffering so pausedAtGlobalMs is set to the most recent seek/buffer time (use state.bufferingAtGlobalMs when non-null, otherwise state.currentGlobalTimeMsForUi) and do not overwrite an existing non-null pausedAtGlobalMs from the SEEK path; update the assignment for pausedAtGlobalMs in that branch accordingly (referencing pausedAtGlobalMs, currentGlobalTimeMsForUi, bufferingAtGlobalMs, playbackMode).
🧹 Nitpick comments (7)
packages/stack-shared/src/interface/client-interface.ts (1)
253-298: Both batch methods use a catch-all that swallowsKnownErrortype information.
sendClientRequestcan throw typedKnownErrorinstances (e.g.,AnalyticsNotEnabled). The catch-all on lines 272–274 / 296–297 wraps them as plainErrorinResult.error, so callers lose the ability to distinguish specific failure reasons. This is fine if callers only need success/failure, but it deviates from the project guideline against try-catch-all.If the caller should be able to react to
AnalyticsNotEnabledspecifically (e.g., to stop sending further batches), consider usingsendClientRequestAndCatchKnownErrorinstead, or at least preserving the original error object rather than re-wrapping it asnew Error(String(e)).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/stack-shared/src/interface/client-interface.ts` around lines 253 - 298, The catch-all in sendSessionReplayBatch and sendAnalyticsEventBatch hides typed KnownError (e.g., AnalyticsNotEnabled) by wrapping exceptions into new Error, so update these methods to preserve KnownError information: either call sendClientRequestAndCatchKnownError instead of sendClientRequest (so KnownError is handled explicitly) or, if keeping sendClientRequest, return Result.error(e instanceof Error ? e : new Error(String(e))) without re-wrapping KnownError (i.e., don't create new Error for KnownError instances) — ensure the catch distinguishes KnownError and returns it directly to Result.error while only normalizing unknowns, referencing sendSessionReplayBatch, sendAnalyticsEventBatch, sendClientRequest, sendClientRequestAndCatchKnownError, KnownError, and AnalyticsNotEnabled.packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts (2)
261-278:getAccessTokenresult is asynchronous buthasAuthis read synchronously — one-tick lag on auth state changes.
_tickfiresgetAccessTokenviarunAsynchronously(fire-and-forget) and then immediately readsthis._lastKnownAccessTokensynchronously. This means the auth state always reflects the previous tick's result. During user transitions (logout → login as a different user), there's a ~10s window where:
- Events from the new user could be flushed with the old user's stale token (server rejects).
- On the next tick,
_wasAuthenticated && !hasAuthclears the buffer, possibly discarding valid events from the new user.For an internal-only analytics tracker this is acceptable, but worth documenting with a brief comment.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts` around lines 261 - 278, The _tick method fires getAccessToken asynchronously and then immediately reads this._lastKnownAccessToken, causing a one-tick stale auth state; change _tick so it updates and uses the token result before making decisions: call this._deps.getAccessToken() and await its result (or chain a .then) inside _tick to set this._lastKnownAccessToken and compute hasAuth from that fresh value, then perform the logout buffer clear and conditional flush using the fresh hasAuth; update references to _wasAuthenticated, _events, _approxBytes and _flush accordingly so they act on the current token state rather than the previous tick.
68-80: Events lost silently on flush failure.In
_flush(line 243–244), the event buffer is cleared before the network call completes. IfsendBatchfails, those events are permanently discarded with only aconsole.warn. This is a standard trade-off in analytics SDKs (no guaranteed delivery), but it's worth noting as a conscious design choice — especially sincenoErrorLogging: trueon therunAsynchronouslycalls means these failures won't surface through the normal error pipeline either.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts` around lines 68 - 80, The code currently clears the event buffer inside _flush before calling sendBatch which causes silent loss if sendBatch fails; modify _flush so it does not mutate or drop this._events/_approxBytes until sendBatch succeeds (or at least re-queues them on failure): capture the current batch (e.g., const batch = this._events.slice() and bytes copy) and set this._events and this._approxBytes to a fresh buffer only after sendBatch resolves, otherwise restore or prepend the failed batch back into this._events and adjust _approxBytes; also remove or reconsider using noErrorLogging for the runAsynchronously calls around _flush so failures surface via the normal error pipeline, referencing _pushEvent, _flush, sendBatch, and runAsynchronously to locate the changes.apps/e2e/tests/js/cookies.test.ts (1)
7-23: Consider makingpathnameconfigurable inBrowserEnvOptions
pathnameis now part ofBrowserEnvbut there is no corresponding option inBrowserEnvOptions, so all tests that callsetupBrowserCookieEnvare locked to"/". Since the newEventTrackeruseswindow.location.pathnamefor page-view events, future tests that need to assert page-view tracking on non-root paths will need to mutatelocation.pathnamemanually after setup — a pattern that can be fragile.🔧 Proposed refactor
type BrowserEnvOptions = { host?: string, protocol?: "https:" | "http:", + pathname?: string, };const { host = "app.example.com", protocol = "https:", + pathname = "/", } = options;pathname: "/",- pathname: "/", + pathname,Also applies to: 25-50
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/e2e/tests/js/cookies.test.ts` around lines 7 - 23, Add a pathname option to the test environment options and thread it into the environment setup: update the BrowserEnvOptions type to include pathname?: string, default to "/" when not provided in setupBrowserCookieEnv, and use that value when building the BrowserEnv.location.pathname (and any derived href/origin if needed). Modify setupBrowserCookieEnv to accept and apply the pathname option so tests can initialize non-root window.location.pathname without manual mutation; keep BrowserEnv and existing callers working by falling back to "/".apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx (1)
1357-1365:clickCountsByReplayId.get(r.id)is called twice; extract to a variable.♻️ Proposed refactor
+ const clickCount = clickCountsByReplayId.get(r.id) ?? 0; <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> <DisplayDate date={r.lastEventAt} /> - {(clickCountsByReplayId.get(r.id) ?? 0) > 0 && ( + {clickCount > 0 && ( <span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70"> <CursorClickIcon className="h-3 w-3" /> - {clickCountsByReplayId.get(r.id)} + {clickCount} </span> )} </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx around lines 1357 - 1365, The code calls clickCountsByReplayId.get(r.id) twice; extract it to a local variable (e.g., const clickCount = clickCountsByReplayId.get(r.id) ?? 0) before the JSX and use clickCount in both the conditional and the rendered value so the map lookup is performed once; update the JSX that references clickCountsByReplayId.get(r.id) and the conditional to use clickCount, keeping DisplayDate and CursorClickIcon unchanged.packages/template/src/lib/stack-app/apps/implementations/session-replay.ts (1)
120-132: ExportedgetOrRotateSessionaccesseslocalStoragewithout a browser guardWhile
getOrRotateSessionis exported and callslocalStoragedirectly (line 121), all current internal call sites are protected by theisBrowserLike()guard inSessionRecorder.start()(line 173). The function is also not re-exported from the public API (index.ts), and no external imports exist in the codebase.However, since this is an exported function, adding a defensive guard follows best practices:
🛡️ Proposed fix
export function getOrRotateSession(options: { key: string, nowMs: number }): StoredSession { + if (typeof localStorage === "undefined") throwErr("getOrRotateSession called outside a browser context"); const existing = safeParseStoredSession(localStorage.getItem(options.key));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/template/src/lib/stack-app/apps/implementations/session-replay.ts` around lines 120 - 132, getOrRotateSession currently accesses localStorage directly which can throw in non-browser environments; update getOrRotateSession to guard access to localStorage (e.g., using the existing isBrowserLike check) and handle non-browser cases by returning a sensible fallback or throwing a clear error. Specifically, inside getOrRotateSession (and before calling safeParseStoredSession or localStorage.setItem) verify isBrowserLike() or equivalent, and if false return a new StoredSession object (or reject the rotation) so callers such as SessionRecorder.start remain safe when this exported function is used outside the browser.apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts (1)
559-587: LGTM — new tests correctly validatepausedAtGlobalMs = currentGlobalTimeMsForUifor all three pause-from modes.All three scenarios (
playing,gap_fast_forward,buffering) are covered with explicitcurrentGlobalTimeMsForUioverrides, making the expected source ofpausedAtGlobalMsunambiguous.Optional: the three new
.toBe()assertions on lines 563, 571, and 584 could be expressed as.toMatchInlineSnapshot()per project test guidelines.- expect(s.pausedAtGlobalMs).toBe(2500); + expect(s.pausedAtGlobalMs).toMatchInlineSnapshot(`2500`); - expect(s.pausedAtGlobalMs).toBe(3000); + expect(s.pausedAtGlobalMs).toMatchInlineSnapshot(`3000`); - expect(s.pausedAtGlobalMs).toBe(1500); + expect(s.pausedAtGlobalMs).toMatchInlineSnapshot(`1500`);Based on learnings: "Prefer .toMatchInlineSnapshot over other selectors in tests, and check/modify snapshot-serializer.ts for snapshot formatting."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts around lines 559 - 587, Replace the three explicit expect(...).toBe(...) assertions that check pausedAtGlobalMs in the tests "pauses from playing", "pauses from gap_fast_forward", and "pauses from buffering" with expect(...).toMatchInlineSnapshot() calls per project snapshot style; ensure the snapshot values reflect currentGlobalTimeMsForUi for each case and run the test suite to update generated inline snapshots, and if snapshot formatting differs, adjust snapshot-serializer.ts so the inline snapshot output matches project conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/backend/src/app/api/latest/analytics/events/batch/route.tsx`:
- Around line 69-90: This route currently inserts rows from body.events without
checking body.batch_id for prior ingestion; before calling
clickhouseClient.insert, look up the batch by batch_id using the same dedupe
mechanism used in the session-replays flow (e.g., call
sessionReplayChunk.findUnique({ where: { batchId: body.batch_id } }) or an
equivalent ClickHouse existence query) and if a record exists skip the insert
and return a successful response (or log and no-op) to avoid duplicate events on
retries; ensure you reference body.batch_id, rows, clickhouseClient.insert and
sessionReplayChunk.findUnique in the change so the dedupe check is clearly
adjacent to the insert.
- Around line 31-37: The events schema currently allows unbounded `data` via
`yupMixed()` (inside the `events` array schema used in route.tsx), which can
permit huge payloads when combined with up to `MAX_EVENTS`; add a defensive size
check: either constrain the `data` field (e.g., require a string/JSON with a max
byte/char length) or perform a request-level body-size guard like the
session-replays batch route by checking `fullReq.bodyBuffer.byteLength` against
`MAX_BODY_BYTES` before parsing/validating; update the validation logic that
constructs `events` (the `yupObject` containing `event_type`, `event_at_ms`,
`data`) to enforce the chosen limit and return a 413/validation error when
exceeded.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx:
- Around line 269-275: Update the JSX className that builds the timeline marker
(the cn(...) expression using isClick) to include the hover:transition-none
utility alongside transition-colors so hover-enter fades are disabled; locate
the cn call that currently contains "transition-colors" and add
"hover:transition-none" while keeping the conditional bg-blue-*/bg-emerald-*
classes unchanged.
- Around line 128-140: In formatEventTooltip(TimelineEvent) remove the
TypeScript casts and boolean-fallback: check at runtime that d.tag_name is a
string (e.g., d.tag_name != null && typeof d.tag_name === "string") and use that
value or the literal "element" via nullish-coalescing logic; similarly for
d.path and d.url ensure they are strings at runtime before using them and
combine them with ?? (not ||) to pick the first defined string or "/" as the
final path; preserve the existing truncation and return behavior but replace all
uses of (d.tag_name as string), || "element", and (d.path as string |
undefined)/(d.url as string | undefined) with these explicit runtime guards.
- Around line 475-495: Snapshot the current recordings IDs and guard the async
query with a cancelled flag so late responses cannot overwrite newer data:
inside the useEffect that references recordings and adminApp, create a local
cancelled boolean (let cancelled = false) and return a cleanup that sets
cancelled = true; capture const ids = recordings.map(r => r.id) before the async
call; inside runAsynchronously, after receiving res.result, build the Map only
for rows whose session_replay_id is a runtime-verified string (do not use "as
string") and only call setClickCountsByReplayId(map) if not cancelled; also
ensure you only populate entries for ids present in the local ids snapshot so
older queries cannot wipe counts for newer recordings (use the local ids set to
filter rows).
- Around line 1072-1105: The mapping callback should stop using (r: any) and
r.event_type as string and must safely parse per-row JSON without throwing;
define a local type/interface (e.g., ReplayEventRow with event_type: string,
event_at_ms: number|string, data?: string|Record<string, any>) and use that
instead of any, call adminApp.queryAnalytics inside the existing useEffect as
before, then map res.result as ReplayEventRow[] and for each row parse the data
field inside a try/catch (or use a safe-parse helper) to return {} on failure
while logging the parse error, convert event_at_ms to Number safely, and pass
objects to setTimelineEvents; this preserves non-fatal failures, removes 'as'
casts, and avoids silent swallowing by logging per-row parse issues while
keeping runAsynchronously({ noErrorLogging: true }) as-is.
In `@packages/template/src/lib/stack-app/apps/implementations/session-replay.ts`:
- Around line 278-283: Replace the non-null assertion on this._rrwebModule in
the snapshot block with a defensive null-check using the project's throwErr
helper: instead of this._rrwebModule!.record.takeFullSnapshot(), use
(this._rrwebModule ?? throwErr("rrweb module missing when attempting to take
full snapshot")).record.takeFullSnapshot(); keep the surrounding _takingSnapshot
true/false logic as-is and ensure the error message clearly states the context
(e.g., "rrweb module missing when taking full snapshot in emit callback").
- Around line 194-201: The _persistActivity function writes an updated
StoredSession (with last_activity_ms set to nowMs) to localStorage but
incorrectly returns the old stored object; change the function to return the
persisted object by returning updated instead of stored after the persistence
step (refer to _persistActivity, stored, updated, this._storageKey, and
last_activity_ms) so callers receive the freshest last_activity_ms value.
---
Outside diff comments:
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts:
- Around line 758-772: The pause transition currently unconditionally sets
pausedAtGlobalMs to state.currentGlobalTimeMsForUi which can be stale if we
entered buffering via SEEK; change the pause logic in the branch handling
isPlaying || isBuffering so pausedAtGlobalMs is set to the most recent
seek/buffer time (use state.bufferingAtGlobalMs when non-null, otherwise
state.currentGlobalTimeMsForUi) and do not overwrite an existing non-null
pausedAtGlobalMs from the SEEK path; update the assignment for pausedAtGlobalMs
in that branch accordingly (referencing pausedAtGlobalMs,
currentGlobalTimeMsForUi, bufferingAtGlobalMs, playbackMode).
---
Nitpick comments:
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx:
- Around line 1357-1365: The code calls clickCountsByReplayId.get(r.id) twice;
extract it to a local variable (e.g., const clickCount =
clickCountsByReplayId.get(r.id) ?? 0) before the JSX and use clickCount in both
the conditional and the rendered value so the map lookup is performed once;
update the JSX that references clickCountsByReplayId.get(r.id) and the
conditional to use clickCount, keeping DisplayDate and CursorClickIcon
unchanged.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts:
- Around line 559-587: Replace the three explicit expect(...).toBe(...)
assertions that check pausedAtGlobalMs in the tests "pauses from playing",
"pauses from gap_fast_forward", and "pauses from buffering" with
expect(...).toMatchInlineSnapshot() calls per project snapshot style; ensure the
snapshot values reflect currentGlobalTimeMsForUi for each case and run the test
suite to update generated inline snapshots, and if snapshot formatting differs,
adjust snapshot-serializer.ts so the inline snapshot output matches project
conventions.
In `@apps/e2e/tests/js/cookies.test.ts`:
- Around line 7-23: Add a pathname option to the test environment options and
thread it into the environment setup: update the BrowserEnvOptions type to
include pathname?: string, default to "/" when not provided in
setupBrowserCookieEnv, and use that value when building the
BrowserEnv.location.pathname (and any derived href/origin if needed). Modify
setupBrowserCookieEnv to accept and apply the pathname option so tests can
initialize non-root window.location.pathname without manual mutation; keep
BrowserEnv and existing callers working by falling back to "/".
In `@packages/stack-shared/src/interface/client-interface.ts`:
- Around line 253-298: The catch-all in sendSessionReplayBatch and
sendAnalyticsEventBatch hides typed KnownError (e.g., AnalyticsNotEnabled) by
wrapping exceptions into new Error, so update these methods to preserve
KnownError information: either call sendClientRequestAndCatchKnownError instead
of sendClientRequest (so KnownError is handled explicitly) or, if keeping
sendClientRequest, return Result.error(e instanceof Error ? e : new
Error(String(e))) without re-wrapping KnownError (i.e., don't create new Error
for KnownError instances) — ensure the catch distinguishes KnownError and
returns it directly to Result.error while only normalizing unknowns, referencing
sendSessionReplayBatch, sendAnalyticsEventBatch, sendClientRequest,
sendClientRequestAndCatchKnownError, KnownError, and AnalyticsNotEnabled.
In `@packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts`:
- Around line 261-278: The _tick method fires getAccessToken asynchronously and
then immediately reads this._lastKnownAccessToken, causing a one-tick stale auth
state; change _tick so it updates and uses the token result before making
decisions: call this._deps.getAccessToken() and await its result (or chain a
.then) inside _tick to set this._lastKnownAccessToken and compute hasAuth from
that fresh value, then perform the logout buffer clear and conditional flush
using the fresh hasAuth; update references to _wasAuthenticated, _events,
_approxBytes and _flush accordingly so they act on the current token state
rather than the previous tick.
- Around line 68-80: The code currently clears the event buffer inside _flush
before calling sendBatch which causes silent loss if sendBatch fails; modify
_flush so it does not mutate or drop this._events/_approxBytes until sendBatch
succeeds (or at least re-queues them on failure): capture the current batch
(e.g., const batch = this._events.slice() and bytes copy) and set this._events
and this._approxBytes to a fresh buffer only after sendBatch resolves, otherwise
restore or prepend the failed batch back into this._events and adjust
_approxBytes; also remove or reconsider using noErrorLogging for the
runAsynchronously calls around _flush so failures surface via the normal error
pipeline, referencing _pushEvent, _flush, sendBatch, and runAsynchronously to
locate the changes.
In `@packages/template/src/lib/stack-app/apps/implementations/session-replay.ts`:
- Around line 120-132: getOrRotateSession currently accesses localStorage
directly which can throw in non-browser environments; update getOrRotateSession
to guard access to localStorage (e.g., using the existing isBrowserLike check)
and handle non-browser cases by returning a sensible fallback or throwing a
clear error. Specifically, inside getOrRotateSession (and before calling
safeParseStoredSession or localStorage.setItem) verify isBrowserLike() or
equivalent, and if false return a new StoredSession object (or reject the
rotation) so callers such as SessionRecorder.start remain safe when this
exported function is used outside the browser.
| 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; | ||
| } |
There was a problem hiding this comment.
Replace as casts with runtime type guards; use ?? instead of ||.
Three as casts and one boolean-coercion fallback violate the project guidelines:
(d.tag_name as string)— no runtime guaranteetag_nameis astring; cast should be a type guard.|| "element"—||swallows empty string; use??per the explicit-null-check rule.(d.path as string | undefined)and(d.url as string | undefined)— same issue on line 135.
🔧 Proposed fix
function formatEventTooltip(event: TimelineEvent): string {
const d = event.data;
if (event.eventType === "$click") {
- const tag = (d.tag_name as string) || "element";
+ const tag = typeof d.tag_name === "string" ? d.tag_name : "element";
return `Clicked ${tag}`;
}
if (event.eventType === "$page-view") {
- const path = (d.path as string | undefined) ?? (d.url as string | undefined) ?? "/";
+ const path = (typeof d.path === "string" ? d.path : undefined)
+ ?? (typeof d.url === "string" ? d.url : undefined)
+ ?? "/";
const truncated = path.length > 30 ? path.slice(0, 27) + "..." : path;
return truncated;
}
return event.eventType;
}As per coding guidelines: "Do not use as, any, type casts, or other type system bypasses" and "Prefer explicit null/undefinedness checks over boolean checks (e.g., foo == null instead of !foo)".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
around lines 128 - 140, In formatEventTooltip(TimelineEvent) remove the
TypeScript casts and boolean-fallback: check at runtime that d.tag_name is a
string (e.g., d.tag_name != null && typeof d.tag_name === "string") and use that
value or the literal "element" via nullish-coalescing logic; similarly for
d.path and d.url ensure they are strings at runtime before using them and
combine them with ?? (not ||) to pick the first defined string or "/" as the
final path; preserve the existing truncation and return behavior but replace all
uses of (d.tag_name as string), || "element", and (d.path as string |
undefined)/(d.url as string | undefined) with these explicit runtime guards.
| className={cn( | ||
| "absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer", | ||
| "transition-colors", | ||
| isClick | ||
| ? "bg-blue-500/70 hover:bg-blue-400" | ||
| : "bg-emerald-500/70 hover:bg-emerald-400", | ||
| )} |
There was a problem hiding this comment.
Add hover:transition-none to avoid a hover-enter fade.
The "transition-colors" class alone applies a transition on both enter and exit. The rest of the file (e.g., the recording list buttons on line 1345) already follows the correct pattern.
🔧 Proposed fix
- "transition-colors",
+ "transition-colors hover:transition-none",As per coding guidelines: "For hover transitions, avoid hover-enter transitions and use only hover-exit transitions (e.g., transition-colors hover:transition-none)".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| className={cn( | |
| "absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer", | |
| "transition-colors", | |
| isClick | |
| ? "bg-blue-500/70 hover:bg-blue-400" | |
| : "bg-emerald-500/70 hover:bg-emerald-400", | |
| )} | |
| className={cn( | |
| "absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer", | |
| "transition-colors hover:transition-none", | |
| isClick | |
| ? "bg-blue-500/70 hover:bg-blue-400" | |
| : "bg-emerald-500/70 hover:bg-emerald-400", | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
around lines 269 - 275, Update the JSX className that builds the timeline marker
(the cn(...) expression using isClick) to include the hover:transition-none
utility alongside transition-colors so hover-enter fades are disabled; locate
the cn call that currently contains "transition-colors" and add
"hover:transition-none" while keeping the conditional bg-blue-*/bg-emerald-*
classes unchanged.
| useEffect(() => { | ||
| if (recordings.length === 0) return; | ||
| const ids = recordings.map(r => r.id); | ||
| runAsynchronously(async () => { | ||
| const res = await adminApp.queryAnalytics({ | ||
| query: `SELECT session_replay_id, count() as cnt | ||
| FROM default.events | ||
| WHERE event_type = '$click' | ||
| AND session_replay_id IN ({ids:Array(String)}) | ||
| GROUP BY session_replay_id`, | ||
| params: { ids }, | ||
| include_all_branches: false, | ||
| timeout_ms: 15000, | ||
| }); | ||
| const map = new Map<string, number>(); | ||
| for (const row of res.result) { | ||
| map.set(row.session_replay_id as string, Number(row.cnt)); | ||
| } | ||
| setClickCountsByReplayId(map); | ||
| }, { noErrorLogging: true }); | ||
| }, [recordings, adminApp]); |
There was a problem hiding this comment.
Race condition: stale query can overwrite complete data; also has an as cast.
Race condition: The recordings array grows with every pagination fetch. When a second page loads, this effect re-fires with the full ID list and starts a new query. If the earlier (smaller) query resolves last (network race), it calls setClickCountsByReplayId with a map that only covers the original IDs, silently wiping the counts for all newer recordings. The timeline-events effect (line 1077) avoids the same problem with a cancelled flag — apply the same pattern here.
as cast (line 491): row.session_replay_id as string bypasses the type system; use a runtime check.
🔧 Proposed fix
useEffect(() => {
if (recordings.length === 0) return;
const ids = recordings.map(r => r.id);
+ let cancelled = false;
runAsynchronously(async () => {
const res = await adminApp.queryAnalytics({
query: `SELECT session_replay_id, count() as cnt
FROM default.events
WHERE event_type = '$click'
AND session_replay_id IN ({ids:Array(String)})
GROUP BY session_replay_id`,
params: { ids },
include_all_branches: false,
timeout_ms: 15000,
});
+ if (cancelled) return;
const map = new Map<string, number>();
for (const row of res.result) {
- map.set(row.session_replay_id as string, Number(row.cnt));
+ const replayId = typeof row.session_replay_id === "string" ? row.session_replay_id : String(row.session_replay_id);
+ map.set(replayId, Number(row.cnt));
}
setClickCountsByReplayId(map);
}, { noErrorLogging: true });
+ return () => { cancelled = true; };
}, [recordings, adminApp]);As per coding guidelines: "Do not use as, any, type casts, or other type system bypasses".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
around lines 475 - 495, Snapshot the current recordings IDs and guard the async
query with a cancelled flag so late responses cannot overwrite newer data:
inside the useEffect that references recordings and adminApp, create a local
cancelled boolean (let cancelled = false) and return a cleanup that sets
cancelled = true; capture const ids = recordings.map(r => r.id) before the async
call; inside runAsynchronously, after receiving res.result, build the Map only
for rows whose session_replay_id is a runtime-verified string (do not use "as
string") and only call setClickCountsByReplayId(map) if not cancelled; also
ensure you only populate entries for ids present in the local ids snapshot so
older queries cannot wipe counts for newer recordings (use the local ids set to
filter rows).
| useEffect(() => { | ||
| if (!selectedRecordingId) { | ||
| setTimelineEvents([]); | ||
| return; | ||
| } | ||
| let cancelled = false; | ||
| setTimelineEvents([]); | ||
| runAsynchronously(async () => { | ||
| const res = await adminApp.queryAnalytics({ | ||
| query: `SELECT event_type, | ||
| toUnixTimestamp64Milli(event_at) as event_at_ms, | ||
| data | ||
| FROM default.events | ||
| WHERE session_replay_id = {id:String} | ||
| AND event_type IN ('$click', '$page-view') | ||
| ORDER BY event_at ASC | ||
| LIMIT 2000`, | ||
| params: { id: selectedRecordingId }, | ||
| include_all_branches: false, | ||
| timeout_ms: 15000, | ||
| }); | ||
| if (cancelled) return; | ||
| setTimelineEvents(res.result.map((r: any) => ({ | ||
| eventType: r.event_type as string, | ||
| eventAtMs: Number(r.event_at_ms), | ||
| data: typeof r.data === "string" | ||
| ? JSON.parse(r.data) | ||
| : (r.data ?? {}), | ||
| }))); | ||
| }, { noErrorLogging: true }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [selectedRecordingId, adminApp]); |
There was a problem hiding this comment.
any type, as cast, and silent JSON.parse failure in the timeline events effect.
Three issues in the result-mapping callback:
(r: any)—anytype with no explanatory comment (guideline: "Avoid theanytype; if necessary, leave a comment explaining why").r.event_type as string— bypasses the type system.JSON.parse(r.data)— if any row contains malformed JSON, the entireasyncfunction throws,runAsynchronouslycatches it, andnoErrorLogging: truesuppresses it completely. The result: all timeline markers silently disappear for that replay with no user feedback.
The data field should be parsed per-row with a safe fallback, and the any should be replaced with an explicit type annotation and runtime access.
🔧 Proposed fix
+ type AnalyticsEventRow = { event_type: unknown, event_at_ms: unknown, data: unknown };
setTimelineEvents(res.result.map((r: AnalyticsEventRow) => ({
- eventType: r.event_type as string,
+ eventType: typeof r.event_type === "string" ? r.event_type : String(r.event_type),
eventAtMs: Number(r.event_at_ms),
- data: typeof r.data === "string"
- ? JSON.parse(r.data)
- : (r.data ?? {}),
+ data: (() => {
+ if (typeof r.data !== "string") return (r.data != null && typeof r.data === "object") ? r.data as Record<string, unknown> : {};
+ try { const parsed = JSON.parse(r.data); return (parsed != null && typeof parsed === "object") ? parsed as Record<string, unknown> : {}; } catch { return {}; }
+ })(),
})));As per coding guidelines: "Avoid the any type", "Do not use as, any, type casts", and "Always carefully deal with loading and error states in frontend code; be explicit with these and never silently swallow errors".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
around lines 1072 - 1105, The mapping callback should stop using (r: any) and
r.event_type as string and must safely parse per-row JSON without throwing;
define a local type/interface (e.g., ReplayEventRow with event_type: string,
event_at_ms: number|string, data?: string|Record<string, any>) and use that
instead of any, call adminApp.queryAnalytics inside the existing useEffect as
before, then map res.result as ReplayEventRow[] and for each row parse the data
field inside a try/catch (or use a safe-parse helper) to return {} on failure
while logging the parse error, convert event_at_ms to Number safely, and pass
objects to setTimelineEvents; this preserves non-fatal failures, removes 'as'
casts, and avoids silent swallowing by logging per-row parse issues while
keeping runAsynchronously({ noErrorLogging: true }) as-is.
| 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; | ||
| } |
There was a problem hiding this comment.
_persistActivity returns stored instead of the just-persisted updated
After the debounce gate, the function builds updated (with last_activity_ms: nowMs), persists it to localStorage, but then returns the stale stored. The caller receives a StoredSession whose last_activity_ms does not reflect the value that was just written to storage.
Today's caller only reads session_id (which is identical in both objects), so rotation detection is unaffected. But the return type implies the caller gets the persisted value, and any future use of the returned last_activity_ms will silently read stale data.
🐛 Proposed fix
private _persistActivity(nowMs: number): StoredSession {
const stored = getOrRotateSession({ key: this._storageKey, nowMs });
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;
+ return updated;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 _persistActivity(nowMs: number): StoredSession { | |
| const stored = getOrRotateSession({ key: this._storageKey, nowMs }); | |
| 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 updated; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/template/src/lib/stack-app/apps/implementations/session-replay.ts`
around lines 194 - 201, The _persistActivity function writes an updated
StoredSession (with last_activity_ms set to nowMs) to localStorage but
incorrectly returns the old stored object; change the function to return the
persisted object by returning updated instead of stored after the persistence
step (refer to _persistActivity, stored, updated, this._storageKey, and
last_activity_ms) so callers receive the freshest last_activity_ms value.
| this._takingSnapshot = true; | ||
| try { | ||
| this._rrwebModule!.record.takeFullSnapshot(); | ||
| } finally { | ||
| this._takingSnapshot = false; | ||
| } |
There was a problem hiding this comment.
Replace non-null assertion with throwErr per coding guidelines
this._rrwebModule! on line 280 uses a non-null assertion. Even though it is safe at runtime (the emit callback only runs after _rrwebModule is assigned), the guideline requires explicit ?? throwErr(...) with a descriptive error message instead of !.
♻️ Proposed fix
- this._rrwebModule!.record.takeFullSnapshot();
+ (this._rrwebModule ?? throwErr("rrweb module not loaded when trying to take a full snapshot")).record.takeFullSnapshot();As per coding guidelines: "Code defensively and prefer ?? throwErr(...) over non-null assertions with good error messages."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| this._takingSnapshot = true; | |
| try { | |
| this._rrwebModule!.record.takeFullSnapshot(); | |
| } finally { | |
| this._takingSnapshot = false; | |
| } | |
| this._takingSnapshot = true; | |
| try { | |
| (this._rrwebModule ?? throwErr("rrweb module not loaded when trying to take a full snapshot")).record.takeFullSnapshot(); | |
| } finally { | |
| this._takingSnapshot = false; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/template/src/lib/stack-app/apps/implementations/session-replay.ts`
around lines 278 - 283, Replace the non-null assertion on this._rrwebModule in
the snapshot block with a defensive null-check using the project's throwErr
helper: instead of this._rrwebModule!.record.takeFullSnapshot(), use
(this._rrwebModule ?? throwErr("rrweb module missing when attempting to take
full snapshot")).record.takeFullSnapshot(); keep the surrounding _takingSnapshot
true/false logic as-is and ensure the error message clearly states the context
(e.g., "rrweb module missing when taking full snapshot in emit callback").
| private _lastBrowserSessionId: string | null = null; | ||
| private _takingSnapshot = false; | ||
| private _flushInProgress = false; |
There was a problem hiding this comment.
fixes replays bug where some replays are unable to play because they are missing an rrweb full snapshot
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md -->
https://www.loom.com/share/09a89533039d4bd4814332ec0728a30f
Summary by CodeRabbit