From 5a7c282e03341d981501fbc5bb9fcbfc53ce3be6 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 10:29:52 -0700 Subject: [PATCH 1/3] Remove auth gate from session replay recorder The gate delayed flushes until an access token resolved, but sendBatch already resolves the session itself via _getSession() at send time, so _lastKnownAccessToken was a redundant readiness check that caused head-of-session events to be silently dropped on slow auth init and suppressed uploads entirely when token fetch failed. --- .../apps/implementations/client-app-impl.ts | 1 - .../apps/implementations/session-replay.ts | 16 +++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 12386ae464..678df8c395 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -556,7 +556,6 @@ export class _StackClientAppImplIncomplete { return await this._interface.sendSessionReplayBatch(body, await this._getSession(), opts); }, diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index 2eb497bca6..33ed6202bd 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -130,7 +130,6 @@ export function getOrRotateSession(options: { key: string, nowMs: number }): Sto export type SessionRecorderDeps = { projectId: string, - getAccessToken: () => Promise, sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, }; @@ -148,7 +147,6 @@ export class SessionRecorder { private _lastBrowserSessionId: string | null = null; private _takingSnapshot = false; private _flushInProgress = false; - private _lastKnownAccessToken: string | null = null; private readonly _sessionReplaySegmentId: string; private readonly _storageKey: string; private readonly _deps: SessionRecorderDeps; @@ -172,7 +170,7 @@ export class SessionRecorder { // Kick off rrweb recording runAsynchronously(() => this._startRecording(), { noErrorLogging: true }); - // Periodic flush + token refresh + // Periodic flush this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS); } @@ -202,7 +200,6 @@ export class SessionRecorder { } private async _flush(options: { keepalive: boolean }) { - if (!this._lastKnownAccessToken) return; if (this._events.length === 0) return; // Prevent concurrent in-flight HTTP requests. When a flush is already // in-flight, a second batch could race on the server (both call @@ -324,15 +321,8 @@ export class SessionRecorder { private _tick() { if (this._cancelled) return; - - // Refresh the cached access token (async, fire-and-forget for this tick) - runAsynchronously(async () => { - this._lastKnownAccessToken = await this._deps.getAccessToken(); - }, { noErrorLogging: true }); - - const hasAuth = !!this._lastKnownAccessToken; - if (hasAuth && this._events.length > 0) { - runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); + if (this._events.length > 0) { + runAsynchronously(() => this._flush({ keepalive: false })); } } } From ff20721c7378de0065785160d40235590ede097d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 10:57:35 -0700 Subject: [PATCH 2/3] Remove auth gate from EventTracker Same pattern as SessionRecorder: sendBatch already resolves the session via _getSession() at send time, so the _lastKnownAccessToken readiness check was redundant and delayed the first batch by an extra tick. getAnalyticsAccessToken is now unused and removed from the construction site. --- .../stack-app/apps/implementations/client-app-impl.ts | 7 +------ .../apps/implementations/event-tracker.test.ts | 10 +++------- .../stack-app/apps/implementations/event-tracker.ts | 11 +---------- packages/template/src/lib/stack-app/common.ts | 2 +- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 678df8c395..7e650060a7 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -548,10 +548,6 @@ export class _StackClientAppImplIncomplete => { - this._ensurePersistentTokenStore(); - return await (await this.getUser({ or: "anonymous" })).getAccessToken(); - }; if (isBrowserLike() && this._analyticsOptions?.replays?.enabled === true) { this._sessionRecorder = new SessionRecorder({ @@ -566,7 +562,6 @@ export class _StackClientAppImplIncomplete { return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), opts); }, @@ -2675,7 +2670,7 @@ export class _StackClientAppImplIncomplete { const sentBodies: string[] = []; const tracker = new EventTracker({ projectId: "internal", - getAccessToken: async () => "access-token", sendBatch: async (body) => { sentBodies.push(body); return Result.ok(new Response()); @@ -56,7 +53,7 @@ describe("EventTracker", () => { clientY: 34, })); - await advancePastAccessTokenRefresh(); + await advancePastFlush(); expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` [ @@ -79,7 +76,6 @@ describe("EventTracker", () => { const sentBodies: string[] = []; const tracker = new EventTracker({ projectId: "internal", - getAccessToken: async () => "access-token", sendBatch: async (body) => { sentBodies.push(body); return Result.ok(new Response()); @@ -90,7 +86,7 @@ describe("EventTracker", () => { tracker.start(); window.history.pushState({}, "", "/projects/test-project"); - await advancePastAccessTokenRefresh(); + await advancePastFlush(); expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` [ diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index c46a652d29..72b5d0a079 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -29,7 +29,6 @@ function hasHistoryMethods(value: unknown): value is { pushState: History["pushS export type EventTrackerDeps = { projectId: string, - getAccessToken: () => Promise, sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, }; @@ -46,7 +45,6 @@ export class EventTracker { private _flushTimer: ReturnType | null = null; private _events: TrackedEvent[] = []; private _approxBytes = 0; - private _lastKnownAccessToken: string | null = null; private _lastUrl: string | null = null; private readonly _sessionReplaySegmentId: string; private readonly _deps: EventTrackerDeps; @@ -265,7 +263,6 @@ export class EventTracker { } private async _flush(options: { keepalive: boolean }) { - if (!this._lastKnownAccessToken) return; if (this._events.length === 0) return; const nowMs = Date.now(); @@ -298,13 +295,7 @@ export class EventTracker { private _tick() { if (this._cancelled) return; - - runAsynchronously(async () => { - this._lastKnownAccessToken = await this._deps.getAccessToken(); - }, { noErrorLogging: true }); - - const hasAuth = !!this._lastKnownAccessToken; - if (hasAuth && this._events.length > 0) { + if (this._events.length > 0) { runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); } } diff --git a/packages/template/src/lib/stack-app/common.ts b/packages/template/src/lib/stack-app/common.ts index 331db26ef6..58b7d740a2 100644 --- a/packages/template/src/lib/stack-app/common.ts +++ b/packages/template/src/lib/stack-app/common.ts @@ -52,7 +52,7 @@ export type ConvexCtx = export type GetCurrentPartialUserOptions = & { - or?: 'return-null' | 'anonymous', // note: unlike normal getUser, 'anonymous' still returns null sometimes (eg. if no token is present) + or?: 'return-null' | 'anonymous-if-exists', // note: unlike normal getUser, 'anonymous' still returns null sometimes (eg. if no token is present) tokenStore?: TokenStoreInit, } & ( From dbd9b8d63d260ad59b51d37fdd243cc30b6e9af9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 11:32:40 -0700 Subject: [PATCH 3/3] Resolve analytics session before sending batches Use getPartialUser to detect a cached token; fall back to getUser({ or: 'anonymous' }) so anonymous sign-up happens in the analytics path itself rather than relying on a separate access-token gate. Also drop the `noErrorLogging: true` flags from the _flush calls so unexpected failures surface instead of being swallowed. --- examples/demo/src/app/token-staleness/page.tsx | 2 +- .../apps/implementations/client-app-impl.ts | 14 ++++++++++++-- .../apps/implementations/event-tracker.ts | 8 ++++---- .../apps/implementations/session-replay.ts | 6 +++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/examples/demo/src/app/token-staleness/page.tsx b/examples/demo/src/app/token-staleness/page.tsx index 587ef551f5..dba232a47a 100644 --- a/examples/demo/src/app/token-staleness/page.tsx +++ b/examples/demo/src/app/token-staleness/page.tsx @@ -23,7 +23,7 @@ export default function TokenStalenessPage() { const [newDisplayName, setNewDisplayName] = useState(''); // Get partial user from token (can be stale compared to actual user data) - const partialUserFromToken = app.usePartialUser({ from: 'token', or: 'anonymous' }); + const partialUserFromToken = app.usePartialUser({ from: 'token', or: 'anonymous-if-exists' }); // Get raw tokens const tokens = user?.currentSession.useTokens(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 7e650060a7..f4b4f91c97 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -549,11 +549,21 @@ export class _StackClientAppImplIncomplete => { + this._ensurePersistentTokenStore(); + const partialUser = await this.getPartialUser({ from: 'token', or: 'anonymous-if-exists' }); + if (partialUser) { + return await this._getSession(); + } + const anonUser = await this.getUser({ or: "anonymous" }); + return anonUser._internalSession; + }; + if (isBrowserLike() && this._analyticsOptions?.replays?.enabled === true) { this._sessionRecorder = new SessionRecorder({ projectId: this.projectId, sendBatch: async (body, opts) => { - return await this._interface.sendSessionReplayBatch(body, await this._getSession(), opts); + return await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts); }, }, this._analyticsOptions.replays); this._sessionRecorder.start(); @@ -563,7 +573,7 @@ export class _StackClientAppImplIncomplete { - return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), opts); + return await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts); }, }); this._eventTracker.start(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 72b5d0a079..1e2b0a1115 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -84,7 +84,7 @@ export class EventTracker { clearInterval(this._flushTimer); this._flushTimer = null; } - runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: true })); this._teardown(); } @@ -97,7 +97,7 @@ export class EventTracker { this._events.push(event); this._approxBytes += JSON.stringify(event).length; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { - runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: false })); } } @@ -224,7 +224,7 @@ export class EventTracker { } private readonly _onPageHide = () => { - runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: true })); }; private _setupPageHideListeners() { @@ -296,7 +296,7 @@ export class EventTracker { private _tick() { if (this._cancelled) return; if (this._events.length > 0) { - runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: false })); } } } diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index 33ed6202bd..be3cf89d8a 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -181,7 +181,7 @@ export class SessionRecorder { this._flushTimer = null; } // Flush remaining events before cleanup - runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: true })); this._stopCurrentRecording(); } @@ -284,7 +284,7 @@ export class SessionRecorder { this._events.push(event); this._approxBytes += JSON.stringify(event).length; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { - runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: false })); } }, maskAllInputs: this._replayOptions.maskAllInputs ?? true, @@ -295,7 +295,7 @@ export class SessionRecorder { this._recording = true; const onPageHide = () => { - runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + runAsynchronously(() => this._flush({ keepalive: true })); }; window.addEventListener("pagehide", onPageHide); document.addEventListener("visibilitychange", onPageHide);