Skip to content

Commit bc91efb

Browse files
committed
fix(analytics): skip the identity lookup when no token is set
On a public page the analytics module still issued a `User/me` at client construction: `trackInitializationEvent` queues an event, the processor flushes the first batch immediately, and `flush` awaits `getSessionContext`, which calls `auth.me()` unconditionally. With no session that request can only answer 401, and the browser logs it to the console from the network layer -- before any handler in the SDK sees the rejection. The existing `.catch` in `getSessionContext` and the try/catch in `flush` already keep the rejection from escaping, so no amount of additional catching removes what users actually see. Skip the lookup when no access token is set. Anonymous events already reported `user_id: null` through that same `.catch`, so no analytics data changes -- the request is dropped only in the case where its sole possible outcome was a 401. The dedupe from #245 is unaffected: when a token is present, analytics still resolves through `auth.me()` and shares the app's in-flight request, so an authenticated cold load continues to issue exactly one `User/me`. Token presence is tracked in the auth module rather than read off `axios.defaults`, so it follows the identity transitions that already exist there (`setToken`, `logout`) and is seeded from the constructor token, which is how the server-side SDK carries a session it never sets explicitly. The skip is deliberately not memoized: a visitor who logs in mid-session must start resolving an identity again. This does not cover a stale or expired token, which still 401s. Removing that one needs `User/me` to answer 200 with a null user instead of 401, which is a backend change. Tests: the two analytics tests that exercise identity resolution now build a token-bearing client; the axios mock gains the `defaults` that `setToken` and `logout` write through. Three new tests cover the anonymous skip, resolution resuming after `setToken`, and token presence across identity changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtKFfzaVR6nWAi8tNvJqfw
1 parent 24bc26f commit bc91efb

5 files changed

Lines changed: 92 additions & 0 deletions

File tree

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
150150
{
151151
appBaseUrl: normalizedAppBaseUrl,
152152
serverUrl,
153+
token,
153154
}
154155
);
155156

