Skip to content

analytics: replays event markers#1210

Merged
BilalG1 merged 16 commits intodevfrom
analytics-replays-event-markers
Feb 19, 2026
Merged

analytics: replays event markers#1210
BilalG1 merged 16 commits intodevfrom
analytics-replays-event-markers

Conversation

@BilalG1
Copy link
Copy Markdown
Collaborator

@BilalG1 BilalG1 commented Feb 18, 2026

https://www.loom.com/share/09a89533039d4bd4814332ec0728a30f

Summary by CodeRabbit

  • New Features
    • Timeline in replay analytics now displays visual markers for user interactions, including clicks and page views
    • Replay list shows per-recording click count with icon indicator for quick reference

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-backend Ready Ready Preview, Comment Feb 19, 2026 3:40am
stack-dashboard Ready Ready Preview, Comment Feb 19, 2026 3:40am
stack-demo Ready Ready Preview, Comment Feb 19, 2026 3:40am
stack-docs Ready Ready Preview, Comment Feb 19, 2026 3:40am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 18, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
Dashboard Timeline Visualization
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
Added TimelineEvent and TimelineMarker types; introduced formatEventTooltip() function for event label rendering; extended Timeline component to accept and display markers with hover tooltips; added state management for click counts and timeline events; integrated analytics queries to fetch $click and $page-view events for selected recordings; now displays per-replay click counts in replay list UI.
Analytics Token Resolution
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Introduced shared getAnalyticsAccessToken() resolver ensuring persistent token store access; refactored SessionRecorder and EventTracker to use centralized token resolution; added buffer cleanup in _signOut() to prevent cross-user event leakage.
Event Tracker Buffer Management
packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts
Removed pre-auth buffer size limits (MAX_PREAUTH_BUFFER_EVENTS, MAX_PREAUTH_BUFFER_BYTES) and _wasAuthenticated flag; added public clearBuffer() method for explicit buffer reset.
Session Replay Concurrency & Rotation
packages/template/src/lib/stack-app/apps/implementations/session-replay.ts
Added concurrency guards (_flushInProgress) and session rotation tracking (_lastBrowserSessionId); detects session rotation on event emission and injects FullSnapshot when rotation occurs; _persistActivity() now manages session state and early returns for existing sessions; added public clearBuffer() method; removed pre-auth buffer caps and logout-based buffer clearing.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • session replays #1187: Modifies the same replays analytics page component with timeline markers, click-count state, and icon additions directly related to session replays feature enhancement.
  • analytics: replays event markers #1210: Both PRs modify apps/.../analytics/replays/page-client.tsx, adding timeline event/marker types and fetching $click/$page-view events for replay visualization.

Suggested reviewers

  • N2D4

Poem

🐰 Hops through events with markers so bright,
Timeline clicks now glow in replay light,
Sessions rotate, snapshots stand guard,
Tokens flow shared, concurrency's not hard—
Analytics bloom where buffers once grew!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The pull request description is empty except for a reminder comment about CONTRIBUTING.md guidelines and contains no substantive information about the changes. Add a proper pull request description explaining the purpose, changes, and context. Follow the repository's description template and guidelines from CONTRIBUTING.md to document the feature being implemented.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'analytics: replays event markers' clearly summarizes the main change—adding event markers functionality to replay analytics. It's concise, specific, and aligns with the changeset's core objective of displaying click and page-view events as markers on the replay timeline.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch analytics-replays-event-markers

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 18, 2026

Greptile Summary

This PR adds analytics event tracking (page views and clicks) to the session replays feature, with event markers displayed on the replay timeline. Key changes:

  • New EventTracker client-side class that captures $page-view and $click events, buffers them, and flushes to a new backend endpoint. Currently scoped to the internal project only.
  • New /api/v1/analytics/events/batch endpoint that validates and inserts events into ClickHouse with proper auth checks, session replay correlation, and schema validation via createSmartRouteHandler.
  • Dashboard timeline markers showing click and page-view events as colored markers on the replay timeline, with tooltips and click-to-seek.
  • Click counts displayed per replay in the sidebar list.
  • Refactored findRecentSessionReplay into a shared utility used by both session replay and analytics event handlers.
  • Breaking change: Session replay batch endpoint now throws AnalyticsNotEnabled error instead of silently returning a 200 no-op when analytics is disabled.
  • Bug fix: pausedAtGlobalMs now correctly set from currentGlobalTimeMsForUi when pausing the replay.
  • SessionRecorder improvements: Added _flushInProgress guard to prevent concurrent flush races, and session rotation snapshot injection.
  • Analytics routing: Added support for a separate analytics base URL (r.stack-auth.com) for production traffic.

Confidence Score: 3/5

  • Mostly safe to merge; the new EventTracker has a missing concurrency guard that should be addressed to match SessionRecorder's pattern.
  • The PR is well-structured with proper auth validation, schema checks, and comprehensive E2E tests. However, the EventTracker._flush method is missing the _flushInProgress guard that was explicitly added to SessionRecorder in this same PR to prevent race conditions. The dashboard JSON.parse without error handling could also silently break timeline rendering. These are not critical blockers but should be addressed for robustness.
  • Pay close attention to packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts (missing flush guard) and apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx (unguarded JSON.parse and redundant queries on pagination).

Important Files Changed