src/modules/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,14 @@ async function getSessionContext(
332332
userAuthModule: AuthModule
333333
): Promise<SessionContext> {
334334
if (!analyticsSharedState.sessionContext) {
335+
// With no token there is no identity to resolve: `me()` can only answer 401,
336+
// which the browser logs to the console before any handler here sees it. On
337+
// a public page that request is the sole reason an error appears, so skip
338+
// it. This is not memoized — a visitor who logs in later must still resolve.
339+
if (!userAuthModule.hasToken()) {
340+
return { user_id: null, session_id: getAnalyticsSessionId() };
341+
}
342+
335343
if (!sessionContextPromise) {
336344
const sessionId = getAnalyticsSessionId();
337345
sessionContextPromise = userAuthModule

src/modules/auth.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,16 @@ export function createAuthModule(
108108
pendingMe = null;
109109
};
110110

111+
// Tracked here rather than read off `axios.defaults` so the answer stays tied
112+
// to the identity transitions below (`setToken`, `logout`) instead of to the
113+
// header a caller may have set on the instance directly.
114+
let hasAccessToken = Boolean(options.token);
115+
111116
return {
117+
hasToken() {
118+
return hasAccessToken;
119+
},
120+
112121
// Get current user information
113122
async me() {
114123
const request: Promise<User> =
@@ -189,6 +198,7 @@ export function createAuthModule(
189198
// flight would otherwise resolve into callers that run after the logout.
190199
clearPendingMe();
191200
resetAnalyticsSessionContext();
201+
hasAccessToken = false;
192202

193203
// Only do the rest if in a browser environment
194204
if (typeof window !== "undefined") {
@@ -220,6 +230,7 @@ export function createAuthModule(
220230
// resolved for the previous one must not be handed to later callers.
221231
clearPendingMe();
222232
resetAnalyticsSessionContext();
233+
hasAccessToken = true;
223234

224235
// handle token change for axios clients
225236
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;

src/modules/auth.types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export interface AuthModuleOptions {
100100
serverUrl: string;
101101
/** Base URL for the app (used for login redirects). */
102102
appBaseUrl: string;
103+
/**
104+
* Access token the client was constructed with, if any. Seeds the module's
105+
* view of whether a session exists before {@link AuthModule.setToken} runs,
106+
* which is how the server-side SDK reports a token it never sets explicitly.
107+
*/
108+
token?: string;
103109
}
104110

105111
/**
@@ -120,6 +126,17 @@ export interface AuthModuleOptions {
120126
* The auth module is only available in user authentication mode (`base44.auth`).
121127
*/
122128
export interface AuthModule {
129+
/**
130+
* Whether an access token is currently set on the client.
131+
*
132+
* Reports only the presence of a token, never its validity — an expired or
133+
* revoked token still reads as `true`. Callers use this to skip requests that
134+
* could not succeed without a session, not to decide that one is valid.
135+
*
136+
* @internal
137+
*/
138+
hasToken(): boolean;
139+
123140
/**
124141
* Gets the current authenticated user's information.
125142
*

tests/unit/analytics.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ describe("Analytics Module", () => {
2626
createAxiosClient: vi.fn().mockImplementation(
2727
() =>
2828
({
29+
// `setToken` and `logout` write through to these, so the mock needs
30+
// them present per instance.
31+
defaults: { headers: { common: {} as Record<string, string> } },
2932
request: vi.fn().mockResolvedValue({
3033
status: 200,
3134
data: {
@@ -54,9 +57,12 @@ describe("Analytics Module", () => {
5457
heartBeatInterval: undefined,
5558
};
5659

60+
// Token-bearing by default: most tests here exercise the flush path that
61+
// resolves an identity, and that lookup is skipped without a session.
5762
base44 = createClient({
5863
serverUrl,
5964
appId,
65+
token: "test-access-token",
6066
});
6167
});
6268

@@ -126,6 +132,55 @@ describe("Analytics Module", () => {
126132
expect(heartBeatState.isHeartBeatProcessing).toBeFalsy();
127133
});
128134

135+
test("should not resolve an identity when no token is set", async () => {
136+
resetAnalyticsSessionContext();
137+
138+
const anonymous = createClient({ serverUrl, appId });
139+
const me = vi.spyOn(anonymous.auth, "me");
140+
141+
anonymous.analytics.track({ eventName: "public-page-event" });
142+
await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0));
143+
144+
// The whole point: on a public page `me()` can only answer 401, and the
145+
// browser logs that to the console before any handler here sees it. The
146+
// event still flushes -- anonymous events already reported user_id: null.
147+
expect(me).not.toHaveBeenCalled();
148+
149+
anonymous.cleanup();
150+
});
151+
152+
test("should resolve an identity once a token is set", async () => {
153+
resetAnalyticsSessionContext();
154+
155+
const anonymous = createClient({ serverUrl, appId });
156+
const me = vi
157+
.spyOn(anonymous.auth, "me")
158+
.mockResolvedValue({ id: "user-1" } as User);
159+
160+
// A visitor who logs in mid-session must start reporting their identity, so
161+
// the skip above must not be memoized.
162+
anonymous.auth.setToken("token-acquired-after-login", false);
163+
anonymous.analytics.track({ eventName: "post-login-event" });
164+
165+
await vi.waitFor(() => expect(me).toHaveBeenCalled());
166+
167+
anonymous.cleanup();
168+
});
169+
170+
test("should report token presence across identity changes", () => {
171+
const client = createClient({ serverUrl, appId });
172+
173+
expect(client.auth.hasToken()).toBe(false);
174+
175+
client.auth.setToken("some-token", false);
176+
expect(client.auth.hasToken()).toBe(true);
177+
178+
client.auth.logout();
179+
expect(client.auth.hasToken()).toBe(false);
180+
181+
client.cleanup();
182+
});
183+
129184
test("should track multiple events", async () => {
130185
vi.useFakeTimers();
131186

0 commit comments

Comments
 (0)