Filename Overview
packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts New EventTracker class for capturing $page-view and $click events. Missing concurrent flush guard (_flushInProgress) that was added to SessionRecorder in this same PR.
apps/backend/src/app/api/latest/analytics/events/batch/route.tsx New SmartRouteHandler endpoint for ingesting analytics events into ClickHouse. Well-structured with proper auth checks and schema validation.
apps/backend/src/lib/session-replays.tsx Clean refactor: extracts findRecentSessionReplay into a shared utility, now used by both session-replays and analytics-events batch handlers.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx Adds timeline event markers UI, click counts per replay, and event querying. JSON.parse of event data lacks error handling; click count query re-fires on pagination.
apps/backend/scripts/clickhouse-migrations.ts Adds a second EVENTS_VIEW_SQL execution after replay columns are added so the view picks up the new columns. Correct ordering.
packages/template/src/lib/stack-app/apps/implementations/session-replay.ts Adds _flushInProgress guard, session rotation snapshot injection, and exports several utility functions. Solid improvements.
packages/stack-shared/src/interface/client-interface.ts Adds getAnalyticsBaseUrl, sendAnalyticsEventBatch, and apiUrlOverride parameter to support routing analytics traffic to a separate domain.
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts Integrates EventTracker for internal project only. Adds sendAnalyticsEventBatch to app internals interface.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: e7a050a

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

pausedAtGlobalMs may be stale when pausing from buffering entered via SEEK

When SEEK takes the buffering path (lines 895–910), currentGlobalTimeMsForUi is not updated — only pausedAtGlobalMs is set to the seek target. If the user presses pause before the next TICK reconciles currentGlobalTimeMsForUi (normally within ~16 ms), pausedAtGlobalMs will 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 swallows KnownError type information.

sendClientRequest can throw typed KnownError instances (e.g., AnalyticsNotEnabled). The catch-all on lines 272–274 / 296–297 wraps them as plain Error in Result.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 AnalyticsNotEnabled specifically (e.g., to stop sending further batches), consider using sendClientRequestAndCatchKnownError instead, or at least preserving the original error object rather than re-wrapping it as new 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: getAccessToken result is asynchronous but hasAuth is read synchronously — one-tick lag on auth state changes.

_tick fires getAccessToken via runAsynchronously (fire-and-forget) and then immediately reads this._lastKnownAccessToken synchronously. 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:

  1. Events from the new user could be flushed with the old user's stale token (server rejects).
  2. On the next tick, _wasAuthenticated && !hasAuth clears 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. If sendBatch fails, those events are permanently discarded with only a console.warn. This is a standard trade-off in analytics SDKs (no guaranteed delivery), but it's worth noting as a conscious design choice — especially since noErrorLogging: true on the runAsynchronously calls 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 making pathname configurable in BrowserEnvOptions

pathname is now part of BrowserEnv but there is no corresponding option in BrowserEnvOptions, so all tests that call setupBrowserCookieEnv are locked to "/". Since the new EventTracker uses window.location.pathname for page-view events, future tests that need to assert page-view tracking on non-root paths will need to mutate location.pathname manually 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: Exported getOrRotateSession accesses localStorage without a browser guard

While getOrRotateSession is exported and calls localStorage directly (line 121), all current internal call sites are protected by the isBrowserLike() guard in SessionRecorder.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 validate pausedAtGlobalMs = currentGlobalTimeMsForUi for all three pause-from modes.

All three scenarios (playing, gap_fast_forward, buffering) are covered with explicit currentGlobalTimeMsForUi overrides, making the expected source of pausedAtGlobalMs unambiguous.

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.

Comment on lines +128 to +140
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 guarantee tag_name is a string; 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.

Comment on lines +269 to +275
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",
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +475 to +495
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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).

Comment on lines +1072 to +1105
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

any type, as cast, and silent JSON.parse failure in the timeline events effect.

Three issues in the result-mapping callback:

  1. (r: any)any type with no explanatory comment (guideline: "Avoid the any type; if necessary, leave a comment explaining why").
  2. r.event_type as string — bypasses the type system.
  3. JSON.parse(r.data) — if any row contains malformed JSON, the entire async function throws, runAsynchronously catches it, and noErrorLogging: true suppresses 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.

Comment on lines +194 to 201
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

_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.

Suggested change
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.

Comment on lines +278 to +283
this._takingSnapshot = true;
try {
this._rrwebModule!.record.takeFullSnapshot();
} finally {
this._takingSnapshot = false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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").

Comment on lines +151 to +153
private _lastBrowserSessionId: string | null = null;
private _takingSnapshot = false;
private _flushInProgress = false;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixes replays bug where some replays are unable to play because they are missing an rrweb full snapshot

@BilalG1 BilalG1 requested a review from N2D4 February 18, 2026 04:42
@BilalG1 BilalG1 assigned N2D4 and unassigned BilalG1 Feb 18, 2026
@BilalG1 BilalG1 changed the title Analytics replays event markers analytics: replays event markers Feb 18, 2026
@github-actions github-actions bot assigned BilalG1 and unassigned N2D4 Feb 18, 2026
@BilalG1 BilalG1 merged commit 7f0063f into dev Feb 19, 2026
18 of 26 checks passed
@BilalG1 BilalG1 deleted the analytics-replays-event-markers branch February 19, 2026 03:33
@coderabbitai coderabbitai bot mentioned this pull request Feb 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants