From 9a23140880cf48cf9bd506dc6fd6e2d1b0837b0e Mon Sep 17 00:00:00 2001 From: liorma Date: Wed, 9 Sep 2026 18:07:47 +0300 Subject: [PATCH 01/12] feat: add browser experiments with auth-safe exposure tracking --- .../appended-articles.json | 3 + .../types-to-expose.json | 2 + src/client.ts | 14 ++ src/client.types.ts | 9 +- src/index.ts | 5 + src/modules/analytics.ts | 7 +- src/modules/auth.ts | 32 +++- src/modules/auth.types.ts | 9 + src/modules/experiment-exposures.ts | 62 ++++++ src/modules/experiments-runtime.types.ts | 25 +++ src/modules/experiments.ts | 175 +++++++++++++++++ src/modules/experiments.types.ts | 99 ++++++++++ tests/types/experiments.types.ts | 17 ++ tests/unit/auth-identity.test.ts | 177 ++++++++++++++++++ tests/unit/experiment-exposures.test.ts | 127 +++++++++++++ tests/unit/experiments-auth.test.ts | 103 ++++++++++ tests/unit/experiments.test.ts | 168 +++++++++++++++++ 17 files changed, 1029 insertions(+), 5 deletions(-) create mode 100644 src/modules/experiment-exposures.ts create mode 100644 src/modules/experiments-runtime.types.ts create mode 100644 src/modules/experiments.ts create mode 100644 src/modules/experiments.types.ts create mode 100644 tests/types/experiments.types.ts create mode 100644 tests/unit/auth-identity.test.ts create mode 100644 tests/unit/experiment-exposures.test.ts create mode 100644 tests/unit/experiments-auth.test.ts create mode 100644 tests/unit/experiments.test.ts diff --git a/scripts/mintlify-post-processing/appended-articles.json b/scripts/mintlify-post-processing/appended-articles.json index 27fb69b8..d9eb3c3b 100644 --- a/scripts/mintlify-post-processing/appended-articles.json +++ b/scripts/mintlify-post-processing/appended-articles.json @@ -1,4 +1,7 @@ { + "interfaces/ExperimentsModule": [ + "interfaces/ExperimentsSnapshot" + ], "interfaces/ConnectorsModule": [ "type-aliases/ConnectorIntegrationType", "interfaces/ConnectorIntegrationTypeRegistry", diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 5d7ef016..7a9f4571 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -22,6 +22,8 @@ "EntityHandler", "EntityRecord", "EntityTypeRegistry", + "ExperimentsModule", + "ExperimentsSnapshot", "FunctionName", "FunctionNameRegistry", "FunctionsModule", diff --git a/src/client.ts b/src/client.ts index fe0c9834..cb34a05a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,6 +23,8 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { createExperimentsModule } from "./modules/experiments.js"; +import { createExposureTracker } from "./modules/experiment-exposures.js"; import { createActorsModule, resolveActorsHost, @@ -166,6 +168,15 @@ export function createClient(config: CreateClientConfig): Base44Client { headers, }); + const experiments = createExperimentsModule({ + getAuth: () => userAuthModule, + trackExposure: createExposureTracker({ + axiosClient, + appId, + enabled: analytics?.enabled ?? true, + }).track, + }); + const userAuthModule = createAuthModule( axiosClient, functionsAxiosClient, @@ -174,6 +185,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appBaseUrl: normalizedAppBaseUrl, serverUrl, token, + onAuthStateChange: experiments.onAuthStateChange, } ); @@ -228,6 +240,7 @@ export function createClient(config: CreateClientConfig): Base44Client { integrations: createIntegrationsModule(axiosClient, appId), connectors: createUserConnectorsModule(axiosClient, appId), auth: userAuthModule, + experiments: experiments.module, functions: createFunctionsModule(functionsAxiosClient, appId, { getAuthHeaders: () => { const headers: Record = {}; @@ -261,6 +274,7 @@ export function createClient(config: CreateClientConfig): Base44Client { actors: actorsModule.module, cleanup: () => { userModules.analytics.cleanup(); + experiments.cleanup(); actorsModule.closeAll(); if (socket) { socket.disconnect(); diff --git a/src/client.types.ts b/src/client.types.ts index 31fa838a..629cec8f 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -12,6 +12,7 @@ import type { AiGatewayModule } from "./modules/ai-gateway.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AppModule } from "./modules/app.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { ExperimentsModule } from "./modules/experiments.types.js"; import type { ActorsModule } from "./modules/actors.types.js"; import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js"; @@ -44,9 +45,9 @@ export interface CreateClientAnalyticsConfig { /** * Whether app analytics is enabled for this client. * - * When disabled, automatic analytics and calls to `analytics.track()` are - * no-ops. The SDK does not create an analytics session identifier, start - * heartbeat timers, or send analytics requests. + * When disabled, automatic analytics, experiment exposures and calls to + * `analytics.track()` are no-ops. The SDK does not create an analytics session + * identifier, start heartbeat timers, or send analytics requests. * * @defaultValue `true` */ @@ -141,6 +142,8 @@ export interface Base44Client { connectors: UserConnectorsModule; /** {@link EntitiesModule | Entities module} for CRUD operations on your data models. */ entities: EntitiesModule; + /** {@link ExperimentsModule | Experiments module} for browser feature flags and exposures. */ + experiments: ExperimentsModule; /** {@link FunctionsModule | Functions module} for invoking custom backend functions. */ functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} for calling pre-built integration endpoints. */ diff --git a/src/index.ts b/src/index.ts index 8842b8fe..e2c040de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,11 @@ export type { export * from "./types.js"; // Module types +export type { + ExperimentsModule, + ExperimentsSnapshot, +} from "./modules/experiments.types.js"; + export type { DeleteManyResult, DeleteResult, diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index e9aebfc5..951ca602 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -65,6 +65,11 @@ export interface AnalyticsModuleArgs { enabled: boolean; } +/** @internal */ +export function isAnalyticsEnabled(enabled: boolean): boolean { + return enabled && analyticsSharedState.config.enabled && !isReactNative; +} + export const createAnalyticsModule = ({ axiosClient, serverUrl, @@ -79,7 +84,7 @@ export const createAnalyticsModule = ({ // so the per-callsite `typeof window` guards below aren't enough to keep it // from touching `document` (e.g. `document.referrer` on init). Node/SSR is // still handled by those `window` guards, so this doesn't affect it. - if (!enabled || !analyticsSharedState.config?.enabled || isReactNative) { + if (!isAnalyticsEnabled(enabled)) { return { track: () => {}, cleanup: () => {}, diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b9e23747..e14badc8 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,6 +1,7 @@ import { AxiosInstance } from "axios"; import { AuthModuleOptions, + AuthState, InternalAuthModule, User, VerifyOtpParams, @@ -104,7 +105,9 @@ export function createAuthModule( // requests would leave the app rendering a stale identity after logout or a // session swap. let pendingMe: Promise | null = null; + let identityGeneration = 0; const clearPendingMe = () => { + identityGeneration += 1; pendingMe = null; }; @@ -112,6 +115,13 @@ export function createAuthModule( // to the identity transitions below (`setToken`, `logout`) instead of to the // header a caller may have set on the instance directly. let hasAccessToken = Boolean(options.token); + const notifyAuthState = (state: AuthState) => { + try { + options.onAuthStateChange?.(state); + } catch { + // Optional observers must not interrupt authentication or logout redirects. + } + }; return { hasToken() { @@ -120,9 +130,27 @@ export function createAuthModule( // Get current user information async me() { + const generation = identityGeneration; const request: Promise = pendingMe ?? - axios.get(`/apps/${appId}/entities/User/me`).finally(() => { + axios.get(`/apps/${appId}/entities/User/me`).then( + (user) => { + if (generation === identityGeneration) { + notifyAuthState({ status: "authenticated", userId: user.id }); + } + return user; + }, + (error: unknown) => { + if (generation === identityGeneration) { + const authError = error as { status?: number; response?: { status?: number } }; + const status = authError?.status ?? authError?.response?.status; + notifyAuthState({ + status: status === 401 || status === 403 ? "anonymous" : "error", + }); + } + throw error; + } + ).finally(() => { // Only retire this request if it is still the shared one. An identity // change mid-flight clears `pendingMe` and the next caller starts a // fresh request; an unconditional clear here would retire that newer @@ -199,6 +227,7 @@ export function createAuthModule( clearPendingMe(); resetAnalyticsSessionContext(); hasAccessToken = false; + notifyAuthState({ status: "anonymous" }); // Only do the rest if in a browser environment if (typeof window !== "undefined") { @@ -237,6 +266,7 @@ export function createAuthModule( functionsAxiosClient.defaults.headers.common[ "Authorization" ] = `Bearer ${token}`; + notifyAuthState({ status: "pending" }); // Save token to localStorage if requested if ( diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index 7c080efe..852f99eb 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -92,6 +92,13 @@ export interface ResetPasswordParams { newPassword: string; } +/** @internal */ +export type AuthState = + | { status: "pending" } + | { status: "anonymous" } + | { status: "authenticated"; userId: string } + | { status: "error" }; + /** * Configuration options for the auth module. */ @@ -106,6 +113,8 @@ export interface AuthModuleOptions { * which is how the server-side SDK reports a token it never sets explicitly. */ token?: string; + /** @internal */ + onAuthStateChange?: (state: AuthState) => void; } /** diff --git a/src/modules/experiment-exposures.ts b/src/modules/experiment-exposures.ts new file mode 100644 index 00000000..c9d86486 --- /dev/null +++ b/src/modules/experiment-exposures.ts @@ -0,0 +1,62 @@ +import type { AxiosInstance } from "axios"; +import { isAnalyticsEnabled } from "./analytics.js"; + +/** @internal */ +export function createExposureTracker({ + axiosClient, + appId, + enabled, +}: { + axiosClient: AxiosInstance; + appId: string; + enabled: boolean; +}) { + const exposed = new Set(); + + return { + track( + assignment: { + experiment_id: string; + run_version: number; + variant_key: string; + }, + identity: { visitorId: string; userId: string | null }, + ): void { + if (typeof window === "undefined" || !isAnalyticsEnabled(enabled)) return; + + const { experiment_id, run_version, variant_key } = assignment; + const key = JSON.stringify([ + experiment_id, + run_version, + variant_key, + identity.userId, + identity.visitorId, + ]); + if (exposed.has(key)) return; + exposed.add(key); + + // Pin the event's auth before an account change can alter Axios defaults. + const authorization = identity.userId + ? (axiosClient.defaults.headers.common.Authorization ?? null) + : null; + void axiosClient + .request({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: authorization }, + data: { + events: [ + { + event_name: "__experiment_exposure__", + timestamp: new Date().toISOString(), + session_id: identity.visitorId, + page_url: window.location.pathname, + properties: { experiment_id, run_version, variant_key }, + }, + ], + }, + }) + .catch(() => exposed.delete(key)); + }, + }; +} diff --git a/src/modules/experiments-runtime.types.ts b/src/modules/experiments-runtime.types.ts new file mode 100644 index 00000000..5ea035da --- /dev/null +++ b/src/modules/experiments-runtime.types.ts @@ -0,0 +1,25 @@ +/** @internal */ +export interface ExperimentAssignment { + experiment_id: string; + flag_key: string; + run_version: number; + variant_key: string; + preview: boolean; +} + +/** @internal */ +export interface ExperimentsRuntime { + flags: Record; + assignments: ExperimentAssignment[]; + visitorId: string; + userId: string | null; + pendingUser: boolean; + setUser(id: string | null): void; +} + +/** @internal */ +export function getExperimentsRuntime(): ExperimentsRuntime | undefined { + if (typeof window === "undefined" || typeof document === "undefined") return; + return (window as Window & { __B44_EXPERIMENTS__?: ExperimentsRuntime }) + .__B44_EXPERIMENTS__; +} diff --git a/src/modules/experiments.ts b/src/modules/experiments.ts new file mode 100644 index 00000000..df781d1b --- /dev/null +++ b/src/modules/experiments.ts @@ -0,0 +1,175 @@ +import type { AuthState, InternalAuthModule } from "./auth.types.js"; +import type { + ExperimentsModule, + ExperimentsSnapshot, +} from "./experiments.types.js"; +import { + getExperimentsRuntime, + type ExperimentsRuntime, +} from "./experiments-runtime.types.js"; +import type { createExposureTracker } from "./experiment-exposures.js"; + +const EMPTY: ExperimentsSnapshot = Object.freeze({ + flags: Object.freeze({}), + isLoading: false, +}); + +/** @internal */ +export function createExperimentsModule({ + getAuth, + trackExposure, +}: { + getAuth: () => InternalAuthModule; + trackExposure: ReturnType["track"]; +}) { + let runtime: ExperimentsRuntime | undefined; + let state: AuthState | undefined; + let snapshot = EMPTY; + let active = false; + let disposed = false; + let generation = 0; + let pending: Promise | undefined; + const listeners = new Set<() => void>(); + const readyWaiters = new Set<(value: ExperimentsSnapshot) => void>(); + + function settleReady() { + if (snapshot.isLoading) return; + for (const resolve of readyWaiters) resolve(snapshot); + readyWaiters.clear(); + } + + function publish() { + const isLoading = !!runtime && state?.status === "pending"; + const flags = + runtime && + (state?.status === "authenticated" || state?.status === "anonymous") + ? runtime.flags + : EMPTY.flags; + if ( + snapshot.isLoading === isLoading && + Object.keys(snapshot.flags).length === Object.keys(flags).length && + Object.keys(flags).every( + (key) => + Object.prototype.hasOwnProperty.call(snapshot.flags, key) && + snapshot.flags[key] === flags[key], + ) + ) { + settleReady(); + return; + } + snapshot = Object.freeze({ flags: Object.freeze({ ...flags }), isLoading }); + settleReady(); + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observers must not interrupt authentication. */ + } + } + } + + function applyIdentity() { + if (runtime) { + const userId = state?.status === "authenticated" ? state.userId : null; + if (runtime.userId !== userId || runtime.pendingUser) + runtime.setUser(userId); + } + publish(); + } + + function resolveIdentity() { + if (!runtime || pending || disposed) return; + state = { status: "pending" }; + applyIdentity(); + const currentGeneration = generation; + pending = getAuth() + .me() + .then( + () => {}, + () => {}, + ) + .finally(() => { + if (currentGeneration === generation) pending = undefined; + }); + } + + function activate() { + if (disposed) return; + active = true; + runtime = getExperimentsRuntime(); + if (!runtime) { + publish(); + return; + } + if (!state) + state = getAuth().hasToken() + ? { status: "pending" } + : { status: "anonymous" }; + applyIdentity(); + if (state.status === "pending") resolveIdentity(); + } + + function onAuthStateChange(next: AuthState) { + if (disposed) return; + state = next; + if (next.status === "pending" || next.status === "anonymous") { + generation++; + pending = undefined; + } + if (!active) return; + runtime = getExperimentsRuntime(); + applyIdentity(); + if (next.status === "pending") resolveIdentity(); + } + + const module: ExperimentsModule = { + isEnabled(flagKey, fallback = false) { + activate(); + if (!Object.prototype.hasOwnProperty.call(snapshot.flags, flagKey)) + return fallback; + const assignment = runtime?.assignments.find( + (item) => item.flag_key === flagKey && !item.preview, + ); + if (runtime && assignment) trackExposure(assignment, runtime); + return snapshot.flags[flagKey]; + }, + getSnapshot() { + activate(); + return snapshot; + }, + subscribe(listener) { + activate(); + if (!disposed) listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async ready() { + activate(); + if (state?.status === "error") { + // Wait for auth.me() to release its shared, failed request before retrying. + await pending; + if (state?.status === "error") resolveIdentity(); + } + if (snapshot.isLoading) + return new Promise((resolve) => + readyWaiters.add(resolve), + ); + return snapshot; + }, + }; + + return { + module, + onAuthStateChange, + cleanup() { + disposed = true; + generation++; + pending = undefined; + runtime = undefined; + snapshot = EMPTY; + settleReady(); + listeners.clear(); + }, + }; +} diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts new file mode 100644 index 00000000..cda4891d --- /dev/null +++ b/src/modules/experiments.types.ts @@ -0,0 +1,99 @@ +/** A stable, read-only view of the browser's current feature flags. */ +export interface ExperimentsSnapshot { + /** Resolved flags. Empty when the runtime is absent or identity is unresolved. */ + readonly flags: Readonly>; + /** Whether the SDK is resolving the signed-in user's identity. */ + readonly isLoading: boolean; +} + +/** + * Reads feature flags evaluated by the Base44 browser runtime. + * + * - Reads flags and reports experiment exposures when a flag is used. + * - Synchronizes assignments with this client's SDK login, token changes, and logout. + * - Provides readiness and subscriptions without requiring React. + * + * Available as `base44.experiments` for anonymous and signed-in app visitors, + * not in service role mode. Use one client for the app whose runtime is on the page. + * The platform must inject the Experiments runtime before this module can evaluate + * flags. Without it, including on servers and Workers, reads return their fallback; + * this module does not provide server-side evaluation or hydration guarantees. + * Goal conversions use the existing {@link AnalyticsModule | analytics module}. + * Visitor-keyed conversion attribution requires matching runtime and analytics + * visitor IDs; blocked browser storage is not currently supported for attribution. + */ +export interface ExperimentsModule { + /** + * Reads a flag and reports a best-effort exposure for its current assignment. + * + * The first use resolves identity through {@link AuthModule.me | auth.me()} + * when the client has a token. Reads return the fallback while identity is + * pending or could not be resolved. Await {@link ExperimentsModule.ready | ready()} + * or subscribe to updates before displaying authenticated variants. + * + * Call only where the feature is used: a read counts as exposure, not proof of + * visibility. Preview overrides and flags without an assignment are not tracked. + * Exposures respect the client's analytics setting, are deduplicated per client, + * experiment run, variant and identity, and retry only on a later read after failure. + * + * @param flagKey - Feature flag key defined in your app. + * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`. + * @returns The evaluated boolean, or the fallback when unavailable. + * @example + * ```typescript + * await base44.experiments.ready(); + * const showNewCheckout = base44.experiments.isEnabled('new_checkout'); + * ``` + */ + isEnabled(flagKey: string, fallback?: boolean): boolean; + + /** + * Returns the current flags and identity-loading state without tracking exposures. + * + * Starts lazy identity resolution if needed. The returned object retains its + * reference until its values change, for use with external-store subscriptions. + * Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary + * to record exposure rather than displaying a variant directly from this snapshot. + * + * @returns A stable, read-only snapshot. + * @example + * ```typescript + * const { isLoading } = base44.experiments.getSnapshot(); + * ``` + */ + getSnapshot(): ExperimentsSnapshot; + + /** + * Listens for flag or loading-state changes caused by this client's SDK auth flows. + * + * Does not poll for platform configuration changes or observe token writes outside + * the SDK. {@link Base44Client.cleanup | cleanup()} removes all listeners. + * + * @param listener - Callback invoked when the snapshot changes. + * @returns A function that removes the listener. + * @example + * ```typescript + * const unsubscribe = base44.experiments.subscribe(() => { + * renderCheckout(base44.experiments.isEnabled('new_checkout')); + * }); + * unsubscribe(); + * ``` + */ + subscribe(listener: () => void): () => void; + + /** + * Waits for the current identity lookup, including a token change during that lookup. + * + * Resolves with empty flags after an identity lookup failure; calling again retries + * the lookup. Missing runtimes resolve immediately. This does not wait for a future + * runtime injection or for exposure delivery, and never records an exposure itself. + * + * @returns A snapshot after the current identity lookup settles. + * @example + * ```typescript + * await base44.experiments.ready(); + * renderCheckout(base44.experiments.isEnabled('new_checkout')); + * ``` + */ + ready(): Promise; +} diff --git a/tests/types/experiments.types.ts b/tests/types/experiments.types.ts new file mode 100644 index 00000000..82704712 --- /dev/null +++ b/tests/types/experiments.types.ts @@ -0,0 +1,17 @@ +import type { Base44Client, ExperimentsModule, ExperimentsSnapshot } from "../../src/index.js"; + +declare const client: Base44Client; +const experiments: ExperimentsModule = client.experiments; +const enabled: boolean = experiments.isEnabled("checkout", false); +const snapshot: ExperimentsSnapshot = experiments.getSnapshot(); +const ready: Promise = experiments.ready(); +const unsubscribe: () => void = experiments.subscribe(() => {}); +// @ts-expect-error Fallbacks are boolean, not variant names. +experiments.isEnabled("checkout", "control"); +// @ts-expect-error Snapshots cannot override platform evaluations. +snapshot.flags.checkout = true; +// @ts-expect-error Identity is managed by auth, not a public caller-supplied user ID. +experiments.setUser("user-1"); +// @ts-expect-error Browser experiments are unavailable to service-role clients. +client.asServiceRole.experiments; +void [enabled, snapshot, ready, unsubscribe]; diff --git a/tests/unit/auth-identity.test.ts b/tests/unit/auth-identity.test.ts new file mode 100644 index 00000000..15b8399f --- /dev/null +++ b/tests/unit/auth-identity.test.ts @@ -0,0 +1,177 @@ +import axios from "axios"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAuthModule } from "../../src/modules/auth.ts"; +import type { AuthState, User } from "../../src/modules/auth.types.ts"; + +afterEach(() => vi.unstubAllGlobals()); + +function deferredUser() { + let resolve!: (user: User) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function setup() { + const api = axios.create(); + const functionsApi = axios.create(); + const get = vi.spyOn(api, "get"); + const onAuthStateChange = vi.fn<(state: AuthState) => void>(); + const auth = createAuthModule(api, functionsApi, "app-id", { + serverUrl: "https://base44.example", + appBaseUrl: "https://base44.example", + onAuthStateChange, + }); + return { api, functionsApi, get, onAuthStateChange, auth }; +} + +describe("auth identity notifications", () => { + test("reports the returned user once for concurrent callers without caching settled identities", async () => { + const { get, onAuthStateChange, auth } = setup(); + const pending = deferredUser(); + const user = { id: "user-1" } as User; + get.mockReturnValueOnce(pending.promise); + + const first = auth.me(); + const second = auth.me(); + expect(onAuthStateChange).not.toHaveBeenCalled(); + pending.resolve(user); + + expect(await Promise.all([first, second])).toEqual([user, user]); + expect(get).toHaveBeenCalledTimes(1); + expect(onAuthStateChange.mock.calls).toEqual([ + [{ status: "authenticated", userId: "user-1" }], + ]); + + get.mockResolvedValueOnce({ id: "user-2" }); + await auth.me(); + expect(get).toHaveBeenCalledTimes(2); + expect(onAuthStateChange).toHaveBeenLastCalledWith({ + status: "authenticated", userId: "user-2", + }); + }); + + test("announces a token change only after subsequent requests can use it", () => { + const { api, functionsApi, onAuthStateChange, auth } = setup(); + const observed: unknown[] = []; + onAuthStateChange.mockImplementation((state) => { + observed.push({ + state, + hasToken: auth.hasToken(), + authorization: api.defaults.headers.common.Authorization, + functionsAuthorization: functionsApi.defaults.headers.common.Authorization, + }); + }); + + auth.setToken("next-token", false); + expect(observed).toEqual([{ + state: { status: "pending" }, + hasToken: true, + authorization: "Bearer next-token", + functionsAuthorization: "Bearer next-token", + }]); + }); + + test.each(["success", "failure"])("ignores an old token's late %s without retiring the new request", async (outcome) => { + const { get, onAuthStateChange, auth } = setup(); + const old = deferredUser(); + const current = deferredUser(); + get.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + const before = auth.me().catch((error) => error); + + auth.setToken("new-token", false); + const after = auth.me(); + if (outcome === "success") old.resolve({ id: "old-user" } as User); + else old.reject({ status: 401 }); + await before; + expect(onAuthStateChange.mock.calls).toEqual([[{ status: "pending" }]]); + + const joined = auth.me(); + current.resolve({ id: "new-user" } as User); + expect(await Promise.all([after, joined])).toEqual([ + { id: "new-user" }, { id: "new-user" }, + ]); + expect(get).toHaveBeenCalledTimes(2); + expect(onAuthStateChange.mock.calls).toEqual([ + [{ status: "pending" }], + [{ status: "authenticated", userId: "new-user" }], + ]); + }); + + test.each(["success", "failure"])("keeps logout anonymous after an old request's late %s", async (outcome) => { + const { api, get, onAuthStateChange, auth } = setup(); + const pending = deferredUser(); + auth.setToken("old-token", false); + onAuthStateChange.mockClear(); + get.mockReturnValueOnce(pending.promise); + const before = auth.me().catch((error) => error); + + const observed: unknown[] = []; + onAuthStateChange.mockImplementation(() => { + observed.push({ + hasToken: auth.hasToken(), + authorization: api.defaults.headers.common.Authorization, + }); + }); + auth.logout(); + if (outcome === "success") pending.resolve({ id: "old-user" } as User); + else pending.reject({ response: { status: 503 } }); + await before; + + expect(onAuthStateChange.mock.calls).toEqual([[{ status: "anonymous" }]]); + expect(observed).toEqual([{ hasToken: false, authorization: undefined }]); + }); + + test.each([ + [{ status: 401 }, "anonymous"], + [{ status: 403 }, "anonymous"], + [{ response: { status: 401 } }, "anonymous"], + [{ response: { status: 403 } }, "anonymous"], + [{ status: 503 }, "error"], + [new Error("Network unavailable"), "error"], + ])("classifies shared %j failures once without changing their rejection", async (error, status) => { + const { get, onAuthStateChange, auth } = setup(); + get.mockRejectedValueOnce(error); + + const results = await Promise.allSettled([auth.me(), auth.me()]); + expect(results).toEqual([ + { status: "rejected", reason: error }, + { status: "rejected", reason: error }, + ]); + expect(get).toHaveBeenCalledTimes(1); + expect(onAuthStateChange.mock.calls).toEqual([[{ status }]]); + }); + + test("preserves successful and failed auth results when an observer throws", async () => { + const { get, onAuthStateChange, auth } = setup(); + onAuthStateChange.mockImplementation(() => { throw new Error("Observer failed"); }); + get.mockResolvedValueOnce({ id: "user-1" }); + await expect(auth.me()).resolves.toEqual({ id: "user-1" }); + + const error = { status: 401 }; + get.mockRejectedValueOnce(error); + await expect(auth.me()).rejects.toBe(error); + }); + + test("still persists tokens and completes logout cleanup and redirect when an observer throws", () => { + const { onAuthStateChange, auth } = setup(); + const localStorage = { setItem: vi.fn(), removeItem: vi.fn() }; + const location = { href: "https://base44.example/dashboard" }; + vi.stubGlobal("window", { localStorage, location }); + onAuthStateChange.mockImplementation(() => { throw new Error("Observer failed"); }); + + auth.setToken("new-token"); + expect(localStorage.setItem).toHaveBeenCalledWith("base44_access_token", "new-token"); + expect(localStorage.setItem).toHaveBeenCalledWith("token", "new-token"); + + auth.logout(); + expect(localStorage.removeItem).toHaveBeenCalledWith("base44_access_token"); + expect(localStorage.removeItem).toHaveBeenCalledWith("token"); + expect(location.href).toBe( + "https://base44.example/api/apps/auth/logout?from_url=https%3A%2F%2Fbase44.example%2Fdashboard" + ); + }); +}); diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts new file mode 100644 index 00000000..f22638f7 --- /dev/null +++ b/tests/unit/experiment-exposures.test.ts @@ -0,0 +1,127 @@ +import axios, { type AxiosInstance } from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createExposureTracker } from "../../src/modules/experiment-exposures.js"; + +const assignment = { experiment_id: "experiment-1", run_version: 1, variant_key: "control" }; +const identity = { visitorId: "runtime-visitor", userId: "user-1" }; +const appId = "66f1a2b3c4d5e6f7a8b9c0d1"; + +describe("experiment exposure transport", () => { + let client: AxiosInstance; + let request: ReturnType; + + beforeEach(() => { + vi.stubGlobal("window", { + location: { pathname: "/checkout", search: "" }, + history: { replaceState: vi.fn() }, + }); + vi.stubGlobal("document", {}); + client = axios.create(); + client.defaults.headers.common.Authorization = "Bearer user-1-token"; + request = vi.spyOn(client, "request").mockResolvedValue({ data: { accepted: 1 } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + test("sends one immediate batch with the runtime visitor and no client user claim", () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: "Bearer user-1-token" }, + data: { events: [{ + event_name: "__experiment_exposure__", + timestamp: expect.any(String), + session_id: "runtime-visitor", + page_url: "/checkout", + properties: assignment, + }] }, + }); + const event = request.mock.calls[0][0].data.events[0]; + expect(new Date(event.timestamp).toISOString()).toBe(event.timestamp); + }); + + test("deduplicates reads but allows new runs, variants, users and visitors", () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + tracker.track({ ...assignment }, { ...identity }); + tracker.track({ ...assignment, run_version: 2 }, identity); + tracker.track({ ...assignment, variant_key: "treatment" }, identity); + tracker.track(assignment, { ...identity, userId: "user-2" }); + tracker.track(assignment, { ...identity, visitorId: "visitor-2" }); + + expect(request).toHaveBeenCalledTimes(5); + }); + + test("retries on a later read after a failed request without an unhandled rejection", async () => { + request.mockRejectedValueOnce(new Error("offline")); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + await Promise.resolve(); + tracker.track(assignment, identity); + + expect(request).toHaveBeenCalledTimes(2); + }); + + test.each(["user-1", null])("pins Authorization before defaults change for %s", async (userId) => { + request.mockRestore(); + const adapter = vi.fn(async (config) => ({ data: {}, status: 200, statusText: "OK", headers: {}, config })); + client.defaults.adapter = adapter; + client.interceptors.request.use(async (config) => { + await Promise.resolve(); + return config; + }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, { ...identity, userId }); + client.defaults.headers.common.Authorization = "Bearer replacement-token"; + + await vi.waitFor(() => expect(adapter).toHaveBeenCalledOnce()); + expect(adapter.mock.calls[0][0].headers.get("Authorization")).toBe( + userId ? "Bearer user-1-token" : null, + ); + }); + + test("uses explicit null auth when no default header exists", () => { + delete client.defaults.headers.common.Authorization; + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request.mock.calls[0][0].headers.Authorization).toBeNull(); + }); + + test("does not send when disabled in client options or outside a browser", () => { + createExposureTracker({ axiosClient: client, appId, enabled: false }).track(assignment, identity); + vi.stubGlobal("window", undefined); + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); + + test("honors the URL opt-out after analytics consumes and removes the parameter", async () => { + window.location.search = "?analytics-enable=false"; + vi.resetModules(); + const { createExposureTracker: createTracker } = await import("../../src/modules/experiment-exposures.js"); + expect(window.history.replaceState).toHaveBeenCalledOnce(); + expect(window.history.replaceState).toHaveBeenCalledWith({}, "", "/checkout"); + window.location.search = ""; + createTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); + + test("does not send on React Native", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", undefined); + vi.resetModules(); + const { createExposureTracker: createTracker } = await import("../../src/modules/experiment-exposures.js"); + createTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/experiments-auth.test.ts b/tests/unit/experiments-auth.test.ts new file mode 100644 index 00000000..5f843415 --- /dev/null +++ b/tests/unit/experiments-auth.test.ts @@ -0,0 +1,103 @@ +import axios from "axios"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAuthModule } from "../../src/modules/auth.js"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { User } from "../../src/modules/auth.types.js"; +import type { ExperimentsRuntime } from "../../src/modules/experiments-runtime.types.js"; + +function setup(token?: string) { + const api = axios.create(); + const requests: { resolve: (user: User) => void; reject: (error: unknown) => void }[] = []; + const get = vi.spyOn(api, "get").mockImplementation(() => + new Promise((resolve, reject) => requests.push({ resolve, reject })) + ); + const runtime: ExperimentsRuntime = { + flags: { checkout: false }, assignments: [], visitorId: "visitor", + userId: null, pendingUser: false, + setUser(userId) { + this.userId = userId; + this.pendingUser = false; + this.flags = { checkout: userId !== null }; + }, + }; + vi.stubGlobal("window", { + __B44_EXPERIMENTS__: runtime, + localStorage: { setItem: vi.fn(), removeItem: vi.fn() }, + location: { href: "https://example.test/dashboard" }, + }); + vi.stubGlobal("document", {}); + const bridge = createExperimentsModule({ getAuth: () => auth, trackExposure: vi.fn() }); + const auth = createAuthModule(api, axios.create(), "app-id", { + serverUrl: "https://example.test", appBaseUrl: "https://example.test", + onAuthStateChange: bridge.onAuthStateChange, + }); + if (token) auth.setToken(token, false); + return { api, get, requests, runtime, auth, ...bridge }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("experiments with real SDK auth", () => { + test("ready follows token B without waiting for A, and A cannot restore its identity", async () => { + const b = setup("token-a"); + const ready = b.module.ready(); + const oldRequest = b.auth.me(); + b.auth.setToken("token-b", false); + expect(b.get).toHaveBeenCalledTimes(2); + + b.requests[1].resolve({ id: "user-b" } as User); + expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("user-b"); + + b.requests[0].resolve({ id: "user-a" } as User); + await expect(oldRequest).resolves.toEqual({ id: "user-a" }); + expect(b.runtime.userId).toBe("user-b"); + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.get).toHaveBeenCalledTimes(2); + }); + + test("logout settles ready immediately and ignores the old authenticated response", async () => { + const b = setup("old-token"); + const ready = b.module.ready(); + const oldRequest = b.auth.me(); + b.auth.logout(); + + expect(await ready).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(b.runtime.userId).toBeNull(); + b.requests[0].resolve({ id: "old-user" } as User); + await oldRequest; + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(b.runtime.userId).toBeNull(); + expect(b.get).toHaveBeenCalledOnce(); + }); + + test("ready retries a failed me request after its shared promise has been released", async () => { + const b = setup("valid-token"); + const ready = b.module.ready(); + b.requests[0].reject({ status: 503 }); + expect(await ready).toEqual({ flags: {}, isLoading: false }); + + const retry = b.module.ready(); + await vi.waitFor(() => expect(b.get).toHaveBeenCalledTimes(2)); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + b.requests[1].resolve({ id: "recovered-user" } as User); + expect(await retry).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("recovered-user"); + }); + + test("email login changes an active anonymous experiment session to the resolved user", async () => { + const b = setup(); + expect(await b.module.ready()).toEqual({ flags: { checkout: false }, isLoading: false }); + const response = { access_token: "login-token", user: { id: "logged-in" } }; + vi.spyOn(b.api, "post").mockResolvedValueOnce(response); + + await expect(b.auth.loginViaEmailPassword("user@example.test", "password")).resolves.toEqual(response); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(b.api.defaults.headers.common.Authorization).toBe("Bearer login-token"); + const ready = b.module.ready(); + b.requests[0].resolve(response.user as User); + expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("logged-in"); + expect(b.get).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts new file mode 100644 index 00000000..a9a3a756 --- /dev/null +++ b/tests/unit/experiments.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { ExperimentsRuntime } from "../../src/modules/experiments-runtime.types.js"; +import type { AuthState, InternalAuthModule, User } from "../../src/modules/auth.types.js"; + +function setup(hasToken = false) { + const runtime: ExperimentsRuntime = { + flags: { checkout: false }, + assignments: [{ experiment_id: "exp", flag_key: "checkout", run_version: 1, variant_key: "control", preview: false }], + visitorId: "visitor", userId: null, pendingUser: false, + setUser(id) { + this.userId = id; + this.pendingUser = false; + this.flags = { checkout: id !== null }; + }, + }; + vi.stubGlobal("window", { __B44_EXPERIMENTS__: runtime }); + vi.stubGlobal("document", {}); + const requests: { resolve: (user: User) => void; reject: (error: Error) => void }[] = []; + const me = vi.fn(() => new Promise((resolve, reject) => requests.push({ resolve, reject }))); + const trackExposure = vi.fn(); + const bridge = createExperimentsModule({ + getAuth: () => ({ hasToken: () => hasToken, me }) as InternalAuthModule, + trackExposure, + }); + const settle = (index: number, state: AuthState) => { + bridge.onAuthStateChange(state); + if (state.status === "authenticated") requests[index].resolve({ id: state.userId } as User); + else requests[index].reject(new Error("lookup failed")); + }; + return { ...bridge, runtime, requests, settle, me, trackExposure }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("browser experiments", () => { + test("stays lazy and returns fallback without a browser or injected runtime", async () => { + const b = setup(true); + expect(b.me).not.toHaveBeenCalled(); + vi.stubGlobal("window", undefined); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(await b.module.ready()).toEqual({ flags: {}, isLoading: false }); + vi.stubGlobal("window", {}); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.me).not.toHaveBeenCalled(); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("preserves explicit false, ignores inherited keys, and tracks only assigned reads", () => { + const b = setup(); + expect(b.module.isEnabled("missing", true)).toBe(true); + expect(b.module.isEnabled("toString")).toBe(false); + expect(b.trackExposure).not.toHaveBeenCalled(); + expect(b.module.isEnabled("checkout", true)).toBe(false); + expect(b.trackExposure).toHaveBeenCalledWith(b.runtime.assignments[0], b.runtime); + expect(b.me).not.toHaveBeenCalled(); + }); + + test("preview flags without assignments never report exposures", () => { + const b = setup(); + b.runtime.flags.checkout = true; + b.runtime.assignments = []; + expect(b.module.isEnabled("checkout")).toBe(true); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("holds all exposures until token identity resolves, even when bootstrap is not pending", async () => { + const b = setup(true); + const observed: boolean[] = []; + b.module.subscribe(() => observed.push(b.module.getSnapshot().isLoading)); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(b.trackExposure).not.toHaveBeenCalled(); + const ready = b.module.ready(); + b.settle(0, { status: "authenticated", userId: "user-1" }); + expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("user-1"); + expect(observed).toEqual([false]); + expect(b.module.isEnabled("checkout")).toBe(true); + expect(b.me).toHaveBeenCalledOnce(); + }); + + test("snapshots are stable and immutable and observation alone does not expose", async () => { + const b = setup(); + const first = b.module.getSnapshot(); + expect(await b.module.ready()).toBe(first); + expect(Object.isFrozen(first.flags)).toBe(true); + const listener = vi.fn(); + const unsubscribe = b.module.subscribe(listener); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.module.getSnapshot()).toBe(first); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + b.onAuthStateChange({ status: "authenticated", userId: "user-1" }); + expect(listener).not.toHaveBeenCalled(); + expect(b.module.getSnapshot()).not.toBe(first); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("ready follows a replacement token and logout immediately clears user assignments", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.onAuthStateChange({ status: "pending" }); + expect(b.module.getSnapshot().isLoading).toBe(true); + b.settle(1, { status: "authenticated", userId: "new-user" }); + expect((await ready).flags.checkout).toBe(true); + expect(b.runtime.userId).toBe("new-user"); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.runtime.userId).toBeNull(); + expect(b.module.isEnabled("checkout")).toBe(false); + b.requests[0].resolve({ id: "stale-user" } as User); + }); + + test("failed identity lookup returns fallbacks and explicit ready retries", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.settle(0, { status: "error" }); + expect(await ready).toEqual({ flags: {}, isLoading: false }); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(b.me).toHaveBeenCalledOnce(); + expect(b.trackExposure).not.toHaveBeenCalled(); + const retry = b.module.ready(); + await vi.waitFor(() => expect(b.me).toHaveBeenCalledTimes(2)); + b.settle(1, { status: "authenticated", userId: "user-1" }); + expect((await retry).flags.checkout).toBe(true); + }); + + test("invalid authentication resolves to visitor flags instead of user enrollment", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.settle(0, { status: "anonymous" }); + expect((await ready).flags.checkout).toBe(false); + expect(b.runtime.userId).toBeNull(); + }); + + test("adopts a runtime injected later and clears stale bootstrap identity", () => { + const b = setup(); + vi.stubGlobal("window", {}); + b.module.getSnapshot(); + b.runtime.userId = "old-user"; + b.runtime.flags.checkout = true; + vi.stubGlobal("window", { __B44_EXPERIMENTS__: b.runtime }); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.runtime.userId).toBeNull(); + }); + + test("cleanup and throwing subscribers cannot restore or interrupt identity", async () => { + const b = setup(true); + const listener = vi.fn(() => { throw new Error("render error"); }); + b.module.subscribe(listener); + b.settle(0, { status: "authenticated", userId: "user-1" }); + await b.module.ready(); + expect(b.module.isEnabled("checkout")).toBe(true); + b.cleanup(); + b.onAuthStateChange({ status: "authenticated", userId: "late-user" }); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + expect(listener).toHaveBeenCalledOnce(); + }); + + test.each(["logout", "cleanup"])("ready settles on %s without waiting for an obsolete lookup", async (action) => { + const b = setup(true); + const ready = b.module.ready(); + if (action === "logout") b.onAuthStateChange({ status: "anonymous" }); + else b.cleanup(); + expect((await ready).isLoading).toBe(false); + b.requests[0].resolve({ id: "obsolete-user" } as User); + }); +}); From abd514f94ed81dd709f57ca8bcad62fbcc4ecef2 Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 10 Sep 2026 08:12:36 +0300 Subject: [PATCH 02/12] fix(experiments): share runtime visitor identity with analytics --- src/modules/analytics.ts | 3 ++ src/modules/experiments.types.ts | 5 +-- tests/unit/experiment-exposures.test.ts | 46 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 951ca602..3647757d 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -11,6 +11,7 @@ import { import { getSharedInstance } from "../utils/sharedInstance.js"; import type { InternalAuthModule } from "./auth.types"; import { generateUuid, isReactNative } from "../utils/common.js"; +import { getExperimentsRuntime } from "./experiments-runtime.types.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -413,6 +414,8 @@ function getFallbackSessionId(): string { } export function getAnalyticsSessionId(): string { + const visitorId = getExperimentsRuntime()?.visitorId; + if (visitorId && visitorId !== "anon") return visitorId; if (typeof window === "undefined") { return getFallbackSessionId(); } diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts index cda4891d..e37e25fc 100644 --- a/src/modules/experiments.types.ts +++ b/src/modules/experiments.types.ts @@ -19,8 +19,9 @@ export interface ExperimentsSnapshot { * flags. Without it, including on servers and Workers, reads return their fallback; * this module does not provide server-side evaluation or hydration guarantees. * Goal conversions use the existing {@link AnalyticsModule | analytics module}. - * Visitor-keyed conversion attribution requires matching runtime and analytics - * visitor IDs; blocked browser storage is not currently supported for attribution. + * Visitor-keyed conversions share the injected runtime's visitor ID. When browser + * storage is blocked, the platform must supply a unique per-page ID; attribution + * then lasts for that page only, not across reloads or tabs. */ export interface ExperimentsModule { /** diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts index f22638f7..dd8324a0 100644 --- a/tests/unit/experiment-exposures.test.ts +++ b/tests/unit/experiment-exposures.test.ts @@ -1,6 +1,8 @@ import axios, { type AxiosInstance } from "axios"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createExposureTracker } from "../../src/modules/experiment-exposures.js"; +import { createAnalyticsModule, getAnalyticsSessionId, resetAnalyticsSessionContext } from "../../src/modules/analytics.js"; +import { createAuthModule } from "../../src/modules/auth.js"; const assignment = { experiment_id: "experiment-1", run_version: 1, variant_key: "control" }; const identity = { visitorId: "runtime-visitor", userId: "user-1" }; @@ -14,6 +16,8 @@ describe("experiment exposure transport", () => { vi.stubGlobal("window", { location: { pathname: "/checkout", search: "" }, history: { replaceState: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), }); vi.stubGlobal("document", {}); client = axios.create(); @@ -60,6 +64,48 @@ describe("experiment exposure transport", () => { expect(request).toHaveBeenCalledTimes(5); }); + test.each(["getItem", "setItem"])("attributes goals to the exposure when storage %s fails", async (method) => { + vi.useFakeTimers(); + const storage = { getItem: vi.fn(() => null as string | null), setItem: vi.fn() }; + storage[method as keyof typeof storage].mockImplementation(() => { throw new Error("storage blocked"); }); + vi.stubGlobal("localStorage", storage); + Object.assign(window, { __B44_EXPERIMENTS__: identity }); + delete client.defaults.headers.common.Authorization; + resetAnalyticsSessionContext(); + const userAuthModule = createAuthModule(client, axios.create(), appId, { serverUrl: "https://example.test", appBaseUrl: "https://example.test" }); + const analytics = createAnalyticsModule({ axiosClient: client, appId, serverUrl: "https://example.test", userAuthModule, enabled: true }); + try { + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, { ...identity, userId: null }); + analytics.track({ eventName: "purchase" }); + await vi.advanceTimersByTimeAsync(1000); + storage.getItem.mockReturnValue("recovered-storage-visitor"); + storage.setItem.mockImplementation(() => {}); + analytics.track({ eventName: "purchase_after_storage_recovers" }); + await vi.advanceTimersByTimeAsync(1000); + + const events = request.mock.calls.flatMap(([config]) => config.data.events); + for (const eventName of ["__experiment_exposure__", "purchase", "purchase_after_storage_recovers"]) { + expect(events.find((event) => event.event_name === eventName)?.session_id).toBe(identity.visitorId); + } + } finally { + analytics.cleanup(); + vi.useRealTimers(); + } + }); + + test.each([undefined, "anon"])("keeps ordinary visitor IDs when runtime ID is %s", (visitorId) => { + Object.assign(window, { __B44_EXPERIMENTS__: visitorId ? { visitorId } : undefined }); + const storage = { getItem: vi.fn(() => "stored-visitor"), setItem: vi.fn() }; + vi.stubGlobal("localStorage", storage); + expect(getAnalyticsSessionId()).toBe("stored-visitor"); + + storage.getItem.mockImplementation(() => { throw new Error("storage blocked"); }); + const fallback = getAnalyticsSessionId(); + expect(fallback).toBeTruthy(); + expect(fallback).not.toBe("anon"); + expect(getAnalyticsSessionId()).toBe(fallback); + }); + test("retries on a later read after a failed request without an unhandled rejection", async () => { request.mockRejectedValueOnce(new Error("offline")); const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); From 49cad18734445d48f5051cb0fa25a9d28ca86595 Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 10 Sep 2026 17:05:33 +0300 Subject: [PATCH 03/12] feat(experiments): evaluate request contexts locally and acknowledge exposures --- src/client.ts | 40 +++++++-- src/client.types.ts | 9 +- src/index.ts | 2 + src/modules/analytics.ts | 4 +- src/modules/auth.ts | 1 + src/modules/experiment-exposures.ts | 104 +++++++++++++---------- src/modules/experiments-config.types.ts | 35 ++++++++ src/modules/experiments-context.ts | 49 +++++++++++ src/modules/experiments-evaluator.ts | 53 ++++++++++++ src/modules/experiments.ts | 57 +++++-------- src/modules/experiments.types.ts | 40 ++++++--- src/utils/fetch-with-auth.ts | 6 ++ tests/types/experiments.types.ts | 4 +- tests/unit/experiment-exposures.test.ts | 43 ++++++++-- tests/unit/experiments-auth.test.ts | 21 ++--- tests/unit/experiments-client.test.ts | 74 ++++++++++++++++ tests/unit/experiments-context.test.ts | 76 +++++++++++++++++ tests/unit/experiments-evaluator.test.ts | 52 ++++++++++++ tests/unit/experiments.test.ts | 17 ++-- 19 files changed, 560 insertions(+), 127 deletions(-) create mode 100644 src/modules/experiments-config.types.ts create mode 100644 src/modules/experiments-context.ts create mode 100644 src/modules/experiments-evaluator.ts create mode 100644 tests/unit/experiments-client.test.ts create mode 100644 tests/unit/experiments-context.test.ts create mode 100644 tests/unit/experiments-evaluator.test.ts diff --git a/src/client.ts b/src/client.ts index cb34a05a..f3a812d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -25,6 +25,7 @@ import type { import { createAnalyticsModule } from "./modules/analytics.js"; import { createExperimentsModule } from "./modules/experiments.js"; import { createExposureTracker } from "./modules/experiment-exposures.js"; +import { EXPERIMENTS_CONTEXT_HEADER, getBrowserExperimentsContext, readExperimentsContext } from "./modules/experiments-context.js"; import { createActorsModule, resolveActorsHost, @@ -92,6 +93,7 @@ export function createClient(config: CreateClientConfig): Base44Client { // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + const experimentsContext = config.experiments ?? getBrowserExperimentsContext(appId); const socketConfig: RoomsSocketConfig = { serverUrl, @@ -112,9 +114,14 @@ export function createClient(config: CreateClientConfig): Base44Client { return socket; }; + const { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext, ...requestHeaders } = optionalHeaders ?? {}; const headers = { - ...optionalHeaders, + ...requestHeaders, "X-App-Id": String(appId), + ...(experimentsContext ? { + "Base44-Visitor-Id": experimentsContext.identity.visitorId, + "Base44-Experiment-Preview": JSON.stringify(experimentsContext.preview ?? {}), + } : {}), }; const functionHeaders = functionsVersion @@ -168,13 +175,18 @@ export function createClient(config: CreateClientConfig): Base44Client { headers, }); - const experiments = createExperimentsModule({ - getAuth: () => userAuthModule, - trackExposure: createExposureTracker({ + const exposureTracker = createExposureTracker({ axiosClient, appId, enabled: analytics?.enabled ?? true, - }).track, + source: typeof window === "undefined" ? "backend" : "browser", + pageUrl: experimentsContext?.pageUrl, + }); + const experiments = createExperimentsModule({ + getAuth: () => userAuthModule, + trackExposure: exposureTracker.track, + flushExposures: exposureTracker.flush, + context: experimentsContext, }); const userAuthModule = createAuthModule( @@ -199,6 +211,14 @@ export function createClient(config: CreateClientConfig): Base44Client { userAuthModule.setToken(accessToken); } } + if (experimentsContext) { + const { userId, status } = experimentsContext.identity; + // The document's cookie identity may differ from this client's localStorage token. + const needsClientIdentity = typeof window !== "undefined" && userAuthModule.hasToken() && + experimentsContext.config.experiments.some((experiment) => experiment.assign_by === "user"); + experiments.onAuthStateChange(status === "pending" || needsClientIdentity ? { status: "pending" } : + userId ? { status: "authenticated", userId } : { status: "anonymous" }); + } const actorsModule = createActorsModule({ appId, @@ -270,6 +290,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, enabled: analytics?.enabled ?? true, + getVisitorId: experiments.visitorId, }), actors: actorsModule.module, cleanup: () => { @@ -345,7 +366,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId: String(appId), serverUrl, functionsVersion, - platformHeaders: optionalHeaders, + platformHeaders: { + ...headers, + ...(inheritedExperimentsContext ? { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext } : {}), + }, }), /** @@ -521,6 +545,9 @@ export function createClientFromRequest(request: Request): Base44Client { // Prepare additional headers to propagate const additionalHeaders: Record = {}; + const encodedExperiments = request.headers.get(EXPERIMENTS_CONTEXT_HEADER); + const experimentsContext = readExperimentsContext(encodedExperiments, appId); + if (experimentsContext && encodedExperiments) additionalHeaders[EXPERIMENTS_CONTEXT_HEADER] = encodedExperiments; if (stateHeader) { additionalHeaders["Base44-State"] = stateHeader; } @@ -542,5 +569,6 @@ export function createClientFromRequest(request: Request): Base44Client { serviceToken: serviceRoleToken, functionsVersion: functionsVersion ?? undefined, headers: additionalHeaders, + experiments: experimentsContext ? { ...experimentsContext, pageUrl: request.url ? new URL(request.url).pathname : "/" } : undefined, }); } diff --git a/src/client.types.ts b/src/client.types.ts index 629cec8f..635552db 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -13,6 +13,7 @@ import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AppModule } from "./modules/app.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; import type { ExperimentsModule } from "./modules/experiments.types.js"; +import type { ExperimentsContext } from "./modules/experiments-config.types.js"; import type { ActorsModule } from "./modules/actors.types.js"; import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js"; @@ -86,6 +87,12 @@ export interface CreateClientConfig { * Omit this option to preserve the default analytics behavior. */ analytics?: CreateClientAnalyticsConfig; + /** + * Platform-validated context for local flag evaluation. Request-scoped on servers. + * Automatically read from the platform bootstrap in browsers and trusted headers + * by createClientFromRequest(). Not an authorization credential. + */ + experiments?: ExperimentsContext; /** * User authentication token. Used to authenticate as a specific user. * @@ -142,7 +149,7 @@ export interface Base44Client { connectors: UserConnectorsModule; /** {@link EntitiesModule | Entities module} for CRUD operations on your data models. */ entities: EntitiesModule; - /** {@link ExperimentsModule | Experiments module} for browser feature flags and exposures. */ + /** {@link ExperimentsModule | Experiments module} for local feature flags and exposures. */ experiments: ExperimentsModule; /** {@link FunctionsModule | Functions module} for invoking custom backend functions. */ functions: FunctionsModule; diff --git a/src/index.ts b/src/index.ts index e2c040de..de326da4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,8 @@ export type { }; export * from "./types.js"; +export { evaluateExperiments } from "./modules/experiments-evaluator.js"; +export type { ExperimentsConfig, ExperimentsContext, ExperimentsIdentity } from "./modules/experiments-config.types.js"; // Module types export type { diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 3647757d..bddf5892 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -64,6 +64,7 @@ export interface AnalyticsModuleArgs { appId: string; userAuthModule: InternalAuthModule; enabled: boolean; + getVisitorId?: () => string | undefined; } /** @internal */ @@ -77,6 +78,7 @@ export const createAnalyticsModule = ({ appId, userAuthModule, enabled, + getVisitorId, }: AnalyticsModuleArgs) => { // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; @@ -126,7 +128,7 @@ export const createAnalyticsModule = ({ const sessionContext_ = await getSessionContext(userAuthModule); const events = eventsData.map( - transformEventDataToApiRequestData(sessionContext_) + transformEventDataToApiRequestData({ ...sessionContext_, session_id: getVisitorId?.() ?? sessionContext_.session_id }) ); try { diff --git a/src/modules/auth.ts b/src/modules/auth.ts index e14badc8..c795ee31 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -304,6 +304,7 @@ export function createAuthModule( if (access_token) { this.setToken(access_token); + if (typeof user?.id === "string") notifyAuthState({ status: "authenticated", userId: user.id }); } return { diff --git a/src/modules/experiment-exposures.ts b/src/modules/experiment-exposures.ts index c9d86486..dfe73efb 100644 --- a/src/modules/experiment-exposures.ts +++ b/src/modules/experiment-exposures.ts @@ -1,62 +1,80 @@ import type { AxiosInstance } from "axios"; +import { v4 as uuid } from "uuid"; import { isAnalyticsEnabled } from "./analytics.js"; /** @internal */ export function createExposureTracker({ - axiosClient, - appId, - enabled, + axiosClient, appId, enabled, source = "browser", pageUrl, }: { axiosClient: AxiosInstance; appId: string; enabled: boolean; + source?: "browser" | "backend"; + pageUrl?: string; }) { - const exposed = new Set(); + type Entry = { + data: { events: Record[] }; + authorization: string | null; + acknowledged: boolean; + pending?: Promise; + }; + const entries = new Map(); + + function send(entry: Entry): Promise { + if (entry.pending) return entry.pending; + const pending = (async () => { + for (let attempt = 0; ; attempt++) { + try { + const response = await axiosClient.request({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: entry.authorization }, + data: entry.data, + }); + if (response.accepted !== 1) throw new Error("Experiment exposure was not accepted"); + entry.acknowledged = true; + return; + } catch (error) { + if (attempt === 2) throw error; + await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); + } + } + })().finally(() => { entry.pending = undefined; }); + entry.pending = pending; + // Reads stay synchronous; flush() lets request handlers observe delivery failures. + void pending.catch(() => {}); + return pending; + } return { track( - assignment: { - experiment_id: string; - run_version: number; - variant_key: string; - }, + assignment: { experiment_id: string; run_version: number; variant_key: string }, identity: { visitorId: string; userId: string | null }, ): void { - if (typeof window === "undefined" || !isAnalyticsEnabled(enabled)) return; - + if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled)) return; const { experiment_id, run_version, variant_key } = assignment; - const key = JSON.stringify([ - experiment_id, - run_version, - variant_key, - identity.userId, - identity.visitorId, - ]); - if (exposed.has(key)) return; - exposed.add(key); - - // Pin the event's auth before an account change can alter Axios defaults. - const authorization = identity.userId - ? (axiosClient.defaults.headers.common.Authorization ?? null) - : null; - void axiosClient - .request({ - method: "POST", - url: `/apps/${appId}/analytics/track/batch`, - headers: { Authorization: authorization }, - data: { - events: [ - { - event_name: "__experiment_exposure__", - timestamp: new Date().toISOString(), - session_id: identity.visitorId, - page_url: window.location.pathname, - properties: { experiment_id, run_version, variant_key }, - }, - ], - }, - }) - .catch(() => exposed.delete(key)); + const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]); + let entry = entries.get(key); + if (!entry) { + const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; + entry = { + acknowledged: false, + authorization: typeof authorization === "string" ? authorization : null, + data: { events: [{ + event_id: uuid(), + event_name: "__experiment_exposure__", + timestamp: new Date().toISOString(), + session_id: identity.visitorId, + page_url: pageUrl ?? (typeof window === "undefined" ? "/" : window.location.pathname), + properties: { experiment_id, run_version, variant_key, source }, + }] }, + }; + entries.set(key, entry); + } + if (!entry.acknowledged) void send(entry); + }, + async flush(): Promise { + await Promise.all([...entries.values()].filter((entry) => !entry.acknowledged).map(send)); }, }; } diff --git a/src/modules/experiments-config.types.ts b/src/modules/experiments-config.types.ts new file mode 100644 index 00000000..fc8e8499 --- /dev/null +++ b/src/modules/experiments-config.types.ts @@ -0,0 +1,35 @@ +import type { ExperimentsSnapshot } from "./experiments.types.js"; + +/** Shared versioned configuration published by the platform, never visitor-specific. */ +export interface ExperimentsConfig { + v: 1; + app_id: string; + revision?: number; + flags: { key: string; rollout_percentage: number }[]; + experiments: { + id: string; + flag_key: string; + run_version: number; + assign_by: "visitor" | "user"; + traffic_allocation: number; + variants: { key: string; value: boolean; weight: number }[]; + }[]; +} + +/** Identity supplied by the platform's normal authenticated request/bootstrap path. */ +export interface ExperimentsIdentity { + visitorId: string; + userId: string | null; + status?: "authenticated" | "anonymous" | "pending"; +} + +/** One request's or browser page's context. Never share it between server requests. */ +export interface ExperimentsContext { + config: ExperimentsConfig; + identity: ExperimentsIdentity; + preview?: Readonly>; + /** Request pathname used for server-side exposure events. */ + pageUrl?: string; + /** Exact server-rendered flags, retained for the browser's first hydration render. */ + serverSnapshot?: ExperimentsSnapshot; +} diff --git a/src/modules/experiments-context.ts b/src/modules/experiments-context.ts new file mode 100644 index 00000000..623ea092 --- /dev/null +++ b/src/modules/experiments-context.ts @@ -0,0 +1,49 @@ +import type { ExperimentsContext } from "./experiments-config.types.js"; +import { evaluateExperiments } from "./experiments-evaluator.js"; +import type { ExperimentsRuntime } from "./experiments-runtime.types.js"; + +/** @internal Platform ingress overwrites this header; it is not authentication. */ +export const EXPERIMENTS_CONTEXT_HEADER = "Base44-Experiments-Context"; + +/** @internal */ +export function readExperimentsContext(encoded: string | null, appId: string): ExperimentsContext | undefined { + if (!encoded || encoded.length > 96 * 1024) return; + try { + const bytes = Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (character) => character.charCodeAt(0)); + return matchingContext(JSON.parse(new TextDecoder().decode(bytes)), appId); + } catch { + return; + } +} + +function matchingContext(value: ExperimentsContext | undefined, appId: string): ExperimentsContext | undefined { + return value?.config?.v === 1 && value.config.app_id === appId && + typeof value.identity?.visitorId === "string" && value.identity.visitorId && + (value.identity.userId === null || typeof value.identity.userId === "string") + ? value : undefined; +} + +/** @internal */ +export function getBrowserExperimentsContext(appId: string): ExperimentsContext | undefined { + if (typeof window === "undefined" || typeof document === "undefined") return; + return matchingContext((window as Window & { + __B44_EXPERIMENTS_BOOTSTRAP__?: ExperimentsContext; + }).__B44_EXPERIMENTS_BOOTSTRAP__, appId); +} + +/** One independent evaluator instance for one client/request. @internal */ +export function createExperimentsRuntime(context: ExperimentsContext): ExperimentsRuntime { + const identity = { ...context.identity }; + const evaluate = () => evaluateExperiments(context.config, identity, context.preview); + const runtime: ExperimentsRuntime = { + ...evaluate(), + visitorId: identity.visitorId, + userId: identity.userId, + pendingUser: identity.status === "pending", + setUser(userId) { + identity.userId = userId; + Object.assign(runtime, evaluate(), { userId, pendingUser: false }); + }, + }; + return runtime; +} diff --git a/src/modules/experiments-evaluator.ts b/src/modules/experiments-evaluator.ts new file mode 100644 index 00000000..f7a273b4 --- /dev/null +++ b/src/modules/experiments-evaluator.ts @@ -0,0 +1,53 @@ +import type { ExperimentAssignment } from "./experiments-runtime.types.js"; +import type { ExperimentsConfig, ExperimentsIdentity } from "./experiments-config.types.js"; + +function bucket(parts: (string | number)[]): number { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(parts.join(":"))) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + return hash % 100; +} + +/** + * Evaluates flags locally without storage, network, clock, or browser globals. + * The same config and identity always produce the same assignments. + * This controls presentation, never authorization or access to data. + */ +export function evaluateExperiments( + config: ExperimentsConfig, + identity: ExperimentsIdentity, + preview: Readonly> = {}, +): { flags: Record; assignments: ExperimentAssignment[] } { + const flags: Record = Object.fromEntries( + config.flags.map((flag) => [ + flag.key, + bucket(["rollout", config.app_id, flag.key, identity.visitorId]) < flag.rollout_percentage, + ]), + ); + const assignments: ExperimentAssignment[] = []; + for (const experiment of config.experiments) { + if (Object.prototype.hasOwnProperty.call(preview, experiment.flag_key)) continue; + const key = experiment.assign_by === "user" ? identity.userId : identity.visitorId; + if (!key || bucket(["enroll", config.app_id, experiment.id, experiment.run_version, key]) >= experiment.traffic_allocation) continue; + const value = bucket(["variant", config.app_id, experiment.id, experiment.run_version, key]); + let total = 0; + let variant = experiment.variants[experiment.variants.length - 1]; + for (const candidate of experiment.variants) { + total += candidate.weight; + if (value < total) { + variant = candidate; + break; + } + } + flags[experiment.flag_key] = variant.value; + assignments.push({ + experiment_id: experiment.id, + flag_key: experiment.flag_key, + run_version: experiment.run_version, + variant_key: variant.key, + preview: false, + }); + } + return { flags: { ...flags, ...preview }, assignments }; +} diff --git a/src/modules/experiments.ts b/src/modules/experiments.ts index df781d1b..53576ca0 100644 --- a/src/modules/experiments.ts +++ b/src/modules/experiments.ts @@ -8,6 +8,8 @@ import { type ExperimentsRuntime, } from "./experiments-runtime.types.js"; import type { createExposureTracker } from "./experiment-exposures.js"; +import type { ExperimentsContext } from "./experiments-config.types.js"; +import { createExperimentsRuntime } from "./experiments-context.js"; const EMPTY: ExperimentsSnapshot = Object.freeze({ flags: Object.freeze({}), @@ -18,19 +20,30 @@ const EMPTY: ExperimentsSnapshot = Object.freeze({ export function createExperimentsModule({ getAuth, trackExposure, + flushExposures = async () => {}, + context, }: { getAuth: () => InternalAuthModule; trackExposure: ReturnType["track"]; + flushExposures?: () => Promise; + context?: ExperimentsContext; }) { - let runtime: ExperimentsRuntime | undefined; - let state: AuthState | undefined; + let runtime: ExperimentsRuntime | undefined = context ? createExperimentsRuntime(context) : undefined; + let state: AuthState | undefined = context + ? context.identity.status === "pending" ? { status: "pending" } + : context.identity.userId ? { status: "authenticated", userId: context.identity.userId } + : { status: "anonymous" } + : undefined; let snapshot = EMPTY; let active = false; let disposed = false; - let generation = 0; - let pending: Promise | undefined; const listeners = new Set<() => void>(); const readyWaiters = new Set<(value: ExperimentsSnapshot) => void>(); + const initial = context?.serverSnapshot ?? (context ? { + flags: context.identity.status === "pending" ? {} : runtime!.flags, + isLoading: context.identity.status === "pending", + } : EMPTY); + const serverSnapshot: ExperimentsSnapshot = Object.freeze({ ...initial, flags: Object.freeze({ ...initial.flags }) }); function settleReady() { if (snapshot.isLoading) return; @@ -77,26 +90,10 @@ export function createExperimentsModule({ publish(); } - function resolveIdentity() { - if (!runtime || pending || disposed) return; - state = { status: "pending" }; - applyIdentity(); - const currentGeneration = generation; - pending = getAuth() - .me() - .then( - () => {}, - () => {}, - ) - .finally(() => { - if (currentGeneration === generation) pending = undefined; - }); - } - function activate() { if (disposed) return; active = true; - runtime = getExperimentsRuntime(); + if (!context) runtime = getExperimentsRuntime(); if (!runtime) { publish(); return; @@ -106,20 +103,14 @@ export function createExperimentsModule({ ? { status: "pending" } : { status: "anonymous" }; applyIdentity(); - if (state.status === "pending") resolveIdentity(); } function onAuthStateChange(next: AuthState) { if (disposed) return; state = next; - if (next.status === "pending" || next.status === "anonymous") { - generation++; - pending = undefined; - } if (!active) return; - runtime = getExperimentsRuntime(); + if (!context) runtime = getExperimentsRuntime(); applyIdentity(); - if (next.status === "pending") resolveIdentity(); } const module: ExperimentsModule = { @@ -137,6 +128,7 @@ export function createExperimentsModule({ activate(); return snapshot; }, + getServerSnapshot: () => serverSnapshot, subscribe(listener) { activate(); if (!disposed) listeners.add(listener); @@ -146,26 +138,21 @@ export function createExperimentsModule({ }, async ready() { activate(); - if (state?.status === "error") { - // Wait for auth.me() to release its shared, failed request before retrying. - await pending; - if (state?.status === "error") resolveIdentity(); - } if (snapshot.isLoading) return new Promise((resolve) => readyWaiters.add(resolve), ); return snapshot; }, + flush: flushExposures, }; return { module, onAuthStateChange, + visitorId: () => runtime?.visitorId, cleanup() { disposed = true; - generation++; - pending = undefined; runtime = undefined; snapshot = EMPTY; settleReady(); diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts index e37e25fc..c2456aa4 100644 --- a/src/modules/experiments.types.ts +++ b/src/modules/experiments.types.ts @@ -7,7 +7,7 @@ export interface ExperimentsSnapshot { } /** - * Reads feature flags evaluated by the Base44 browser runtime. + * Evaluates feature flags locally from platform-provided configuration and identity. * * - Reads flags and reports experiment exposures when a flag is used. * - Synchronizes assignments with this client's SDK login, token changes, and logout. @@ -15,9 +15,10 @@ export interface ExperimentsSnapshot { * * Available as `base44.experiments` for anonymous and signed-in app visitors, * not in service role mode. Use one client for the app whose runtime is on the page. - * The platform must inject the Experiments runtime before this module can evaluate - * flags. Without it, including on servers and Workers, reads return their fallback; - * this module does not provide server-side evaluation or hydration guarantees. + * Browsers read the platform bootstrap. Servers and Workers use the request-scoped + * context passed by createClientFromRequest(), or explicit createClient options. + * Missing context returns fallbacks. For authenticated first render, the platform's + * common auth bootstrap must supply a resolved identity before mounting the app. * Goal conversions use the existing {@link AnalyticsModule | analytics module}. * Visitor-keyed conversions share the injected runtime's visitor ID. When browser * storage is blocked, the platform must supply a unique per-page ID; attribution @@ -25,17 +26,17 @@ export interface ExperimentsSnapshot { */ export interface ExperimentsModule { /** - * Reads a flag and reports a best-effort exposure for its current assignment. + * Reads a flag and queues an acknowledged exposure for its current assignment. * - * The first use resolves identity through {@link AuthModule.me | auth.me()} - * when the client has a token. Reads return the fallback while identity is - * pending or could not be resolved. Await {@link ExperimentsModule.ready | ready()} - * or subscribe to updates before displaying authenticated variants. + * Never starts an authentication request. Reads return the fallback while the + * app's normal auth initialization is pending or failed. Supply trusted bootstrap + * identity or let the app's existing auth.me()/login flow resolve it. * * Call only where the feature is used: a read counts as exposure, not proof of * visibility. Preview overrides and flags without an assignment are not tracked. * Exposures respect the client's analytics setting, are deduplicated per client, - * experiment run, variant and identity, and retry only on a later read after failure. + * experiment run, variant and identity. Failed sends retry up to three attempts + * with the same event ID, timestamp and credentials. Await flush() on servers. * * @param flagKey - Feature flag key defined in your app. * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`. @@ -51,7 +52,7 @@ export interface ExperimentsModule { /** * Returns the current flags and identity-loading state without tracking exposures. * - * Starts lazy identity resolution if needed. The returned object retains its + * Observes identity resolution without starting it. The returned object retains its * reference until its values change, for use with external-store subscriptions. * Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary * to record exposure rather than displaying a variant directly from this snapshot. @@ -64,6 +65,9 @@ export interface ExperimentsModule { */ getSnapshot(): ExperimentsSnapshot; + /** Immutable initial platform snapshot for matching server render and hydration. */ + getServerSnapshot(): ExperimentsSnapshot; + /** * Listens for flag or loading-state changes caused by this client's SDK auth flows. * @@ -83,10 +87,10 @@ export interface ExperimentsModule { subscribe(listener: () => void): () => void; /** - * Waits for the current identity lookup, including a token change during that lookup. + * Waits for the app's common auth initialization, including a token change. * - * Resolves with empty flags after an identity lookup failure; calling again retries - * the lookup. Missing runtimes resolve immediately. This does not wait for a future + * Resolves with empty flags after an identity lookup failure. Retrying authentication + * belongs to the normal auth flow. Missing runtimes resolve immediately. This does not wait for a future * runtime injection or for exposure delivery, and never records an exposure itself. * * @returns A snapshot after the current identity lookup settles. @@ -97,4 +101,12 @@ export interface ExperimentsModule { * ``` */ ready(): Promise; + + /** + * Waits until queued exposures are acknowledged; rejects after bounded retries. + * Server/Worker handlers must await this before ending the request (or use waitUntil). + * Retries preserve event IDs but raw storage is not exactly-once. Calling again + * retries unacknowledged events with the same IDs. No new exposures are created. + */ + flush(): Promise; } diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 9b954982..5403b37d 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -58,6 +58,7 @@ export function createFetchWithAuth({ ? header : null; }; + const contextAuthorization = bearer(axios); return async function fetchWithAuth( path: string, @@ -84,6 +85,11 @@ export function createFetchWithAuth({ inherit("Base44-Functions-Version", functionsVersion); inherit("Base44-State", inherited.get("Base44-State")); inherit("X-Data-Env", inherited.get("X-Data-Env")); + inherit("Base44-Visitor-Id", inherited.get("Base44-Visitor-Id")); + inherit("Base44-Experiment-Preview", inherited.get("Base44-Experiment-Preview")); + if (headers.get("Authorization") === contextAuthorization) { + inherit("Base44-Experiments-Context", inherited.get("Base44-Experiments-Context")); + } // The path is passed through untouched: resolving it here would need a // document, and a root-relative path is already what a runtime that diff --git a/tests/types/experiments.types.ts b/tests/types/experiments.types.ts index 82704712..9d4be99f 100644 --- a/tests/types/experiments.types.ts +++ b/tests/types/experiments.types.ts @@ -6,6 +6,8 @@ const enabled: boolean = experiments.isEnabled("checkout", false); const snapshot: ExperimentsSnapshot = experiments.getSnapshot(); const ready: Promise = experiments.ready(); const unsubscribe: () => void = experiments.subscribe(() => {}); +const serverSnapshot: ExperimentsSnapshot = experiments.getServerSnapshot(); +const delivered: Promise = experiments.flush(); // @ts-expect-error Fallbacks are boolean, not variant names. experiments.isEnabled("checkout", "control"); // @ts-expect-error Snapshots cannot override platform evaluations. @@ -14,4 +16,4 @@ snapshot.flags.checkout = true; experiments.setUser("user-1"); // @ts-expect-error Browser experiments are unavailable to service-role clients. client.asServiceRole.experiments; -void [enabled, snapshot, ready, unsubscribe]; +void [enabled, snapshot, ready, unsubscribe, serverSnapshot, delivered]; diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts index dd8324a0..0f5739ac 100644 --- a/tests/unit/experiment-exposures.test.ts +++ b/tests/unit/experiment-exposures.test.ts @@ -22,10 +22,11 @@ describe("experiment exposure transport", () => { vi.stubGlobal("document", {}); client = axios.create(); client.defaults.headers.common.Authorization = "Bearer user-1-token"; - request = vi.spyOn(client, "request").mockResolvedValue({ data: { accepted: 1 } }); + request = vi.spyOn(client, "request").mockResolvedValue({ accepted: 1 }); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.resetModules(); @@ -42,10 +43,11 @@ describe("experiment exposure transport", () => { headers: { Authorization: "Bearer user-1-token" }, data: { events: [{ event_name: "__experiment_exposure__", + event_id: expect.any(String), timestamp: expect.any(String), session_id: "runtime-visitor", page_url: "/checkout", - properties: assignment, + properties: { ...assignment, source: "browser" }, }] }, }); const event = request.mock.calls[0][0].data.events[0]; @@ -106,20 +108,47 @@ describe("experiment exposure transport", () => { expect(getAnalyticsSessionId()).toBe(fallback); }); - test("retries on a later read after a failed request without an unhandled rejection", async () => { + test("automatically retries the same event and credentials after a lost acknowledgement", async () => { + vi.useFakeTimers(); request.mockRejectedValueOnce(new Error("offline")); const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); tracker.track(assignment, identity); - await Promise.resolve(); - tracker.track(assignment, identity); - + client.defaults.headers.common.Authorization = "Bearer replacement"; + await vi.advanceTimersByTimeAsync(100); + await tracker.flush(); expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][0]).toEqual(request.mock.calls[0][0]); + expect(request.mock.calls[0][0].data.events[0].event_id).toMatch(/^[0-9a-f-]{36}$/); + vi.useRealTimers(); + }); + + test("backend flush rejects unaccepted batches and a later flush reuses the same event", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", undefined); + request.mockResolvedValue({ accepted: 0 }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend", pageUrl: "/checkout" }); + tracker.track(assignment, identity); + const failed = expect(tracker.flush()).rejects.toThrow("not accepted"); + await vi.advanceTimersByTimeAsync(600); + await failed; + expect(request).toHaveBeenCalledTimes(3); + const initial = request.mock.calls[0][0]; + expect(initial.data.events[0].properties.source).toBe("backend"); + expect(initial.data.events[0].page_url).toBe("/checkout"); + request.mockResolvedValue({ accepted: 1 }); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(4); + expect(request.mock.calls[3][0]).toEqual(initial); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(4); }); test.each(["user-1", null])("pins Authorization before defaults change for %s", async (userId) => { request.mockRestore(); - const adapter = vi.fn(async (config) => ({ data: {}, status: 200, statusText: "OK", headers: {}, config })); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); client.defaults.adapter = adapter; + client.interceptors.response.use((response) => response.data); client.interceptors.request.use(async (config) => { await Promise.resolve(); return config; diff --git a/tests/unit/experiments-auth.test.ts b/tests/unit/experiments-auth.test.ts index 5f843415..ec013db6 100644 --- a/tests/unit/experiments-auth.test.ts +++ b/tests/unit/experiments-auth.test.ts @@ -43,9 +43,11 @@ describe("experiments with real SDK auth", () => { const ready = b.module.ready(); const oldRequest = b.auth.me(); b.auth.setToken("token-b", false); + const newRequest = b.auth.me(); expect(b.get).toHaveBeenCalledTimes(2); b.requests[1].resolve({ id: "user-b" } as User); + await newRequest; expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); expect(b.runtime.userId).toBe("user-b"); @@ -71,17 +73,18 @@ describe("experiments with real SDK auth", () => { expect(b.get).toHaveBeenCalledOnce(); }); - test("ready retries a failed me request after its shared promise has been released", async () => { + test("ready observes the common auth flow retry without starting a lookup", async () => { const b = setup("valid-token"); const ready = b.module.ready(); + const first = b.auth.me().catch(() => {}); b.requests[0].reject({ status: 503 }); + await first; expect(await ready).toEqual({ flags: {}, isLoading: false }); - const retry = b.module.ready(); - await vi.waitFor(() => expect(b.get).toHaveBeenCalledTimes(2)); - expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + const retry = b.auth.me(); b.requests[1].resolve({ id: "recovered-user" } as User); - expect(await retry).toEqual({ flags: { checkout: true }, isLoading: false }); + await retry; + expect(await b.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); expect(b.runtime.userId).toBe("recovered-user"); }); @@ -92,12 +95,10 @@ describe("experiments with real SDK auth", () => { vi.spyOn(b.api, "post").mockResolvedValueOnce(response); await expect(b.auth.loginViaEmailPassword("user@example.test", "password")).resolves.toEqual(response); - expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); expect(b.api.defaults.headers.common.Authorization).toBe("Bearer login-token"); - const ready = b.module.ready(); - b.requests[0].resolve(response.user as User); - expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(await b.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); expect(b.runtime.userId).toBe("logged-in"); - expect(b.get).toHaveBeenCalledOnce(); + expect(b.get).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/experiments-client.test.ts b/tests/unit/experiments-client.test.ts new file mode 100644 index 00000000..46a49e57 --- /dev/null +++ b/tests/unit/experiments-client.test.ts @@ -0,0 +1,74 @@ +import axios from "axios"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createClient, createClientFromRequest } from "../../src/client.js"; +import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); + +const context: ExperimentsContext = { + config: { v: 1, revision: 2, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, assign_by: "user", traffic_allocation: 100, + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, + identity: { visitorId: "visitor", userId: "user", status: "authenticated" }, + preview: {}, +}; +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("client experiments integration", () => { + test("request context evaluates synchronously and flushes with the request's user token", async () => { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", "Authorization": "Bearer user-token", "Base44-Experiments-Context": encode(context), + } })); + expect(client.experiments.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(adapter).not.toHaveBeenCalled(); + expect(client.experiments.isEnabled("checkout")).toBe(true); + await client.experiments.flush(); + expect(adapter).toHaveBeenCalledOnce(); + const request = adapter.mock.calls[0][0]; + expect(request.url).toBe("/apps/app/analytics/track/batch"); + expect(request.headers.get("Authorization")).toBe("Bearer user-token"); + expect(request.headers.has("Base44-Experiments-Context")).toBe(false); + const [event] = JSON.parse(request.data).events; + expect(event.properties.source).toBe("backend"); + expect(event.session_id).toBe("visitor"); + expect(event.page_url).toBe("/checkout"); + const transport = vi.fn(async (_url: string, _init?: RequestInit) => new Response("ok")); + await client.fetchWithAuth("/api/child", { fetch: transport }); + expect(new Headers(transport.mock.calls[0][1]?.headers).get("Base44-Experiments-Context")).toBe(encode(context)); + client.setToken("different-user-token"); + await client.fetchWithAuth("/api/child", { fetch: transport }); + expect(new Headers(transport.mock.calls[1][1]?.headers).has("Base44-Experiments-Context")).toBe(false); + client.cleanup(); + }); + + test("browser waits for its token identity while retaining SSR flags and forwarding visitor/preview", async () => { + vi.stubGlobal("window", { + __B44_EXPERIMENTS_BOOTSTRAP__: { ...context, preview: { checkout: false } }, + location: { origin: "https://app.example", pathname: "/checkout" }, + localStorage: { getItem: () => "user-token", setItem: () => {} }, + }); + vi.stubGlobal("document", {}); + const client = createClient({ appId: "app", token: "user-token", analytics: { enabled: false } }); + expect(client.experiments.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(client.experiments.getServerSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + const transport = vi.fn(async (_url: string, _init?: RequestInit) => new Response("ok")); + await client.fetchWithAuth("/api/checkout", { fetch: transport }); + const headers = new Headers(transport.mock.calls[0][1]?.headers); + expect(headers.get("Base44-Visitor-Id")).toBe("visitor"); + expect(headers.get("Base44-Experiment-Preview")).toBe('{"checkout":false}'); + client.cleanup(); + }); +}); diff --git a/tests/unit/experiments-context.test.ts b/tests/unit/experiments-context.test.ts new file mode 100644 index 00000000..29303a5d --- /dev/null +++ b/tests/unit/experiments-context.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; +import { getBrowserExperimentsContext, readExperimentsContext } from "../../src/modules/experiments-context.js"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { InternalAuthModule } from "../../src/modules/auth.types.js"; + +const context: ExperimentsContext = { + config: { v: 1, revision: 8, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, traffic_allocation: 100, assign_by: "user", + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, + identity: { visitorId: "ünïcödé-👩‍💻", userId: "user-a", status: "authenticated" }, +}; +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +afterEach(() => vi.unstubAllGlobals()); + +describe("platform experiments context", () => { + test("decodes UTF8 request context but rejects malformed, oversized and other-app context", () => { + expect(readExperimentsContext(encode(context), "app")).toEqual(context); + for (const value of [null, "not-json", "a".repeat(96 * 1024 + 1), encode({ ...context, config: { ...context.config, v: 2 } })]) { + expect(readExperimentsContext(value, "app")).toBeUndefined(); + } + expect(readExperimentsContext(encode(context), "other-app")).toBeUndefined(); + }); + + test("server requests evaluate independently without globals, auth calls or loading exposures", async () => { + const me = vi.fn(); + const track = vi.fn(); + const make = (value: ExperimentsContext) => createExperimentsModule({ + context: value, getAuth: () => ({ hasToken: () => true, me }) as unknown as InternalAuthModule, trackExposure: track, + }); + const a = make(context); + const b = make({ ...context, identity: { visitorId: "other", userId: null, status: "anonymous" } }); + expect(await a.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(a.module.getServerSnapshot()).toEqual(a.module.getSnapshot()); + expect(track).not.toHaveBeenCalled(); + expect(a.module.isEnabled("checkout")).toBe(true); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(track).toHaveBeenCalledOnce(); + expect(track.mock.calls[0][1].userId).toBe("user-a"); + expect(me).not.toHaveBeenCalled(); + }); + + test("browser hydration preserves request preview despite conflicting session storage", () => { + const bootstrap = { ...context, preview: { checkout: false } }; + vi.stubGlobal("window", { __B44_EXPERIMENTS_BOOTSTRAP__: bootstrap }); + vi.stubGlobal("document", {}); + vi.stubGlobal("sessionStorage", { getItem: () => '{"checkout":true}' }); + const track = vi.fn(); + const sdk = createExperimentsModule({ + context: getBrowserExperimentsContext("app"), + getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: track, + }); + const initial = sdk.module.getServerSnapshot(); + expect(initial).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(sdk.module.isEnabled("checkout")).toBe(false); + sdk.onAuthStateChange({ status: "anonymous" }); + expect(sdk.module.getServerSnapshot()).toBe(initial); + expect(track).not.toHaveBeenCalled(); + }); + + test("preserves server-rendered flags while common browser auth is still pending", () => { + const serverFlags = { checkout: false }; + const sdk = createExperimentsModule({ + context: { ...context, identity: { ...context.identity, userId: null, status: "pending" }, serverSnapshot: { flags: serverFlags, isLoading: false } }, + getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: vi.fn(), + }); + expect(sdk.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(sdk.module.getServerSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + sdk.onAuthStateChange({ status: "authenticated", userId: "user" }); + serverFlags.checkout = true; + expect(sdk.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(sdk.module.getServerSnapshot().flags.checkout).toBe(false); + }); +}); diff --git a/tests/unit/experiments-evaluator.test.ts b/tests/unit/experiments-evaluator.test.ts new file mode 100644 index 00000000..90899aa2 --- /dev/null +++ b/tests/unit/experiments-evaluator.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { evaluateExperiments } from "../../src/modules/experiments-evaluator.js"; +import type { ExperimentsConfig } from "../../src/modules/experiments-config.types.js"; + +const appId = "66f1a2b3c4d5e6f7a8b9c0d1"; +const config: ExperimentsConfig = { + v: 1, revision: 3, app_id: appId, + flags: [{ key: "checkout-flow", rollout_percentage: 54 }], + experiments: [{ + id: "exp-1", flag_key: "checkout-flow", run_version: 1, + assign_by: "visitor", traffic_allocation: 21, + variants: [{ key: "control", value: false, weight: 9 }, { key: "treatment", value: true, weight: 91 }], + }], +}; + +describe("shared local experiments evaluator", () => { + test.each([ + ["visitor-1", 54, 20, 9], + ["ünïcödé-👩‍💻", 8, 14, 59], + ] as const)("matches Python UTF8 golden boundaries for %s", (visitorId, rollout, enroll, variant) => { + const identity = { visitorId, userId: null }; + const golden: ExperimentsConfig = { + ...config, flags: [{ key: "checkout-flow", rollout_percentage: rollout }], + experiments: [{ ...config.experiments[0], traffic_allocation: enroll + 1, variants: [ + { key: "control", value: false, weight: variant }, { key: "treatment", value: true, weight: 100 - variant }, + ] }], + }; + expect(evaluateExperiments({ ...golden, experiments: [] }, identity).flags["checkout-flow"]).toBe(false); + const result = evaluateExperiments(golden, identity); + expect(result.flags["checkout-flow"]).toBe(true); + expect(result.assignments).toEqual([{ + experiment_id: "exp-1", flag_key: "checkout-flow", run_version: 1, variant_key: "treatment", preview: false, + }]); + expect(evaluateExperiments({ ...golden, experiments: [{ ...golden.experiments[0], traffic_allocation: enroll }] }, identity).assignments).toEqual([]); + }); + + test("user assignment ignores refresh visitor changes and excludes anonymous users", () => { + const userConfig: ExperimentsConfig = { ...config, experiments: [{ ...config.experiments[0], assign_by: "user", traffic_allocation: 100 }] }; + expect(evaluateExperiments(userConfig, { visitorId: "v1", userId: null }).assignments).toEqual([]); + expect(evaluateExperiments(userConfig, { visitorId: "v1", userId: "user" }).assignments) + .toEqual(evaluateExperiments(userConfig, { visitorId: "v2", userId: "user" }).assignments); + }); + + test("explicit preview suppresses enrollment, while inherited names are not overrides", () => { + const named: ExperimentsConfig = { ...config, experiments: [{ ...config.experiments[0], flag_key: "constructor", traffic_allocation: 100 }] }; + const identity = { visitorId: "visitor-1", userId: null }; + expect(evaluateExperiments(named, identity).assignments).toHaveLength(1); + const preview = evaluateExperiments(named, identity, { constructor: false }); + expect(preview.flags.constructor).toBe(false); + expect(preview.assignments).toEqual([]); + }); +}); diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts index a9a3a756..83447a78 100644 --- a/tests/unit/experiments.test.ts +++ b/tests/unit/experiments.test.ts @@ -25,8 +25,8 @@ function setup(hasToken = false) { }); const settle = (index: number, state: AuthState) => { bridge.onAuthStateChange(state); - if (state.status === "authenticated") requests[index].resolve({ id: state.userId } as User); - else requests[index].reject(new Error("lookup failed")); + if (state.status === "authenticated") requests[index]?.resolve({ id: state.userId } as User); + else requests[index]?.reject(new Error("lookup failed")); }; return { ...bridge, runtime, requests, settle, me, trackExposure }; } @@ -77,7 +77,7 @@ describe("browser experiments", () => { expect(b.runtime.userId).toBe("user-1"); expect(observed).toEqual([false]); expect(b.module.isEnabled("checkout")).toBe(true); - expect(b.me).toHaveBeenCalledOnce(); + expect(b.me).not.toHaveBeenCalled(); }); test("snapshots are stable and immutable and observation alone does not expose", async () => { @@ -108,20 +108,20 @@ describe("browser experiments", () => { b.onAuthStateChange({ status: "anonymous" }); expect(b.runtime.userId).toBeNull(); expect(b.module.isEnabled("checkout")).toBe(false); - b.requests[0].resolve({ id: "stale-user" } as User); }); - test("failed identity lookup returns fallbacks and explicit ready retries", async () => { + test("failed common identity lookup returns fallbacks without starting its own retry", async () => { const b = setup(true); const ready = b.module.ready(); b.settle(0, { status: "error" }); expect(await ready).toEqual({ flags: {}, isLoading: false }); expect(b.module.isEnabled("checkout", true)).toBe(true); - expect(b.me).toHaveBeenCalledOnce(); + expect(b.me).not.toHaveBeenCalled(); expect(b.trackExposure).not.toHaveBeenCalled(); + expect(await b.module.ready()).toEqual({ flags: {}, isLoading: false }); + b.onAuthStateChange({ status: "pending" }); const retry = b.module.ready(); - await vi.waitFor(() => expect(b.me).toHaveBeenCalledTimes(2)); - b.settle(1, { status: "authenticated", userId: "user-1" }); + b.settle(0, { status: "authenticated", userId: "user-1" }); expect((await retry).flags.checkout).toBe(true); }); @@ -163,6 +163,5 @@ describe("browser experiments", () => { if (action === "logout") b.onAuthStateChange({ status: "anonymous" }); else b.cleanup(); expect((await ready).isLoading).toBe(false); - b.requests[0].resolve({ id: "obsolete-user" } as User); }); }); From 681a8e014d5a819a50dd5e1cdc41d715d63b8336 Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 10 Sep 2026 23:31:45 +0300 Subject: [PATCH 04/12] fix(experiments): mark preview goals at event occurrence --- src/client.ts | 1 + src/modules/analytics.ts | 13 ++++ tests/unit/experiments-client.test.ts | 93 ++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index f3a812d3..cfb9291e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -291,6 +291,7 @@ export function createClient(config: CreateClientConfig): Base44Client { userAuthModule, enabled: analytics?.enabled ?? true, getVisitorId: experiments.visitorId, + experimentsContext, }), actors: actorsModule.module, cleanup: () => { diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index bddf5892..7149d282 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -12,6 +12,7 @@ import { getSharedInstance } from "../utils/sharedInstance.js"; import type { InternalAuthModule } from "./auth.types"; import { generateUuid, isReactNative } from "../utils/common.js"; import { getExperimentsRuntime } from "./experiments-runtime.types.js"; +import type { ExperimentsContext } from "./experiments-config.types.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -65,6 +66,7 @@ export interface AnalyticsModuleArgs { userAuthModule: InternalAuthModule; enabled: boolean; getVisitorId?: () => string | undefined; + experimentsContext?: ExperimentsContext; } /** @internal */ @@ -79,6 +81,7 @@ export const createAnalyticsModule = ({ userAuthModule, enabled, getVisitorId, + experimentsContext, }: AnalyticsModuleArgs) => { // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; @@ -152,9 +155,19 @@ export const createAnalyticsModule = ({ return; } const intrinsicData = getEventIntrinsicData(); + const preview = Object.fromEntries( + Object.entries(experimentsContext?.preview ?? {}).filter(([, value]) => typeof value === "boolean"), + ); + const properties = { ...params.properties }; + delete properties.__b44_experiment_preview; + if (Object.keys(preview).length) { + // Capture now: a queued event must retain its occurrence-time preview. + properties.__b44_experiment_preview = JSON.stringify(preview); + } analyticsSharedState.requestsQueue.push({ ...params, ...intrinsicData, + properties: params.properties || Object.keys(properties).length ? properties : undefined, }); startProcessing(); }; diff --git a/tests/unit/experiments-client.test.ts b/tests/unit/experiments-client.test.ts index 46a49e57..f0f752d5 100644 --- a/tests/unit/experiments-client.test.ts +++ b/tests/unit/experiments-client.test.ts @@ -1,7 +1,9 @@ import axios from "axios"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createClient, createClientFromRequest } from "../../src/client.js"; +import { resetAnalyticsSessionContext } from "../../src/modules/analytics.js"; import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; +import { getSharedInstance } from "../../src/utils/sharedInstance.js"; vi.mock("partysocket", () => ({ WebSocket: class {} })); @@ -15,11 +17,31 @@ const context: ExperimentsContext = { }; const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); +beforeEach(() => { + const state = getSharedInstance("analytics", () => ({ config: {} })); + Object.assign(state, { requestsQueue: [], isProcessing: false, isHeartBeatProcessing: false, + wasInitializationTracked: true, sessionContext: null, sessionStartTime: null }); + Object.assign(state.config, { enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 30, heartBeatInterval: 0 }); + resetAnalyticsSessionContext(); +}); + afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +function captureAnalytics() { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + return adapter; +} + describe("client experiments integration", () => { test("request context evaluates synchronously and flushes with the request's user token", async () => { const create = axios.create.bind(axios); @@ -71,4 +93,73 @@ describe("client experiments integration", () => { expect(headers.get("Base44-Experiment-Preview")).toBe('{"checkout":false}'); client.cleanup(); }); + + test.each([false, true])("browser goals preserve bootstrap preview %s without a URL override", async (value) => { + vi.useFakeTimers(); + const preview = Object.assign(Object.create({ unrelated: true }), { checkout: value }); + const browserContext = { ...context, identity: { visitorId: "visitor", userId: null }, preview }; + vi.stubGlobal("window", { + __B44_EXPERIMENTS_BOOTSTRAP__: browserContext, + location: { origin: "https://app.example", pathname: "/checkout", search: "" }, + localStorage: { getItem: () => null, setItem: () => {} }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { referrer: "" }); + const adapter = captureAnalytics(); + const client = createClient({ appId: "app" }); + client.analytics.track({ eventName: "purchase", properties: { + amount: 42, __b44_experiment_preview: '{"unrelated":true}', + } }); + await vi.advanceTimersByTimeAsync(1000); + expect(adapter).toHaveBeenCalledOnce(); + const [event] = JSON.parse(adapter.mock.calls[0][0].data).events; + expect(event).toMatchObject({ event_name: "purchase", session_id: "visitor", properties: { + amount: 42, __b44_experiment_preview: JSON.stringify({ checkout: value }), + } }); + client.cleanup(); + }); + + test.each([false, true])("Worker goals retain request-scoped preview %s and all user properties", async (value) => { + vi.useFakeTimers(); + const adapter = captureAnalytics(); + const requestContext = { ...context, identity: { visitorId: "worker-visitor", userId: null }, preview: { checkout: value } }; + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", "Base44-Experiments-Context": encode(requestContext), + } })); + const properties = Object.fromEntries(Array.from({ length: 50 }, (_, index) => [`item_${index}`, index])); + client.analytics.track({ eventName: "purchase", properties }); + await vi.advanceTimersByTimeAsync(1000); + const [event] = JSON.parse(adapter.mock.calls[0][0].data).events; + expect(event).toMatchObject({ event_name: "purchase", session_id: "worker-visitor", properties: { + ...properties, __b44_experiment_preview: JSON.stringify({ checkout: value }), + } }); + expect(Object.keys(event.properties)).toHaveLength(51); + expect(Object.keys(properties)).toHaveLength(50); + client.cleanup(); + }); + + test("queued goals keep occurrence-time previews while normal goals stay unchanged", async () => { + vi.useFakeTimers(); + const adapter = captureAnalytics(); + const experimentsContext: ExperimentsContext = { + ...context, identity: { visitorId: "visitor", userId: null }, preview: {}, + }; + const client = createClient({ appId: "app", experiments: experimentsContext }); + client.analytics.track({ eventName: "warmup" }); + await vi.advanceTimersByTimeAsync(0); + experimentsContext.preview = { checkout: false }; + client.analytics.track({ eventName: "preview_purchase", properties: { amount: 42 } }); + experimentsContext.preview = {}; + client.analytics.track({ eventName: "normal_purchase", properties: { amount: 42 } }); + client.analytics.track({ eventName: "reserved_collision", properties: { __b44_experiment_preview: '{"checkout":true}' } }); + await vi.advanceTimersByTimeAsync(1000); + const events = adapter.mock.calls.flatMap(([request]) => JSON.parse(request.data).events); + expect(events.map(({ event_name, properties }) => ({ event_name, properties }))).toEqual([ + { event_name: "warmup", properties: undefined }, + { event_name: "preview_purchase", properties: { amount: 42, __b44_experiment_preview: '{"checkout":false}' } }, + { event_name: "normal_purchase", properties: { amount: 42 } }, + { event_name: "reserved_collision", properties: {} }, + ]); + client.cleanup(); + }); }); From ed5843a2642a011b15eefb703e6642c87ecdc9ff Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 10 Sep 2026 23:31:57 +0300 Subject: [PATCH 05/12] fix(analytics): isolate concurrent server client state --- src/modules/analytics.ts | 146 ++++++++++++++-------------- src/modules/auth.ts | 4 +- tests/unit/analytics-server.test.ts | 109 +++++++++++++++++++++ tests/unit/analytics.test.ts | 25 +++-- 4 files changed, 202 insertions(+), 82 deletions(-) create mode 100644 tests/unit/analytics-server.test.ts diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 7149d282..47356d72 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -37,15 +37,14 @@ const defaultConfiguration: AnalyticsModuleOptions = { /////////////////////////////////////////////// const ANALYTICS_SHARED_STATE_NAME = "analytics"; -// shared state// -const analyticsSharedState = getSharedInstance( - ANALYTICS_SHARED_STATE_NAME, - () => ({ +function createAnalyticsState() { + return { requestsQueue: [] as TrackEventData[], isProcessing: false, isHeartBeatProcessing: false, wasInitializationTracked: false, sessionContext: null as SessionContext | null, + sessionContextPromise: null as Promise | null, sessionStartTime: null as string | null, // Memoized session id for when `localStorage` can't persist one — see // getAnalyticsSessionId. @@ -54,8 +53,11 @@ const analyticsSharedState = getSharedInstance( ...defaultConfiguration, ...getAnalyticsConfigFromUrlParams(), } as Required, - }) -); + }; +} +type AnalyticsState = ReturnType; +const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState); +const serverAnalyticsStates = new WeakMap(); /////////////////////////////////////////////// @@ -70,8 +72,8 @@ export interface AnalyticsModuleArgs { } /** @internal */ -export function isAnalyticsEnabled(enabled: boolean): boolean { - return enabled && analyticsSharedState.config.enabled && !isReactNative; +export function isAnalyticsEnabled(enabled: boolean, state = analyticsSharedState): boolean { + return enabled && state.config.enabled && !isReactNative; } export const createAnalyticsModule = ({ @@ -83,14 +85,16 @@ export const createAnalyticsModule = ({ getVisitorId, experimentsContext, }: AnalyticsModuleArgs) => { + const state = typeof window === "undefined" ? createAnalyticsState() : analyticsSharedState; + if (typeof window === "undefined") serverAnalyticsStates.set(axiosClient, state); // prevent overflow of events // - const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; + const { maxQueueSize, throttleTime, batchSize } = state.config; // Disable analytics on React Native. It defines `window` but not `document`, // so the per-callsite `typeof window` guards below aren't enough to keep it // from touching `document` (e.g. `document.referrer` on init). Node/SSR is // still handled by those `window` guards, so this doesn't affect it. - if (!isAnalyticsEnabled(enabled)) { + if (!isAnalyticsEnabled(enabled, state)) { return { track: () => {}, cleanup: () => {}, @@ -129,7 +133,7 @@ export const createAnalyticsModule = ({ ) => { if (eventsData.length === 0) return; - const sessionContext_ = await getSessionContext(userAuthModule); + const sessionContext_ = await getSessionContext(userAuthModule, state); const events = eventsData.map( transformEventDataToApiRequestData({ ...sessionContext_, session_id: getVisitorId?.() ?? sessionContext_.session_id }) ); @@ -147,11 +151,11 @@ export const createAnalyticsModule = ({ startAnalyticsProcessor(flush, { throttleTime, batchSize, - }); + }, state); }; const track = (params: TrackEventParams) => { - if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { + if (state.requestsQueue.length >= maxQueueSize) { return; } const intrinsicData = getEventIntrinsicData(); @@ -164,7 +168,7 @@ export const createAnalyticsModule = ({ // Capture now: a queued event must retain its occurrence-time preview. properties.__b44_experiment_preview = JSON.stringify(preview); } - analyticsSharedState.requestsQueue.push({ + state.requestsQueue.push({ ...params, ...intrinsicData, properties: params.properties || Object.keys(properties).length ? properties : undefined, @@ -176,18 +180,18 @@ export const createAnalyticsModule = ({ startAnalyticsProcessor(flush, { throttleTime, batchSize, - }); - clearHeartBeatProcessor = startHeartBeatProcessor(track); - setSessionDurationTimerStart(); + }, state); + clearHeartBeatProcessor = startHeartBeatProcessor(track, state); + setSessionDurationTimerStart(state); }; const onDocHidden = () => { - stopAnalyticsProcessor(); + stopAnalyticsProcessor(state); clearHeartBeatProcessor?.(); - trackSessionDurationEvent(track); + trackSessionDurationEvent(track, state); // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); + const eventsData = state.requestsQueue.splice(0); flush(eventsData, { isBeacon: true }); }; @@ -201,7 +205,7 @@ export const createAnalyticsModule = ({ }; const cleanup = () => { - stopAnalyticsProcessor(); + stopAnalyticsProcessor(state); clearHeartBeatProcessor?.(); if (typeof window !== "undefined") { window.removeEventListener("visibilitychange", onVisibilityChange); @@ -211,9 +215,9 @@ export const createAnalyticsModule = ({ // start the flusing process /// startProcessing(); // start the heart beat processor // - clearHeartBeatProcessor = startHeartBeatProcessor(track); + clearHeartBeatProcessor = startHeartBeatProcessor(track, state); // track the referrer event // - trackInitializationEvent(track); + trackInitializationEvent(track, state); // start the visibility change listener // if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); @@ -225,68 +229,69 @@ export const createAnalyticsModule = ({ }; }; -function stopAnalyticsProcessor() { - analyticsSharedState.isProcessing = false; +function stopAnalyticsProcessor(state: AnalyticsState) { + state.isProcessing = false; } async function startAnalyticsProcessor( handleTrack: (eventsData: TrackEventData[]) => Promise, - options?: { + options: { throttleTime: number; batchSize: number; - } + }, + state: AnalyticsState, ) { - if (analyticsSharedState.isProcessing) { + if (state.isProcessing) { // only one instance of the analytics processor can be running at a time // return; } - analyticsSharedState.isProcessing = true; + state.isProcessing = true; const { throttleTime = 1000, batchSize = 30 } = options ?? {}; while ( - analyticsSharedState.isProcessing && - analyticsSharedState.requestsQueue.length > 0 + state.isProcessing && + state.requestsQueue.length > 0 ) { - const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); + const requests = state.requestsQueue.splice(0, batchSize); requests.length && (await handleTrack(requests)); await new Promise((resolve) => setTimeout(resolve, throttleTime)); } - analyticsSharedState.isProcessing = false; + state.isProcessing = false; } -function startHeartBeatProcessor(track: (params: TrackEventParams) => void) { +function startHeartBeatProcessor(track: (params: TrackEventParams) => void, state: AnalyticsState) { // Browser-only, like the other automatic events here (initialization, session // duration, visibility). Outside a browser this timer fired a `me()` every // interval for the lifetime of a long-lived server-side client, and kept the // Node event loop alive. Explicit `analytics.track()` calls still work. if ( typeof window === "undefined" || - analyticsSharedState.isHeartBeatProcessing || - (analyticsSharedState.config.heartBeatInterval ?? 0) < 10 + state.isHeartBeatProcessing || + (state.config.heartBeatInterval ?? 0) < 10 ) { return () => {}; } - analyticsSharedState.isHeartBeatProcessing = true; + state.isHeartBeatProcessing = true; const interval = setInterval(() => { track({ eventName: USER_HEARTBEAT_EVENT_NAME }); - }, analyticsSharedState.config.heartBeatInterval); + }, state.config.heartBeatInterval); return () => { clearInterval(interval); - analyticsSharedState.isHeartBeatProcessing = false; + state.isHeartBeatProcessing = false; }; } -function trackInitializationEvent(track: (params: TrackEventParams) => void) { +function trackInitializationEvent(track: (params: TrackEventParams) => void, state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.wasInitializationTracked + state.wasInitializationTracked ) { return; } - analyticsSharedState.wasInitializationTracked = true; + state.wasInitializationTracked = true; track({ eventName: ANALYTICS_INITIALIZATION_EVENT_NAME, properties: { @@ -295,25 +300,25 @@ function trackInitializationEvent(track: (params: TrackEventParams) => void) { }); } -function setSessionDurationTimerStart() { +function setSessionDurationTimerStart(state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.sessionStartTime !== null + state.sessionStartTime !== null ) { return; } - analyticsSharedState.sessionStartTime = new Date().toISOString(); + state.sessionStartTime = new Date().toISOString(); } -function trackSessionDurationEvent(track: (params: TrackEventParams) => void) { +function trackSessionDurationEvent(track: (params: TrackEventParams) => void, state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.sessionStartTime === null + state.sessionStartTime === null ) return; const sessionDuration = new Date().getTime() - - new Date(analyticsSharedState.sessionStartTime).getTime(); - analyticsSharedState.sessionStartTime = null; + new Date(state.sessionStartTime).getTime(); + state.sessionStartTime = null; track({ eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME, properties: { sessionDuration }, @@ -339,8 +344,6 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { }); } -let sessionContextPromise: Promise | null = null; - /** * Clears the memoized analytics session context. * @@ -351,26 +354,28 @@ let sessionContextPromise: Promise | null = null; * * @internal */ -export function resetAnalyticsSessionContext() { - analyticsSharedState.sessionContext = null; - sessionContextPromise = null; +export function resetAnalyticsSessionContext(axiosClient?: AxiosInstance) { + const state = axiosClient ? serverAnalyticsStates.get(axiosClient) ?? analyticsSharedState : analyticsSharedState; + state.sessionContext = null; + state.sessionContextPromise = null; } async function getSessionContext( - userAuthModule: InternalAuthModule + userAuthModule: InternalAuthModule, + state: AnalyticsState, ): Promise { - if (!analyticsSharedState.sessionContext) { + if (!state.sessionContext) { // With no token there is no identity to resolve: `me()` can only answer 401, // which the browser logs to the console before any handler here sees it. On // a public page that request is the sole reason an error appears, so skip // it. This is not memoized — a visitor who logs in later must still resolve. if (!userAuthModule.hasToken()) { - return { user_id: null, session_id: getAnalyticsSessionId() }; + return { user_id: null, session_id: getAnalyticsSessionId(state) }; } - if (!sessionContextPromise) { - const sessionId = getAnalyticsSessionId(); - sessionContextPromise = userAuthModule + if (!state.sessionContextPromise) { + const sessionId = getAnalyticsSessionId(state); + state.sessionContextPromise = userAuthModule .me() .then((user) => ({ user_id: user.id, @@ -381,7 +386,7 @@ async function getSessionContext( session_id: sessionId, })); } - const pending = sessionContextPromise; + const pending = state.sessionContextPromise; const context = await pending; // Publish only if this lookup is still the current one. A reset that lands // while the request is in flight nulls `sessionContextPromise`, and an @@ -389,12 +394,12 @@ async function getSessionContext( // for the rest of the session. The awaited value is still returned: these // events were queued before the identity changed, so that is who they // belong to. - if (sessionContextPromise === pending) { - analyticsSharedState.sessionContext = context; + if (state.sessionContextPromise === pending) { + state.sessionContext = context; } return context; } - return analyticsSharedState.sessionContext; + return state.sessionContext; } export function getAnalyticsConfigFromUrlParams(): @@ -422,17 +427,16 @@ export function getAnalyticsConfigFromUrlParams(): return { enabled: analyticsEnable === "true" }; } -// When the id can't be persisted (React Native has no `localStorage`), keep -// it stable for the process instead of minting a fresh one per call. -function getFallbackSessionId(): string { - return (analyticsSharedState.fallbackSessionId ??= generateUuid()); +// Without persistent storage, keep the id stable within this analytics state. +function getFallbackSessionId(state: AnalyticsState): string { + return (state.fallbackSessionId ??= generateUuid()); } -export function getAnalyticsSessionId(): string { +export function getAnalyticsSessionId(state = analyticsSharedState): string { const visitorId = getExperimentsRuntime()?.visitorId; if (visitorId && visitorId !== "anon") return visitorId; if (typeof window === "undefined") { - return getFallbackSessionId(); + return getFallbackSessionId(state); } try { const sessionId = localStorage.getItem( @@ -448,6 +452,6 @@ export function getAnalyticsSessionId(): string { } return sessionId; } catch { - return getFallbackSessionId(); + return getFallbackSessionId(state); } } diff --git a/src/modules/auth.ts b/src/modules/auth.ts index c795ee31..3007c0a7 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -225,7 +225,7 @@ export function createAuthModule( // Drop identity resolved under the previous session: a `me()` already in // flight would otherwise resolve into callers that run after the logout. clearPendingMe(); - resetAnalyticsSessionContext(); + resetAnalyticsSessionContext(axios); hasAccessToken = false; notifyAuthState({ status: "anonymous" }); @@ -258,7 +258,7 @@ export function createAuthModule( // Same reasoning as in `logout`: the identity changes here, so anything // resolved for the previous one must not be handed to later callers. clearPendingMe(); - resetAnalyticsSessionContext(); + resetAnalyticsSessionContext(axios); hasAccessToken = true; // handle token change for axios clients diff --git a/tests/unit/analytics-server.test.ts b/tests/unit/analytics-server.test.ts new file mode 100644 index 00000000..c063dff6 --- /dev/null +++ b/tests/unit/analytics-server.test.ts @@ -0,0 +1,109 @@ +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient, createClientFromRequest } from "../../src/client.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function captureRequests() { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ + data: config.url.endsWith("/entities/User/me") + ? { id: config.headers.get("Authorization").replace("Bearer token-", "user-") } + : { accepted: 1 }, + status: 200, statusText: "OK", headers: {}, config, + })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + const events = () => adapter.mock.calls.map(([config]) => config) + .filter((config) => config.url.endsWith("/analytics/track/batch")) + .flatMap((config) => JSON.parse(config.data).events.map((event: Record) => ({ + url: config.url, authorization: config.headers.get("Authorization"), ...event, + }))); + return { adapter, events }; +} + +function requestClient(appId: string, suffix: string) { + const context = { config: { v: 1, app_id: appId, flags: [], experiments: [] }, + identity: { visitorId: `visitor-${suffix}`, userId: `user-${suffix}`, status: "authenticated" }, preview: {} }; + return createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": appId, "Authorization": `Bearer token-${suffix}`, + "Base44-Experiments-Context": Buffer.from(JSON.stringify(context)).toString("base64url"), + } })); +} + +describe("server analytics request isolation", () => { + test.each(["app-a", "app-b"])("concurrent Worker goals keep each request's app and identity (%s)", async (secondApp) => { + const { events } = captureRequests(); + const a = requestClient("app-a", "a"); + const b = requestClient(secondApp, "b"); + a.analytics.track({ eventName: "goal_a" }); + b.analytics.track({ eventName: "goal_b" }); + await vi.advanceTimersByTimeAsync(1000); + expect(events()).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_name: "goal_a", url: "/apps/app-a/analytics/track/batch", + authorization: "Bearer token-a", user_id: "user-a", session_id: "visitor-a" }), + expect.objectContaining({ event_name: "goal_b", url: `/apps/${secondApp}/analytics/track/batch`, + authorization: "Bearer token-b", user_id: "user-b", session_id: "visitor-b" }), + ])); + expect(events()).toHaveLength(2); + a.cleanup(); b.cleanup(); + }); + + test("cleaning up one request cannot stop another request's queued goal", async () => { + const { events } = captureRequests(); + const a = requestClient("app", "a"); + const b = requestClient("app", "b"); + a.analytics.track({ eventName: "warmup_a" }); + b.analytics.track({ eventName: "warmup_b" }); + await vi.advanceTimersByTimeAsync(0); + b.analytics.track({ eventName: "queued_b" }); + a.cleanup(); + await vi.advanceTimersByTimeAsync(1000); + expect(events().find((event) => event.event_name === "queued_b")).toMatchObject({ + user_id: "user-b", session_id: "visitor-b", authorization: "Bearer token-b", + }); + b.cleanup(); + }); + + test("changing one client's token resets only its own analytics identity", async () => { + const { adapter, events } = captureRequests(); + const a = requestClient("app", "a"); + const b = requestClient("app", "b"); + a.analytics.track({ eventName: "before_a" }); + b.analytics.track({ eventName: "before_b" }); + await vi.advanceTimersByTimeAsync(1000); + a.setToken("token-c"); + a.analytics.track({ eventName: "after_a" }); + b.analytics.track({ eventName: "after_b" }); + await vi.advanceTimersByTimeAsync(1000); + expect(events().find((event) => event.event_name === "after_a")).toMatchObject({ user_id: "user-c" }); + expect(events().find((event) => event.event_name === "after_b")).toMatchObject({ user_id: "user-b" }); + const meRequests = adapter.mock.calls.filter(([config]) => config.url.endsWith("/entities/User/me")); + expect(meRequests).toHaveLength(3); + a.cleanup(); b.cleanup(); + }); + + test("anonymous server clients have independent but stable fallback visitor IDs", async () => { + const { events } = captureRequests(); + const a = createClient({ appId: "app" }); + const b = createClient({ appId: "app" }); + a.analytics.track({ eventName: "first_a" }); + a.analytics.track({ eventName: "second_a" }); + b.analytics.track({ eventName: "first_b" }); + await vi.advanceTimersByTimeAsync(1000); + const visitor = (name: string) => events().find((event) => event.event_name === name).session_id; + expect(visitor("first_a")).toBeTruthy(); + expect(visitor("second_a")).toBe(visitor("first_a")); + expect(visitor("first_b")).not.toBe(visitor("first_a")); + a.cleanup(); b.cleanup(); + }); +}); diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 48e61f60..26db3566 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -22,6 +22,13 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }; + vi.stubGlobal("localStorage", storage); + vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); + vi.stubGlobal("window", { + location: { origin: "https://example.com", pathname: "/", search: "" }, + localStorage: storage, addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); vi.mock("../../src/utils/axios-client.ts", () => ({ createAxiosClient: vi.fn().mockImplementation( () => @@ -46,9 +53,7 @@ describe("Analytics Module", () => { })); sharedState.isProcessing = false; sharedState.requestsQueue = []; - sharedState.sessionContext = { - user_id: "test-user-id", - }; + Object.assign(sharedState, { wasInitializationTracked: true }); sharedState.config = { enabled: true, maxQueueSize: 1000, @@ -64,6 +69,9 @@ describe("Analytics Module", () => { appId, token: "test-access-token", }); + sharedState.sessionContext = { + user_id: "test-user-id", + }; }); afterEach(() => { @@ -157,12 +165,11 @@ describe("Analytics Module", () => { }); test("should not start the heartbeat outside a browser", () => { - const heartBeatState = sharedState as unknown as { - isHeartBeatProcessing: boolean; - }; - - expect(typeof window).toBe("undefined"); - expect(heartBeatState.isHeartBeatProcessing).toBeFalsy(); + vi.stubGlobal("window", undefined); + const setInterval = vi.spyOn(globalThis, "setInterval"); + const server = createClient({ serverUrl, appId }); + expect(setInterval).not.toHaveBeenCalled(); + server.cleanup(); }); test("should not resolve an identity when no token is set", async () => { From f9498cf7ae0e43f6c5e3ffd4a392e2ddb625f14f Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 10 Sep 2026 23:36:33 +0300 Subject: [PATCH 06/12] test(auth): scope analytics reset assertion to browser client --- tests/unit/auth.test.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index c79df86b..21638b7f 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -177,13 +177,24 @@ describe('Auth Module', () => { expect(scope.isDone()).toBe(true); }); - test('setToken() clears the analytics session context', () => { - const analyticsState = getSharedInstance('analytics', () => ({})); - analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; - - base44.auth.setToken('new-access-token', false); - - expect(analyticsState.sessionContext).toBeNull(); + test('setToken() clears the shared browser analytics session context', () => { + vi.stubGlobal('window', { + location: { origin: appBaseUrl, pathname: '/', search: '' }, + localStorage: { getItem: () => null }, + }); + let browserClient; + try { + browserClient = createClient({ serverUrl, appId, appBaseUrl, analytics: { enabled: false } }); + const analyticsState = getSharedInstance('analytics', () => ({})); + analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; + + browserClient.auth.setToken('new-access-token', false); + + expect(analyticsState.sessionContext).toBeNull(); + } finally { + browserClient?.cleanup(); + vi.unstubAllGlobals(); + } }); }); @@ -967,4 +978,4 @@ describe('Auth Module', () => { global.window = originalWindow; }); }); -}); \ No newline at end of file +}); From 5f5914a87bdc0a670383e8afbb6394154ebd4c3f Mon Sep 17 00:00:00 2001 From: liorma Date: Mon, 14 Sep 2026 12:06:01 +0300 Subject: [PATCH 07/12] fix(experiments): bound best-effort exposure delivery --- src/modules/experiment-exposures.ts | 40 +++++++++++----- src/modules/experiments.types.ts | 19 +++++--- tests/unit/experiment-exposures.test.ts | 63 ++++++++++++++++++++----- 3 files changed, 91 insertions(+), 31 deletions(-) diff --git a/src/modules/experiment-exposures.ts b/src/modules/experiment-exposures.ts index dfe73efb..140af3be 100644 --- a/src/modules/experiment-exposures.ts +++ b/src/modules/experiment-exposures.ts @@ -1,7 +1,9 @@ -import type { AxiosInstance } from "axios"; +import type { AxiosError, AxiosInstance } from "axios"; import { v4 as uuid } from "uuid"; import { isAnalyticsEnabled } from "./analytics.js"; +const DELIVERY_BUDGET_MS = 5000; + /** @internal */ export function createExposureTracker({ axiosClient, appId, enabled, source = "browser", pageUrl, @@ -15,34 +17,46 @@ export function createExposureTracker({ type Entry = { data: { events: Record[] }; authorization: string | null; - acknowledged: boolean; + settled: boolean; pending?: Promise; }; const entries = new Map(); function send(entry: Entry): Promise { if (entry.pending) return entry.pending; - const pending = (async () => { + const controller = new AbortController(); + const deadline = new Promise((resolve) => { + controller.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + const timeout = setTimeout(() => controller.abort(), DELIVERY_BUDGET_MS); + const delivery = (async () => { for (let attempt = 0; ; attempt++) { try { - const response = await axiosClient.request({ + if (controller.signal.aborted) return; + await axiosClient.request({ method: "POST", url: `/apps/${appId}/analytics/track/batch`, headers: { Authorization: entry.authorization }, data: entry.data, + timeout: DELIVERY_BUDGET_MS, + signal: controller.signal, }); - if (response.accepted !== 1) throw new Error("Experiment exposure was not accepted"); - entry.acknowledged = true; return; } catch (error) { - if (attempt === 2) throw error; + const status = (error as AxiosError).response?.status ?? (error as AxiosError).status; + if (controller.signal.aborted || attempt === 2 || (status !== undefined && (status < 500 || status >= 600))) return; await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); } } - })().finally(() => { entry.pending = undefined; }); + })(); + // Also settle flush when a stalled transport ignores cancellation. + const pending = Promise.race([delivery, deadline]).finally(() => { + clearTimeout(timeout); + controller.abort(); + entry.settled = true; + entry.pending = undefined; + }); entry.pending = pending; - // Reads stay synchronous; flush() lets request handlers observe delivery failures. - void pending.catch(() => {}); return pending; } @@ -58,7 +72,7 @@ export function createExposureTracker({ if (!entry) { const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; entry = { - acknowledged: false, + settled: false, authorization: typeof authorization === "string" ? authorization : null, data: { events: [{ event_id: uuid(), @@ -71,10 +85,10 @@ export function createExposureTracker({ }; entries.set(key, entry); } - if (!entry.acknowledged) void send(entry); + if (!entry.settled) void send(entry); }, async flush(): Promise { - await Promise.all([...entries.values()].filter((entry) => !entry.acknowledged).map(send)); + await Promise.all([...entries.values()].filter((entry) => !entry.settled).map(send)); }, }; } diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts index c2456aa4..f99d5b66 100644 --- a/src/modules/experiments.types.ts +++ b/src/modules/experiments.types.ts @@ -26,7 +26,7 @@ export interface ExperimentsSnapshot { */ export interface ExperimentsModule { /** - * Reads a flag and queues an acknowledged exposure for its current assignment. + * Reads a flag and queues a best-effort exposure for its current assignment. * * Never starts an authentication request. Reads return the fallback while the * app's normal auth initialization is pending or failed. Supply trusted bootstrap @@ -35,8 +35,10 @@ export interface ExperimentsModule { * Call only where the feature is used: a read counts as exposure, not proof of * visibility. Preview overrides and flags without an assignment are not tracked. * Exposures respect the client's analytics setting, are deduplicated per client, - * experiment run, variant and identity. Failed sends retry up to three attempts - * with the same event ID, timestamp and credentials. Await flush() on servers. + * experiment run, variant and identity. Network and server failures retry up to + * three attempts within five seconds, preserving the event ID, timestamp and + * credentials. HTTP successes (including rejected measurements) and client errors + * are terminal. On servers, use the runtime's background lifetime mechanism. * * @param flagKey - Feature flag key defined in your app. * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`. @@ -103,10 +105,13 @@ export interface ExperimentsModule { ready(): Promise; /** - * Waits until queued exposures are acknowledged; rejects after bounded retries. - * Server/Worker handlers must await this before ending the request (or use waitUntil). - * Retries preserve event IDs but raw storage is not exactly-once. Calling again - * retries unacknowledged events with the same IDs. No new exposures are created. + * Waits for pending best-effort deliveries to settle without rejecting. + * Each delivery has a five-second total budget; exhausted or rejected events are + * dropped and are not retried by later reads or flushes. Settlement is not proof + * of ingestion, and raw storage is not exactly-once. No new exposures are created. + * Worker handlers should use `ctx.waitUntil(client.experiments.flush())` instead + * of awaiting Analytics on the application response path. Other runtimes must use + * their supported background lifetime mechanism; fire-and-forget alone may be cut off. */ flush(): Promise; } diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts index 0f5739ac..635edd78 100644 --- a/tests/unit/experiment-exposures.test.ts +++ b/tests/unit/experiment-exposures.test.ts @@ -41,6 +41,8 @@ describe("experiment exposure transport", () => { method: "POST", url: `/apps/${appId}/analytics/track/batch`, headers: { Authorization: "Bearer user-1-token" }, + timeout: 5000, + signal: expect.any(AbortSignal), data: { events: [{ event_name: "__experiment_exposure__", event_id: expect.any(String), @@ -122,26 +124,65 @@ describe("experiment exposure transport", () => { vi.useRealTimers(); }); - test("backend flush rejects unaccepted batches and a later flush reuses the same event", async () => { - vi.useFakeTimers(); + test.each([0, 1])("backend flush settles accepted %s without resending on later reads", async (accepted) => { vi.stubGlobal("window", undefined); - request.mockResolvedValue({ accepted: 0 }); + request.mockResolvedValue({ accepted }); const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend", pageUrl: "/checkout" }); tracker.track(assignment, identity); - const failed = expect(tracker.flush()).rejects.toThrow("not accepted"); + await expect(tracker.flush()).resolves.toBeUndefined(); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0][0].data.events[0]).toMatchObject({ properties: { source: "backend" }, page_url: "/checkout" }); + }); + + test.each([400, 401, 403, 429])("terminal HTTP %s does not reject or resend", async (status) => { + request.mockRejectedValue({ status }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + await expect(tracker.flush()).resolves.toBeUndefined(); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledOnce(); + }); + + test.each([new Error("offline"), { response: { status: 503 } }])("exhausted transient delivery settles without changing the event or credentials", async (error) => { + vi.useFakeTimers(); + request.mockRejectedValue(error); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend" }); + tracker.track(assignment, identity); + const settled = expect(tracker.flush()).resolves.toBeUndefined(); + client.defaults.headers.common.Authorization = "Bearer replacement"; await vi.advanceTimersByTimeAsync(600); - await failed; + await settled; expect(request).toHaveBeenCalledTimes(3); const initial = request.mock.calls[0][0]; - expect(initial.data.events[0].properties.source).toBe("backend"); - expect(initial.data.events[0].page_url).toBe("/checkout"); - request.mockResolvedValue({ accepted: 1 }); + expect(request.mock.calls[1][0]).toEqual(initial); + expect(request.mock.calls[2][0]).toEqual(initial); + tracker.track(assignment, identity); await tracker.flush(); - expect(request).toHaveBeenCalledTimes(4); - expect(request.mock.calls[3][0]).toEqual(initial); + expect(request).toHaveBeenCalledTimes(3); + }); + + test("a stalled transport is cancelled at the total budget without failing the Worker", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", undefined); + request.mockReturnValue(new Promise(() => {})); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend" }); + tracker.track(assignment, identity); + const settled = vi.fn(); + const delivery = tracker.flush().then(settled); + await vi.advanceTimersByTimeAsync(4999); + expect(settled).not.toHaveBeenCalled(); + const signal = request.mock.calls[0][0].signal; + expect(signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await delivery; + expect(settled).toHaveBeenCalledOnce(); + expect(signal.aborted).toBe(true); tracker.track(assignment, identity); await tracker.flush(); - expect(request).toHaveBeenCalledTimes(4); + expect(request).toHaveBeenCalledOnce(); }); test.each(["user-1", null])("pins Authorization before defaults change for %s", async (userId) => { From a5e1dbabf0964703912dd29581b50a0a417c32f6 Mon Sep 17 00:00:00 2001 From: liorma Date: Mon, 14 Sep 2026 13:32:02 +0300 Subject: [PATCH 08/12] fix(experiments): share bounded analytics delivery batches --- src/modules/analytics-queue.ts | 123 ++++++++ src/modules/analytics.ts | 169 +++------- src/modules/experiment-exposures.ts | 90 ++---- src/modules/experiments.types.ts | 21 +- tests/unit/analytics.test.ts | 396 ++++++++++++------------ tests/unit/experiment-exposures.test.ts | 53 +++- tests/unit/experiments-client.test.ts | 76 +++++ 7 files changed, 501 insertions(+), 427 deletions(-) create mode 100644 src/modules/analytics-queue.ts diff --git a/src/modules/analytics-queue.ts b/src/modules/analytics-queue.ts new file mode 100644 index 00000000..194f3d6e --- /dev/null +++ b/src/modules/analytics-queue.ts @@ -0,0 +1,123 @@ +import type { AxiosError, AxiosInstance } from "axios"; +import type { AnalyticsApiRequestData, AnalyticsModuleOptions } from "./analytics.types.js"; + +const DELIVERY_BUDGET_MS = 5000; +type Event = AnalyticsApiRequestData & { event_id?: string }; +type Entry = { event: Promise; authorization: string | null; userId: Promise }; +type PreparedEntry = { event: Event; authorization: string | null; userId: string | null }; +const queues = new WeakMap>(); + +/** @internal One transport queue per client, shared by goals and exposures. */ +export function getAnalyticsQueue(axiosClient: AxiosInstance, appId: string, config: AnalyticsModuleOptions) { + let queue = queues.get(axiosClient); + if (!queue) { + queue = createAnalyticsQueue(axiosClient, appId, config); + queues.set(axiosClient, queue); + } + return queue; +} + +function createAnalyticsQueue(axiosClient: AxiosInstance, appId: string, config: AnalyticsModuleOptions) { + const entries: Entry[] = []; + const pending = new Set>(); + let timer: ReturnType | undefined; + + function deliver(batch: Entry[]) { + const controller = new AbortController(); + const deadlineAt = Date.now() + DELIVERY_BUDGET_MS; + const deadline = new Promise((resolve) => { + controller.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + const timeout = setTimeout(() => controller.abort(), DELIVERY_BUDGET_MS); + function send(prepared: PreparedEntry[]) { + const groups = new Map(); + for (const entry of prepared) { + const key = JSON.stringify([entry.authorization, entry.userId, entry.event.session_id]); + const group = groups.get(key) ?? []; + group.push(entry); + groups.set(key, group); + } + return Promise.all([...groups.values()].map(async (group) => { + const events = group.map(({ event }) => event); + const exposures = events.filter((event) => event.event_name === "__experiment_exposure__"); + const attempts = exposures.length ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + if (controller.signal.aborted) return; + await axiosClient.request({ + method: "POST", url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: group[0].authorization }, + // Ordinary goals have no backend deduplication and remain single-attempt. + data: { events: attempt === 0 ? events : exposures }, + timeout: Math.max(1, deadlineAt - Date.now()), signal: controller.signal, + }); + return; + } catch (error) { + const status = (error as AxiosError).response?.status ?? (error as AxiosError).status; + if (controller.signal.aborted || attempt === attempts - 1 || + (status !== undefined && (status < 500 || status >= 600))) return; + await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); + } + } + })); + } + const delivery = (async () => { + const ready: PreparedEntry[] = []; + const requests: Promise[] = []; + let wake = () => {}; + let preparedAll = false; + const preparation = Promise.all(batch.map(async (entry) => { + const [event, userId] = await Promise.all([entry.event, entry.userId]); + if (event && !controller.signal.aborted) ready.push({ event, userId, authorization: entry.authorization }); + wake(); + })).then(() => { preparedAll = true; wake(); }); + while (!preparedAll || ready.length) { + if (!ready.length && !preparedAll) await Promise.race([ + new Promise((resolve) => { wake = resolve; }), deadline, + ]); + if (controller.signal.aborted) return; + // Coalesce this turn's resolved identities without waiting for unrelated auth I/O. + let turnTimer: ReturnType | undefined; + await Promise.race([preparation, new Promise((resolve) => { turnTimer = setTimeout(resolve, 0); })]); + clearTimeout(turnTimer); + if (ready.length) requests.push(send(ready.splice(0))); + } + await Promise.all(requests); + })(); + // Identity lookup and transports that ignore cancellation must also be bounded. + const settlement = Promise.race([delivery, deadline]).catch(() => {}).finally(() => { + clearTimeout(timeout); + controller.abort(); + pending.delete(settlement); + }); + pending.add(settlement); + } + + function schedule() { + if (timer || entries.length === 0) return; + timer = setTimeout(() => { + timer = undefined; + deliver(entries.splice(0, config.batchSize ?? 30)); + schedule(); + }, config.throttleTime ?? 1000); + } + + return { + enqueue(event: Event | Promise, authorization: string | null, userId: string | null | Promise) { + if (entries.length >= (config.maxQueueSize ?? 1000)) return; + entries.push({ event: Promise.resolve(event).catch(() => undefined), authorization, + userId: Promise.resolve(userId).catch(() => null) }); + schedule(); + }, + async flush() { + clearTimeout(timer); + timer = undefined; + while (entries.length) deliver(entries.splice(0, config.batchSize ?? 30)); + await Promise.all([...pending]); + }, + cleanup() { + clearTimeout(timer); + timer = undefined; + }, + }; +} diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 47356d72..e7338634 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -1,9 +1,6 @@ import { AxiosInstance } from "axios"; import { TrackEventParams, - TrackEventData, - AnalyticsApiRequestData, - AnalyticsApiBatchRequest, TrackEventIntrinsicData, AnalyticsModuleOptions, SessionContext, @@ -13,6 +10,7 @@ import type { InternalAuthModule } from "./auth.types"; import { generateUuid, isReactNative } from "../utils/common.js"; import { getExperimentsRuntime } from "./experiments-runtime.types.js"; import type { ExperimentsContext } from "./experiments-config.types.js"; +import { getAnalyticsQueue } from "./analytics-queue.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -32,15 +30,9 @@ const defaultConfiguration: AnalyticsModuleOptions = { heartBeatInterval: 60 * 1000, }; -/////////////////////////////////////////////// -//// shared queue for analytics events //// -/////////////////////////////////////////////// - const ANALYTICS_SHARED_STATE_NAME = "analytics"; function createAnalyticsState() { return { - requestsQueue: [] as TrackEventData[], - isProcessing: false, isHeartBeatProcessing: false, wasInitializationTracked: false, sessionContext: null as SessionContext | null, @@ -57,9 +49,20 @@ function createAnalyticsState() { } type AnalyticsState = ReturnType; const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState); -const serverAnalyticsStates = new WeakMap(); +const clientAnalyticsStates = new WeakMap(); -/////////////////////////////////////////////// +/** @internal */ +export function getAnalyticsState(axiosClient: AxiosInstance): AnalyticsState { + let state = clientAnalyticsStates.get(axiosClient); + if (!state) { + state = createAnalyticsState(); + if (typeof window !== "undefined") { + state.config = analyticsSharedState.config; + } + clientAnalyticsStates.set(axiosClient, state); + } + return state; +} export interface AnalyticsModuleArgs { axiosClient: AxiosInstance; @@ -78,17 +81,15 @@ export function isAnalyticsEnabled(enabled: boolean, state = analyticsSharedStat export const createAnalyticsModule = ({ axiosClient, - serverUrl, appId, userAuthModule, enabled, getVisitorId, experimentsContext, }: AnalyticsModuleArgs) => { - const state = typeof window === "undefined" ? createAnalyticsState() : analyticsSharedState; - if (typeof window === "undefined") serverAnalyticsStates.set(axiosClient, state); - // prevent overflow of events // - const { maxQueueSize, throttleTime, batchSize } = state.config; + const state = getAnalyticsState(axiosClient); + const automaticState = typeof window === "undefined" ? state : analyticsSharedState; + const queue = getAnalyticsQueue(axiosClient, appId, state.config); // Disable analytics on React Native. It defines `window` but not `document`, // so the per-callsite `typeof window` guards below aren't enough to keep it @@ -102,63 +103,11 @@ export const createAnalyticsModule = ({ } let clearHeartBeatProcessor: (() => void) | undefined = undefined; - const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; - - const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { - await axiosClient.request({ - method: "POST", - url: `/apps/${appId}/analytics/track/batch`, - data: { events }, - } as AnalyticsApiBatchRequest); - }; - - // currently disabled, until fully tested // - const beaconRequest = (events: AnalyticsApiRequestData[]) => { - try { - const beaconPayload = JSON.stringify({ events }); - const blob = new Blob([beaconPayload], { type: "application/json" }); - return ( - typeof navigator === "undefined" || - beaconPayload.length > 60000 || - !navigator.sendBeacon(trackBatchUrl, blob) - ); - } catch { - return false; - } - }; - - const flush = async ( - eventsData: TrackEventData[], - options: { isBeacon?: boolean } = {} - ) => { - if (eventsData.length === 0) return; - - const sessionContext_ = await getSessionContext(userAuthModule, state); - const events = eventsData.map( - transformEventDataToApiRequestData({ ...sessionContext_, session_id: getVisitorId?.() ?? sessionContext_.session_id }) - ); - - try { - if (!options.isBeacon || !beaconRequest(events)) { - await batchRequestFallback(events); - } - } catch { - // do nothing - } - }; - - const startProcessing = () => { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }, state); - }; - const track = (params: TrackEventParams) => { - if (state.requestsQueue.length >= maxQueueSize) { - return; - } const intrinsicData = getEventIntrinsicData(); + const visitorId = getVisitorId?.() ?? getAnalyticsSessionId(state); + const authorization = userAuthModule.hasToken() ? axiosClient.defaults.headers.common.Authorization : null; + const context = getSessionContext(userAuthModule, state); const preview = Object.fromEntries( Object.entries(experimentsContext?.preview ?? {}).filter(([, value]) => typeof value === "boolean"), ); @@ -168,31 +117,27 @@ export const createAnalyticsModule = ({ // Capture now: a queued event must retain its occurrence-time preview. properties.__b44_experiment_preview = JSON.stringify(preview); } - state.requestsQueue.push({ - ...params, - ...intrinsicData, + const event = { + event_name: params.eventName, + timestamp: intrinsicData.timestamp, + page_url: intrinsicData.pageUrl, properties: params.properties || Object.keys(properties).length ? properties : undefined, - }); - startProcessing(); + }; + queue.enqueue(context.then((identity) => ({ ...event, ...identity, session_id: visitorId })), + typeof authorization === "string" ? authorization : null, + context.then((identity) => identity.user_id ?? null)); }; const onDocVisible = () => { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }, state); - clearHeartBeatProcessor = startHeartBeatProcessor(track, state); - setSessionDurationTimerStart(state); + clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState); + setSessionDurationTimerStart(automaticState); }; const onDocHidden = () => { - stopAnalyticsProcessor(state); clearHeartBeatProcessor?.(); - trackSessionDurationEvent(track, state); + trackSessionDurationEvent(track, automaticState); - // flush entire queue on visibility change and hope for the best // - const eventsData = state.requestsQueue.splice(0); - flush(eventsData, { isBeacon: true }); + void queue.flush(); }; const onVisibilityChange = () => { @@ -205,19 +150,17 @@ export const createAnalyticsModule = ({ }; const cleanup = () => { - stopAnalyticsProcessor(state); + queue.cleanup(); clearHeartBeatProcessor?.(); if (typeof window !== "undefined") { window.removeEventListener("visibilitychange", onVisibilityChange); } }; - // start the flusing process /// - startProcessing(); // start the heart beat processor // - clearHeartBeatProcessor = startHeartBeatProcessor(track, state); + clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState); // track the referrer event // - trackInitializationEvent(track, state); + trackInitializationEvent(track, automaticState); // start the visibility change listener // if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); @@ -229,36 +172,6 @@ export const createAnalyticsModule = ({ }; }; -function stopAnalyticsProcessor(state: AnalyticsState) { - state.isProcessing = false; -} - -async function startAnalyticsProcessor( - handleTrack: (eventsData: TrackEventData[]) => Promise, - options: { - throttleTime: number; - batchSize: number; - }, - state: AnalyticsState, -) { - if (state.isProcessing) { - // only one instance of the analytics processor can be running at a time // - return; - } - state.isProcessing = true; - - const { throttleTime = 1000, batchSize = 30 } = options ?? {}; - while ( - state.isProcessing && - state.requestsQueue.length > 0 - ) { - const requests = state.requestsQueue.splice(0, batchSize); - requests.length && (await handleTrack(requests)); - await new Promise((resolve) => setTimeout(resolve, throttleTime)); - } - state.isProcessing = false; -} - function startHeartBeatProcessor(track: (params: TrackEventParams) => void, state: AnalyticsState) { // Browser-only, like the other automatic events here (initialization, session // duration, visibility). Outside a browser this timer fired a `me()` every @@ -334,16 +247,6 @@ function getEventIntrinsicData(): TrackEventIntrinsicData { }; } -function transformEventDataToApiRequestData(sessionContext: SessionContext) { - return (eventData: TrackEventData): AnalyticsApiRequestData => ({ - event_name: eventData.eventName, - properties: eventData.properties, - timestamp: eventData.timestamp, - page_url: eventData.pageUrl, - ...sessionContext, - }); -} - /** * Clears the memoized analytics session context. * @@ -355,7 +258,7 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { * @internal */ export function resetAnalyticsSessionContext(axiosClient?: AxiosInstance) { - const state = axiosClient ? serverAnalyticsStates.get(axiosClient) ?? analyticsSharedState : analyticsSharedState; + const state = axiosClient ? getAnalyticsState(axiosClient) : analyticsSharedState; state.sessionContext = null; state.sessionContextPromise = null; } diff --git a/src/modules/experiment-exposures.ts b/src/modules/experiment-exposures.ts index 140af3be..ec41af94 100644 --- a/src/modules/experiment-exposures.ts +++ b/src/modules/experiment-exposures.ts @@ -1,8 +1,7 @@ -import type { AxiosError, AxiosInstance } from "axios"; +import type { AxiosInstance } from "axios"; import { v4 as uuid } from "uuid"; -import { isAnalyticsEnabled } from "./analytics.js"; - -const DELIVERY_BUDGET_MS = 5000; +import { getAnalyticsState, isAnalyticsEnabled } from "./analytics.js"; +import { getAnalyticsQueue } from "./analytics-queue.js"; /** @internal */ export function createExposureTracker({ @@ -14,81 +13,30 @@ export function createExposureTracker({ source?: "browser" | "backend"; pageUrl?: string; }) { - type Entry = { - data: { events: Record[] }; - authorization: string | null; - settled: boolean; - pending?: Promise; - }; - const entries = new Map(); - - function send(entry: Entry): Promise { - if (entry.pending) return entry.pending; - const controller = new AbortController(); - const deadline = new Promise((resolve) => { - controller.signal.addEventListener("abort", () => resolve(), { once: true }); - }); - const timeout = setTimeout(() => controller.abort(), DELIVERY_BUDGET_MS); - const delivery = (async () => { - for (let attempt = 0; ; attempt++) { - try { - if (controller.signal.aborted) return; - await axiosClient.request({ - method: "POST", - url: `/apps/${appId}/analytics/track/batch`, - headers: { Authorization: entry.authorization }, - data: entry.data, - timeout: DELIVERY_BUDGET_MS, - signal: controller.signal, - }); - return; - } catch (error) { - const status = (error as AxiosError).response?.status ?? (error as AxiosError).status; - if (controller.signal.aborted || attempt === 2 || (status !== undefined && (status < 500 || status >= 600))) return; - await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); - } - } - })(); - // Also settle flush when a stalled transport ignores cancellation. - const pending = Promise.race([delivery, deadline]).finally(() => { - clearTimeout(timeout); - controller.abort(); - entry.settled = true; - entry.pending = undefined; - }); - entry.pending = pending; - return pending; - } + const state = getAnalyticsState(axiosClient); + const queue = getAnalyticsQueue(axiosClient, appId, state.config); + const tracked = new Set(); return { track( assignment: { experiment_id: string; run_version: number; variant_key: string }, identity: { visitorId: string; userId: string | null }, ): void { - if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled)) return; + if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled, state)) return; const { experiment_id, run_version, variant_key } = assignment; const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]); - let entry = entries.get(key); - if (!entry) { - const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; - entry = { - settled: false, - authorization: typeof authorization === "string" ? authorization : null, - data: { events: [{ - event_id: uuid(), - event_name: "__experiment_exposure__", - timestamp: new Date().toISOString(), - session_id: identity.visitorId, - page_url: pageUrl ?? (typeof window === "undefined" ? "/" : window.location.pathname), - properties: { experiment_id, run_version, variant_key, source }, - }] }, - }; - entries.set(key, entry); - } - if (!entry.settled) void send(entry); - }, - async flush(): Promise { - await Promise.all([...entries.values()].filter((entry) => !entry.settled).map(send)); + if (tracked.has(key)) return; + tracked.add(key); + const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; + queue.enqueue({ + event_id: uuid(), + event_name: "__experiment_exposure__", + timestamp: new Date().toISOString(), + session_id: identity.visitorId, + page_url: pageUrl ?? (typeof window === "undefined" ? "/" : window.location.pathname), + properties: { experiment_id, run_version, variant_key, source }, + }, typeof authorization === "string" ? authorization : null, identity.userId); }, + flush: queue.flush, }; } diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts index f99d5b66..eff3ebea 100644 --- a/src/modules/experiments.types.ts +++ b/src/modules/experiments.types.ts @@ -36,9 +36,12 @@ export interface ExperimentsModule { * visibility. Preview overrides and flags without an assignment are not tracked. * Exposures respect the client's analytics setting, are deduplicated per client, * experiment run, variant and identity. Network and server failures retry up to - * three attempts within five seconds, preserving the event ID, timestamp and - * credentials. HTTP successes (including rejected measurements) and client errors - * are terminal. On servers, use the runtime's background lifetime mechanism. + * three attempts within five seconds of batch delivery, preserving the event ID, + * timestamp and credentials. HTTP successes (including rejected measurements) and client errors + * are terminal. Exposures share the Analytics batch with compatible ordinary + * events; credentials and user/visitor identities are captured when tracking. + * Only exposures are retried; ordinary goals retain single-attempt delivery. + * On servers, use the runtime's background lifetime mechanism. * * @param flagKey - Feature flag key defined in your app. * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`. @@ -105,13 +108,23 @@ export interface ExperimentsModule { ready(): Promise; /** - * Waits for pending best-effort deliveries to settle without rejecting. + * Flushes this client's queued Analytics goals and exposures without rejecting. * Each delivery has a five-second total budget; exhausted or rejected events are * dropped and are not retried by later reads or flushes. Settlement is not proof * of ingestion, and raw storage is not exactly-once. No new exposures are created. * Worker handlers should use `ctx.waitUntil(client.experiments.flush())` instead * of awaiting Analytics on the application response path. Other runtimes must use * their supported background lifetime mechanism; fire-and-forget alone may be cut off. + * Base44's legacy Cloudflare runtime exposes `globalThis.Base44.waitUntil(...)`; + * the newer runtime exports `waitUntil` from `base44:runtime`. Use the API provided + * by your deployed runtime. Deno without a background lifetime API must await flush. + * + * @returns A promise that resolves when the current batch deliveries settle. + * @example + * ```typescript + * // In a Worker handler with an execution context: + * ctx.waitUntil(base44.experiments.flush()); + * ``` */ flush(): Promise; } diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 26db3566..ea8ee52e 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -1,248 +1,232 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import { - AnalyticsModuleOptions, - createClient, - SessionContext, - TrackEventData, -} from "../../src/index.ts"; -import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; -import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; -import { InternalAuthModule, User } from "../../src/modules/auth.types.ts"; -import { AxiosInstance } from "axios"; +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient } from "../../src/client.js"; +import { getSharedInstance } from "../../src/utils/sharedInstance.js"; +import type { AnalyticsModuleOptions } from "../../src/modules/analytics.types.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); describe("Analytics Module", () => { - let base44: ReturnType; - let sharedState: null | { - requestsQueue: TrackEventData[]; - isProcessing: boolean; - sessionContext: SessionContext; - config: AnalyticsModuleOptions; + let client: ReturnType; + let adapter: ReturnType; + let config: AnalyticsModuleOptions; + const clients: ReturnType[] = []; + const events = () => batches().flatMap((request) => JSON.parse(request.data).events); + const batches = () => adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + const makeClient = (options = {}) => { + const result = createClient({ appId: "app", ...options }); + clients.push(result); + return result; }; - const appId = "test-app-id"; - const serverUrl = "https://api.base44.com"; beforeEach(() => { - const storage = { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }; + vi.useFakeTimers(); + const stored = new Map(); + const storage = { getItem: vi.fn((key) => stored.get(key) ?? null), + setItem: vi.fn((key, value) => stored.set(key, value)), removeItem: vi.fn((key) => stored.delete(key)) }; vi.stubGlobal("localStorage", storage); vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); vi.stubGlobal("window", { location: { origin: "https://example.com", pathname: "/", search: "" }, localStorage: storage, addEventListener: vi.fn(), removeEventListener: vi.fn(), }); - vi.mock("../../src/utils/axios-client.ts", () => ({ - createAxiosClient: vi.fn().mockImplementation( - () => - ({ - // `setToken` and `logout` write through to these, so the mock needs - // them present per instance. - defaults: { headers: { common: {} as Record } }, - request: vi.fn().mockResolvedValue({ - status: 200, - data: { - message: "success", - }, - }), - } as unknown as AxiosInstance) - ), + const shared = getSharedInstance("analytics", () => ({ config: {} })); + config = shared.config; + Object.assign(config, { enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 2, heartBeatInterval: 0 }); + Object.assign(shared, { wasInitializationTracked: true, isHeartBeatProcessing: false, sessionStartTime: null }); + const create = axios.create.bind(axios); + adapter = vi.fn(async (request) => ({ + data: request.url.endsWith("/entities/User/me") + ? { id: request.headers.get("Authorization").replace("Bearer token-", "user-") } + : { accepted: 1 }, + status: 200, statusText: "OK", headers: {}, config: request, })); - sharedState = getSharedInstance("analytics", () => ({ - requestsQueue: [], - isProcessing: false, - sessionContext: {}, - config: {}, - })); - sharedState.isProcessing = false; - sharedState.requestsQueue = []; - Object.assign(sharedState, { wasInitializationTracked: true }); - sharedState.config = { - enabled: true, - maxQueueSize: 1000, - throttleTime: 1000, - batchSize: 2, - heartBeatInterval: undefined, - }; - - // Token-bearing by default: most tests here exercise the flush path that - // resolves an identity, and that lookup is skipped without a session. - base44 = createClient({ - serverUrl, - appId, - token: "test-access-token", + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; }); - sharedState.sessionContext = { - user_id: "test-user-id", - }; + client = makeClient(); }); afterEach(() => { - vi.clearAllMocks(); - base44.cleanup(); + for (const client of clients.splice(0)) client.cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); - sharedState = null; }); - test("should create analytics module with shared state", () => { - expect(base44.analytics).toBeDefined(); - expect(sharedState).toBeDefined(); - expect(sharedState?.requestsQueue).toBeDefined(); - expect(sharedState?.isProcessing).toBe(false); + test("captures the event's time and properties before the scheduled batch", async () => { + const properties = { amount: 42 }; + const timestamp = new Date().toISOString(); + client.analytics.track({ eventName: "purchase", properties }); + properties.amount = 99; + expect(batches()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1000); + expect(events()).toEqual([expect.objectContaining({ + event_name: "purchase", timestamp, properties: { amount: 42 }, + })]); }); - test("should track an event", () => { - vi.spyOn(base44.analytics, "track"); - - base44.analytics.track({ eventName: "test-event" }); - expect(sharedState?.isProcessing).toBe(true); - expect(base44.analytics.track).toHaveBeenCalledWith({ - eventName: "test-event", - }); + test("respects the configured batch size and interval", async () => { + for (let index = 0; index < 5; index++) client.analytics.track({ eventName: `event_${index}` }); + await vi.advanceTimersByTimeAsync(999); + expect(batches()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(events().map((event) => event.event_name)).toEqual(["event_0", "event_1"]); + client.analytics.track({ eventName: "event_5" }); + await vi.advanceTimersByTimeAsync(2000); + expect(batches().map((request) => JSON.parse(request.data).events.length)).toEqual([2, 2, 2]); + expect(events().map((event) => event.event_name)).toEqual(Array.from({ length: 6 }, (_, index) => `event_${index}`)); }); - test("should have no analytics side effects when disabled in client config", () => { - const storage = { - getItem: vi.fn(() => null), - setItem: vi.fn(), - }; - const addEventListener = vi.fn(); - vi.stubGlobal("localStorage", storage); - vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); - vi.stubGlobal("window", { - addEventListener, - removeEventListener: vi.fn(), - history: { replaceState: vi.fn() }, - localStorage: storage, - location: { origin: "https://example.com", pathname: "/", search: "" }, - }); - const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); - - const disabled = createClient({ - serverUrl, - appId, - analytics: { enabled: false }, - }); - disabled.analytics.track({ eventName: "should-not-track" }); - - expect(sharedState?.requestsQueue).toEqual([]); - expect(storage.setItem).not.toHaveBeenCalled(); - expect(setIntervalSpy).not.toHaveBeenCalled(); - expect(addEventListener).not.toHaveBeenCalled(); - - disabled.cleanup(); + test("drops overflow without replacing the first queued events", async () => { + config.maxQueueSize = 2; + for (const eventName of ["first", "second", "overflow"]) client.analytics.track({ eventName }); + await client.experiments.flush(); + expect(events().map((event) => event.event_name)).toEqual(["first", "second"]); }); - test("should clear the memoized session context on reset", () => { - expect(sharedState?.sessionContext).toEqual({ user_id: "test-user-id" }); - - resetAnalyticsSessionContext(); - - // Called on every identity change. Without it, a visitor who loads - // anonymously and then logs in keeps reporting the pre-login identity. - expect(sharedState?.sessionContext).toBeNull(); + test("ordinary Analytics failures remain best effort without new retries", async () => { + adapter.mockRejectedValue(new Error("offline")); + client.analytics.track({ eventName: "purchase" }); + await client.experiments.flush(); + await vi.advanceTimersByTimeAsync(10000); + expect(batches()).toHaveLength(1); }); - test("should not restore the pre-reset identity when a lookup settles late", async () => { - resetAnalyticsSessionContext(); - - let resolveMe: (user: User) => void; - vi.spyOn(base44.auth, "me").mockReturnValue( - new Promise((resolve) => { - resolveMe = resolve; - }) - ); - - // Flushing this event resolves the session context, which suspends on me(). - base44.analytics.track({ eventName: "anonymous-event" }); - await vi.waitFor(() => expect(base44.auth.me).toHaveBeenCalled()); - - // The identity changes while that lookup is still in flight. - resetAnalyticsSessionContext(); - resolveMe!({ id: "anonymous-user" } as User); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The anonymous identity must not be written back: doing so pins user_id - // for the rest of the session, which is the bug the reset exists to prevent. - expect(sharedState?.sessionContext).toBeNull(); + test("disabled clients do not track or register automatic browser work", async () => { + const addListener = vi.mocked(window.addEventListener); + addListener.mockClear(); + const disabled = makeClient({ analytics: { enabled: false } }); + disabled.analytics.track({ eventName: "not_tracked" }); + await disabled.experiments.flush(); + expect(adapter).not.toHaveBeenCalled(); + expect(addListener).not.toHaveBeenCalled(); }); - test("should not start the heartbeat outside a browser", () => { - vi.stubGlobal("window", undefined); - const setInterval = vi.spyOn(globalThis, "setInterval"); - const server = createClient({ serverUrl, appId }); - expect(setInterval).not.toHaveBeenCalled(); - server.cleanup(); + test("anonymous events skip identity lookup and retain null Authorization after login", async () => { + client.analytics.track({ eventName: "anonymous" }); + client.setToken("token-a"); + client.analytics.track({ eventName: "authenticated" }); + await client.experiments.flush(); + const requests = batches(); + expect(requests).toHaveLength(2); + expect(requests[0].headers.get("Authorization")).toBeNull(); + expect(requests[1].headers.get("Authorization")).toBe("Bearer token-a"); + expect(events()).toEqual([ + expect.objectContaining({ event_name: "anonymous", user_id: null }), + expect.objectContaining({ event_name: "authenticated", user_id: "user-a" }), + ]); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/entities/User/me"))).toHaveLength(1); }); - test("should not resolve an identity when no token is set", async () => { - resetAnalyticsSessionContext(); - - const anonymous = createClient({ serverUrl, appId }); - const me = vi.spyOn(anonymous.auth, "me"); - - anonymous.analytics.track({ eventName: "public-page-event" }); - await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0)); - - // The whole point: on a public page `me()` can only answer 401, and the - // browser logs that to the console before any handler here sees it. The - // event still flushes -- anonymous events already reported user_id: null. - expect(me).not.toHaveBeenCalled(); - - anonymous.cleanup(); + test("different browser clients cannot share credentials, identities, or queued events", async () => { + const a = makeClient({ appId: "app-a", token: "token-a" }); + const b = makeClient({ appId: "app-b", token: "token-b" }); + a.analytics.track({ eventName: "goal_a" }); + b.analytics.track({ eventName: "goal_b" }); + await a.experiments.flush(); + expect(batches()).toHaveLength(1); + expect(batches()[0].url).toBe("/apps/app-a/analytics/track/batch"); + expect(events()[0]).toMatchObject({ event_name: "goal_a", user_id: "user-a" }); + a.cleanup(); + await b.experiments.flush(); + expect(batches()[1].url).toBe("/apps/app-b/analytics/track/batch"); + expect(batches()[1].headers.get("Authorization")).toBe("Bearer token-b"); + expect(events()[1]).toMatchObject({ event_name: "goal_b", user_id: "user-b" }); }); - test("should resolve an identity once a token is set", async () => { - resetAnalyticsSessionContext(); - - const anonymous = createClient({ serverUrl, appId }); - const me = vi - .spyOn(anonymous.auth, "me") - .mockResolvedValue({ id: "user-1" } as User); - - // A visitor who logs in mid-session must start reporting their identity, so - // the skip above must not be memoized. - anonymous.auth.setToken("token-acquired-after-login", false); - anonymous.analytics.track({ eventName: "post-login-event" }); - - await vi.waitFor(() => expect(me).toHaveBeenCalled()); - - anonymous.cleanup(); + test("a late old identity lookup cannot replace the new token's cached goal identity", async () => { + const user = makeClient({ token: "token-a" }); + let releaseOld: () => void; + const normalAdapter = adapter.getMockImplementation()!; + adapter.mockImplementation((request) => request.url.endsWith("/entities/User/me") && + request.headers.get("Authorization") === "Bearer token-a" + ? new Promise((resolve) => { releaseOld = async () => resolve(await normalAdapter(request)); }) + : normalAdapter(request)); + user.analytics.track({ eventName: "old_user" }); + await vi.advanceTimersByTimeAsync(0); + user.setToken("token-b"); + user.analytics.track({ eventName: "new_user" }); + await vi.advanceTimersByTimeAsync(0); + releaseOld!(); + await user.experiments.flush(); + user.analytics.track({ eventName: "new_user_again" }); + await user.experiments.flush(); + expect(events()).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_name: "old_user", user_id: "user-a" }), + expect.objectContaining({ event_name: "new_user", user_id: "user-b" }), + expect.objectContaining({ event_name: "new_user_again", user_id: "user-b" }), + ])); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/entities/User/me"))).toHaveLength(2); }); - test("should report token presence across identity changes", () => { - const client = createClient({ serverUrl, appId }); - // `hasToken` lives on the internal auth surface only; the public client - // narrows to AuthModule, so reach past the narrowing deliberately here. - const auth = client.auth as InternalAuthModule; - - expect(auth.hasToken()).toBe(false); - - auth.setToken("some-token", false); - expect(auth.hasToken()).toBe(true); - - auth.logout(); - expect(auth.hasToken()).toBe(false); - - client.cleanup(); + test("hidden documents immediately drain their pending events", async () => { + client.analytics.track({ eventName: "purchase" }); + Object.assign(document, { visibilityState: "hidden" }); + const listener = vi.mocked(window.addEventListener).mock.calls.find(([name]) => name === "visibilitychange")![1] as () => void; + listener(); + await vi.advanceTimersByTimeAsync(0); + expect(events().map((event) => event.event_name)).toEqual(["purchase"]); }); - test("should track multiple events", async () => { - vi.useFakeTimers(); + test("multiple browser clients retain a single initialization and heartbeat stream", async () => { + const shared = getSharedInstance("analytics", () => ({ config: {} })); + Object.assign(shared, { wasInitializationTracked: false }); + config.heartBeatInterval = 2000; + const a = makeClient(); + const b = makeClient(); + await vi.advanceTimersByTimeAsync(3000); + expect(events().filter((event) => event.event_name === "__initialization_event__")).toHaveLength(1); + expect(events().filter((event) => event.event_name === "__user_heartbeat_event__")).toHaveLength(1); + a.cleanup(); + b.cleanup(); + }); - for (let i = 0; i < 5; i++) { - base44.analytics.track({ eventName: `test-event ${i}` }); - } + test("a hanging identity lookup cannot keep a Worker flush pending beyond five seconds", async () => { + vi.stubGlobal("window", undefined); + const worker = makeClient({ token: "token-a" }); + adapter.mockReturnValue(new Promise(() => {})); + worker.analytics.track({ eventName: "purchase" }); + const settled = vi.fn(); + const delivery = worker.experiments.flush().then(settled); + await vi.advanceTimersByTimeAsync(4999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await delivery; + expect(settled).toHaveBeenCalledOnce(); + await worker.experiments.flush(); + expect(batches()).toHaveLength(0); + }); - expect(sharedState?.isProcessing).toBe(true); - expect(sharedState?.requestsQueue.length).toBe(4); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(2); - // add another event while processing to mix things up - base44.analytics.track({ eventName: `test-event 5` }); + test("server clients never start automatic heartbeat timers", () => { + vi.stubGlobal("window", undefined); + const setInterval = vi.spyOn(globalThis, "setInterval"); + makeClient({ token: "token-a" }); + expect(setInterval).not.toHaveBeenCalled(); + }); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(1); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(0); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.isProcessing).toBe(false); + test("ready exposures are delivered while an unrelated goal's old identity lookup hangs", async () => { + vi.stubGlobal("window", undefined); + const context = { config: { v: 1 as const, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, assign_by: "visitor" as const, traffic_allocation: 100, + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, identity: { visitorId: "visitor", userId: "user-a", status: "authenticated" as const } }; + const worker = makeClient({ token: "token-a", experiments: context }); + const normalAdapter = adapter.getMockImplementation()!; + adapter.mockImplementation((request) => request.url.endsWith("/entities/User/me") + ? new Promise(() => {}) : normalAdapter(request)); + worker.analytics.track({ eventName: "blocked_goal" }); + worker.auth.logout(); + expect(worker.experiments.isEnabled("checkout")).toBe(true); + const delivery = worker.experiments.flush(); + await vi.advanceTimersByTimeAsync(1); + expect(events()).toEqual([expect.objectContaining({ event_name: "__experiment_exposure__", session_id: "visitor" })]); + expect(batches()[0].headers.get("Authorization")).toBeNull(); + await vi.advanceTimersByTimeAsync(4999); + await delivery; + expect(batches()).toHaveLength(1); }); }); diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts index 635edd78..098b673f 100644 --- a/tests/unit/experiment-exposures.test.ts +++ b/tests/unit/experiment-exposures.test.ts @@ -32,16 +32,18 @@ describe("experiment exposure transport", () => { vi.resetModules(); }); - test("sends one immediate batch with the runtime visitor and no client user claim", () => { + test("queues exposure until flush with the runtime visitor and no client user claim", async () => { const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); tracker.track(assignment, identity); + expect(request).not.toHaveBeenCalled(); + await tracker.flush(); expect(request).toHaveBeenCalledOnce(); expect(request).toHaveBeenCalledWith({ method: "POST", url: `/apps/${appId}/analytics/track/batch`, headers: { Authorization: "Bearer user-1-token" }, - timeout: 5000, + timeout: expect.any(Number), signal: expect.any(AbortSignal), data: { events: [{ event_name: "__experiment_exposure__", @@ -56,7 +58,7 @@ describe("experiment exposure transport", () => { expect(new Date(event.timestamp).toISOString()).toBe(event.timestamp); }); - test("deduplicates reads but allows new runs, variants, users and visitors", () => { + test("deduplicates reads and batches distinct assignments only within the same identity", async () => { const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); tracker.track(assignment, identity); tracker.track({ ...assignment }, { ...identity }); @@ -64,8 +66,9 @@ describe("experiment exposure transport", () => { tracker.track({ ...assignment, variant_key: "treatment" }, identity); tracker.track(assignment, { ...identity, userId: "user-2" }); tracker.track(assignment, { ...identity, visitorId: "visitor-2" }); - - expect(request).toHaveBeenCalledTimes(5); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls.map(([config]) => config.data.events.length)).toEqual([3, 1, 1]); }); test.each(["getItem", "setItem"])("attributes goals to the exposure when storage %s fails", async (method) => { @@ -115,15 +118,37 @@ describe("experiment exposure transport", () => { request.mockRejectedValueOnce(new Error("offline")); const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); tracker.track(assignment, identity); + const delivery = tracker.flush(); client.defaults.headers.common.Authorization = "Bearer replacement"; await vi.advanceTimersByTimeAsync(100); - await tracker.flush(); + await delivery; expect(request).toHaveBeenCalledTimes(2); - expect(request.mock.calls[1][0]).toEqual(request.mock.calls[0][0]); + expect(request.mock.calls[1][0]).toMatchObject({ + data: request.mock.calls[0][0].data, headers: request.mock.calls[0][0].headers, + }); expect(request.mock.calls[0][0].data.events[0].event_id).toMatch(/^[0-9a-f-]{36}$/); vi.useRealTimers(); }); + test("captures credential and visitor partitions before a mixed batch is flushed", async () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + client.defaults.headers.common.Authorization = "Bearer token-2"; + tracker.track({ ...assignment, experiment_id: "experiment-2" }, identity); + tracker.track(assignment, { ...identity, visitorId: "visitor-2" }); + tracker.track(assignment, { visitorId: "visitor-2", userId: null }); + await tracker.flush(); + expect(request.mock.calls.map(([config]) => ({ + authorization: config.headers.Authorization, visitor: config.data.events[0].session_id, + count: config.data.events.length, + }))).toEqual([ + { authorization: "Bearer user-1-token", visitor: "runtime-visitor", count: 1 }, + { authorization: "Bearer token-2", visitor: "runtime-visitor", count: 1 }, + { authorization: "Bearer token-2", visitor: "visitor-2", count: 1 }, + { authorization: null, visitor: "visitor-2", count: 1 }, + ]); + }); + test.each([0, 1])("backend flush settles accepted %s without resending on later reads", async (accepted) => { vi.stubGlobal("window", undefined); request.mockResolvedValue({ accepted }); @@ -157,8 +182,8 @@ describe("experiment exposure transport", () => { await settled; expect(request).toHaveBeenCalledTimes(3); const initial = request.mock.calls[0][0]; - expect(request.mock.calls[1][0]).toEqual(initial); - expect(request.mock.calls[2][0]).toEqual(initial); + expect(request.mock.calls[1][0]).toMatchObject({ data: initial.data, headers: initial.headers }); + expect(request.mock.calls[2][0]).toMatchObject({ data: initial.data, headers: initial.headers }); tracker.track(assignment, identity); await tracker.flush(); expect(request).toHaveBeenCalledTimes(3); @@ -198,16 +223,18 @@ describe("experiment exposure transport", () => { tracker.track(assignment, { ...identity, userId }); client.defaults.headers.common.Authorization = "Bearer replacement-token"; - await vi.waitFor(() => expect(adapter).toHaveBeenCalledOnce()); + await tracker.flush(); + expect(adapter).toHaveBeenCalledOnce(); expect(adapter.mock.calls[0][0].headers.get("Authorization")).toBe( userId ? "Bearer user-1-token" : null, ); }); - test("uses explicit null auth when no default header exists", () => { + test("uses explicit null auth when no default header exists", async () => { delete client.defaults.headers.common.Authorization; - createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); - + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + await tracker.flush(); expect(request.mock.calls[0][0].headers.Authorization).toBeNull(); }); diff --git a/tests/unit/experiments-client.test.ts b/tests/unit/experiments-client.test.ts index f0f752d5..6dff0a87 100644 --- a/tests/unit/experiments-client.test.ts +++ b/tests/unit/experiments-client.test.ts @@ -43,6 +43,82 @@ function captureAnalytics() { } describe("client experiments integration", () => { + test("three distinct feature reads and a goal share one request-scoped Analytics batch", async () => { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ + data: config.url.endsWith("/entities/User/me") ? { id: "user" } : { accepted: 4 }, + status: 200, statusText: "OK", headers: {}, config, + })); + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; + }); + const requestContext = { ...context, config: { ...context.config, + experiments: ["checkout", "pricing", "headline"].map((flag_key) => ({ + ...context.config.experiments[0], id: `experiment_${flag_key}`, flag_key, + })), + } }; + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", Authorization: "Bearer user-token", + "Base44-Experiments-Context": encode(requestContext), + } })); + for (const flag of ["checkout", "pricing", "headline"]) expect(client.experiments.isEnabled(flag)).toBe(true); + client.analytics.track({ eventName: "purchase", properties: { amount: 42 } }); + await client.experiments.flush(); + const batches = adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + expect(batches).toHaveLength(1); + expect(batches[0].headers.get("Authorization")).toBe("Bearer user-token"); + const events = JSON.parse(batches[0].data).events; + expect(events.map((event) => event.event_name)).toEqual([ + "__experiment_exposure__", "__experiment_exposure__", "__experiment_exposure__", "purchase", + ]); + expect(new Set(events.slice(0, 3).map((event) => event.event_id)).size).toBe(3); + expect(events.every((event) => event.session_id === "visitor")).toBe(true); + client.cleanup(); + }); + + test("a lost mixed-batch acknowledgement retries only exposures with their original time, ID and credential", async () => { + vi.useFakeTimers(); + const create = axios.create.bind(axios); + let batchAttempt = 0; + const adapter = vi.fn(async (config) => { + if (config.url.endsWith("/analytics/track/batch") && batchAttempt++ === 0) throw new Error("lost acknowledgement"); + return { data: config.url.endsWith("/entities/User/me") ? { id: "user" } : { accepted: 0 }, + status: 200, statusText: "OK", headers: {}, config }; + }); + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; + }); + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", Authorization: "Bearer original-token", "Base44-Experiments-Context": encode(context), + } })); + const exposureTime = new Date().toISOString(); + client.experiments.isEnabled("checkout"); + await vi.advanceTimersByTimeAsync(25); + const goalTime = new Date().toISOString(); + client.analytics.track({ eventName: "purchase" }); + const delivery = client.experiments.flush(); + client.setToken("replacement-token"); + await vi.advanceTimersByTimeAsync(100); + await delivery; + const batches = adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + expect(batches).toHaveLength(2); + expect(batches.map((request) => request.headers.get("Authorization"))).toEqual(["Bearer original-token", "Bearer original-token"]); + const first = JSON.parse(batches[0].data).events; + expect(JSON.parse(batches[1].data).events).toEqual([first[0]]); + expect(first.map((event) => event.timestamp)).toEqual([exposureTime, goalTime]); + expect(first[0].event_id).toMatch(/^[0-9a-f-]{36}$/); + expect(first[1]).not.toHaveProperty("event_id"); + await client.experiments.flush(); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/analytics/track/batch"))).toHaveLength(2); + client.cleanup(); + }); + test("request context evaluates synchronously and flushes with the request's user token", async () => { const create = axios.create.bind(axios); const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); From c218490658699f213d7fc10b9d225e288a1e5495 Mon Sep 17 00:00:00 2001 From: liorma Date: Mon, 14 Sep 2026 13:35:33 +0300 Subject: [PATCH 09/12] test(auth): verify analytics identity after token changes --- tests/unit/auth.test.js | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 21638b7f..f2dcae3b 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -177,22 +177,44 @@ describe('Auth Module', () => { expect(scope.isDone()).toBe(true); }); - test('setToken() clears the shared browser analytics session context', () => { + test('setToken() attributes subsequent browser analytics to the new user', async () => { vi.stubGlobal('window', { location: { origin: appBaseUrl, pathname: '/', search: '' }, localStorage: { getItem: () => null }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), }); + const analyticsState = getSharedInstance('analytics', () => ({})); + const wasInitialized = analyticsState.wasInitializationTracked; + analyticsState.wasInitializationTracked = true; let browserClient; try { - browserClient = createClient({ serverUrl, appId, appBaseUrl, analytics: { enabled: false } }); - const analyticsState = getSharedInstance('analytics', () => ({})); - analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; - + browserClient = createClient({ serverUrl, appId, appBaseUrl }); + scope.get(`/api/apps/${appId}/entities/User/me`) + .matchHeader('authorization', 'Bearer old-access-token') + .reply(200, { id: 'old-user' }); + scope.post(`/api/apps/${appId}/analytics/track/batch`, (body) => + body.events.length === 1 && body.events[0].event_name === 'before_login' && body.events[0].user_id === 'old-user') + .matchHeader('authorization', 'Bearer old-access-token') + .reply(200, { accepted: 1 }); + browserClient.auth.setToken('old-access-token', false); + browserClient.analytics.track({ eventName: 'before_login' }); + await browserClient.experiments.flush(); + + scope.get(`/api/apps/${appId}/entities/User/me`) + .matchHeader('authorization', 'Bearer new-access-token') + .reply(200, { id: 'new-user' }); + scope.post(`/api/apps/${appId}/analytics/track/batch`, (body) => + body.events.length === 1 && body.events[0].event_name === 'after_login' && body.events[0].user_id === 'new-user') + .matchHeader('authorization', 'Bearer new-access-token') + .reply(200, { accepted: 1 }); browserClient.auth.setToken('new-access-token', false); + browserClient.analytics.track({ eventName: 'after_login' }); + await browserClient.experiments.flush(); - expect(analyticsState.sessionContext).toBeNull(); + expect(scope.isDone()).toBe(true); } finally { browserClient?.cleanup(); + analyticsState.wasInitializationTracked = wasInitialized; vi.unstubAllGlobals(); } }); From c69ae8fe0ab1eb70b45e3d96f9cd2a3db70f8fea Mon Sep 17 00:00:00 2001 From: liorma Date: Mon, 14 Sep 2026 17:01:37 +0300 Subject: [PATCH 10/12] ci: pin gateway checker actions to full commit SHAs --- .github/workflows/check-wix-proxy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-wix-proxy.yml b/.github/workflows/check-wix-proxy.yml index 7321799f..286c6232 100644 --- a/.github/workflows/check-wix-proxy.yml +++ b/.github/workflows/check-wix-proxy.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Wix gateway proxy (mandatory) uses: ./.github/actions/wix-gateway-proxy - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" From 55202d3d3e13018b68fe7096d8dbb444fffcc175 Mon Sep 17 00:00:00 2001 From: liorma Date: Wed, 16 Sep 2026 13:53:24 +0300 Subject: [PATCH 11/12] fix(experiments): scope legacy runtime adoption to the client app --- src/client.ts | 1 + src/modules/experiments-runtime.types.ts | 11 +++++--- src/modules/experiments.ts | 6 +++-- tests/unit/experiments-auth.test.ts | 3 ++- tests/unit/experiments-client.test.ts | 33 ++++++++++++++++++++++++ tests/unit/experiments-context.test.ts | 3 +++ tests/unit/experiments.test.ts | 23 ++++++++++++++--- 7 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index cfb9291e..7516b710 100644 --- a/src/client.ts +++ b/src/client.ts @@ -183,6 +183,7 @@ export function createClient(config: CreateClientConfig): Base44Client { pageUrl: experimentsContext?.pageUrl, }); const experiments = createExperimentsModule({ + appId, getAuth: () => userAuthModule, trackExposure: exposureTracker.track, flushExposures: exposureTracker.flush, diff --git a/src/modules/experiments-runtime.types.ts b/src/modules/experiments-runtime.types.ts index 5ea035da..e9eb6be7 100644 --- a/src/modules/experiments-runtime.types.ts +++ b/src/modules/experiments-runtime.types.ts @@ -18,8 +18,13 @@ export interface ExperimentsRuntime { } /** @internal */ -export function getExperimentsRuntime(): ExperimentsRuntime | undefined { +export function getExperimentsRuntime(appId?: string): ExperimentsRuntime | undefined { if (typeof window === "undefined" || typeof document === "undefined") return; - return (window as Window & { __B44_EXPERIMENTS__?: ExperimentsRuntime }) - .__B44_EXPERIMENTS__; + const page = window as Window & { + __B44_EXPERIMENTS__?: ExperimentsRuntime; + __B44_EXPERIMENTS_BOOTSTRAP__?: { config: { app_id: string } }; + }; + // The legacy evaluator has no app ID; its companion bootstrap identifies its owner. + if (appId !== undefined && page.__B44_EXPERIMENTS_BOOTSTRAP__?.config?.app_id !== appId) return; + return page.__B44_EXPERIMENTS__; } diff --git a/src/modules/experiments.ts b/src/modules/experiments.ts index 53576ca0..321b658c 100644 --- a/src/modules/experiments.ts +++ b/src/modules/experiments.ts @@ -18,11 +18,13 @@ const EMPTY: ExperimentsSnapshot = Object.freeze({ /** @internal */ export function createExperimentsModule({ + appId, getAuth, trackExposure, flushExposures = async () => {}, context, }: { + appId: string; getAuth: () => InternalAuthModule; trackExposure: ReturnType["track"]; flushExposures?: () => Promise; @@ -93,7 +95,7 @@ export function createExperimentsModule({ function activate() { if (disposed) return; active = true; - if (!context) runtime = getExperimentsRuntime(); + if (!context) runtime = getExperimentsRuntime(appId); if (!runtime) { publish(); return; @@ -109,7 +111,7 @@ export function createExperimentsModule({ if (disposed) return; state = next; if (!active) return; - if (!context) runtime = getExperimentsRuntime(); + if (!context) runtime = getExperimentsRuntime(appId); applyIdentity(); } diff --git a/tests/unit/experiments-auth.test.ts b/tests/unit/experiments-auth.test.ts index ec013db6..7ccf86e6 100644 --- a/tests/unit/experiments-auth.test.ts +++ b/tests/unit/experiments-auth.test.ts @@ -22,11 +22,12 @@ function setup(token?: string) { }; vi.stubGlobal("window", { __B44_EXPERIMENTS__: runtime, + __B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app-id" } }, localStorage: { setItem: vi.fn(), removeItem: vi.fn() }, location: { href: "https://example.test/dashboard" }, }); vi.stubGlobal("document", {}); - const bridge = createExperimentsModule({ getAuth: () => auth, trackExposure: vi.fn() }); + const bridge = createExperimentsModule({ appId: "app-id", getAuth: () => auth, trackExposure: vi.fn() }); const auth = createAuthModule(api, axios.create(), "app-id", { serverUrl: "https://example.test", appBaseUrl: "https://example.test", onAuthStateChange: bridge.onAuthStateChange, diff --git a/tests/unit/experiments-client.test.ts b/tests/unit/experiments-client.test.ts index 6dff0a87..0996ecc9 100644 --- a/tests/unit/experiments-client.test.ts +++ b/tests/unit/experiments-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createClient, createClientFromRequest } from "../../src/client.js"; import { resetAnalyticsSessionContext } from "../../src/modules/analytics.js"; import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; +import { createExperimentsRuntime } from "../../src/modules/experiments-context.js"; import { getSharedInstance } from "../../src/utils/sharedInstance.js"; vi.mock("partysocket", () => ({ WebSocket: class {} })); @@ -43,6 +44,38 @@ function captureAnalytics() { } describe("client experiments integration", () => { + test.each(["other-app", "unidentified"])("does not adopt an %s legacy runtime or send its exposures", async (owner) => { + vi.useFakeTimers(); + const published: ExperimentsContext = { + ...context, identity: { visitorId: "visitor-a", userId: null, status: "anonymous" }, + config: { ...context.config, experiments: [{ ...context.config.experiments[0], assign_by: "visitor" }] }, + }; + const runtime = createExperimentsRuntime(published); + vi.stubGlobal("window", { + __B44_EXPERIMENTS__: runtime, + __B44_EXPERIMENTS_BOOTSTRAP__: owner === "other-app" ? published : undefined, + location: { origin: "https://app.example", pathname: "/", search: "" }, + localStorage: { getItem: () => null, setItem: () => {} }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { referrer: "" }); + const adapter = captureAnalytics(); + const client = createClient({ appId: "second-app" }); + try { + expect(client.experiments.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + expect(client.experiments.isEnabled("checkout")).toBe(false); + expect(client.experiments.isEnabled("checkout", true)).toBe(true); + await client.experiments.ready(); + await client.experiments.flush(); + await vi.advanceTimersByTimeAsync(1500); + expect(adapter).not.toHaveBeenCalled(); + expect(runtime.flags.checkout).toBe(true); + expect(runtime.userId).toBeNull(); + } finally { + client.cleanup(); + } + }); + test("three distinct feature reads and a goal share one request-scoped Analytics batch", async () => { const create = axios.create.bind(axios); const adapter = vi.fn(async (config) => ({ diff --git a/tests/unit/experiments-context.test.ts b/tests/unit/experiments-context.test.ts index 29303a5d..ace91669 100644 --- a/tests/unit/experiments-context.test.ts +++ b/tests/unit/experiments-context.test.ts @@ -28,6 +28,7 @@ describe("platform experiments context", () => { const me = vi.fn(); const track = vi.fn(); const make = (value: ExperimentsContext) => createExperimentsModule({ + appId: "app", context: value, getAuth: () => ({ hasToken: () => true, me }) as unknown as InternalAuthModule, trackExposure: track, }); const a = make(context); @@ -49,6 +50,7 @@ describe("platform experiments context", () => { vi.stubGlobal("sessionStorage", { getItem: () => '{"checkout":true}' }); const track = vi.fn(); const sdk = createExperimentsModule({ + appId: "app", context: getBrowserExperimentsContext("app"), getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: track, }); @@ -63,6 +65,7 @@ describe("platform experiments context", () => { test("preserves server-rendered flags while common browser auth is still pending", () => { const serverFlags = { checkout: false }; const sdk = createExperimentsModule({ + appId: "app", context: { ...context, identity: { ...context.identity, userId: null, status: "pending" }, serverSnapshot: { flags: serverFlags, isLoading: false } }, getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: vi.fn(), }); diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts index 83447a78..c84a3394 100644 --- a/tests/unit/experiments.test.ts +++ b/tests/unit/experiments.test.ts @@ -14,12 +14,14 @@ function setup(hasToken = false) { this.flags = { checkout: id !== null }; }, }; - vi.stubGlobal("window", { __B44_EXPERIMENTS__: runtime }); + const page = { __B44_EXPERIMENTS__: runtime, __B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app" } } }; + vi.stubGlobal("window", page); vi.stubGlobal("document", {}); const requests: { resolve: (user: User) => void; reject: (error: Error) => void }[] = []; const me = vi.fn(() => new Promise((resolve, reject) => requests.push({ resolve, reject }))); const trackExposure = vi.fn(); const bridge = createExperimentsModule({ + appId: "app", getAuth: () => ({ hasToken: () => hasToken, me }) as InternalAuthModule, trackExposure, }); @@ -28,7 +30,7 @@ function setup(hasToken = false) { if (state.status === "authenticated") requests[index]?.resolve({ id: state.userId } as User); else requests[index]?.reject(new Error("lookup failed")); }; - return { ...bridge, runtime, requests, settle, me, trackExposure }; + return { ...bridge, runtime, page, requests, settle, me, trackExposure }; } afterEach(() => vi.unstubAllGlobals()); @@ -139,11 +141,26 @@ describe("browser experiments", () => { b.module.getSnapshot(); b.runtime.userId = "old-user"; b.runtime.flags.checkout = true; - vi.stubGlobal("window", { __B44_EXPERIMENTS__: b.runtime }); + vi.stubGlobal("window", b.page); expect(b.module.isEnabled("checkout")).toBe(false); expect(b.runtime.userId).toBeNull(); }); + test("auth updates cannot adopt a replacement runtime owned by another app", () => { + const b = setup(); + expect(b.module.isEnabled("checkout")).toBe(false); + b.trackExposure.mockClear(); + b.page.__B44_EXPERIMENTS_BOOTSTRAP__.config.app_id = "other-app"; + const setUser = vi.spyOn(b.runtime, "setUser"); + + b.onAuthStateChange({ status: "authenticated", userId: "user-b" }); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(setUser).not.toHaveBeenCalled(); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + test("cleanup and throwing subscribers cannot restore or interrupt identity", async () => { const b = setup(true); const listener = vi.fn(() => { throw new Error("render error"); }); From 3e9705d480e6d4cdc65a451ab394e87ced92ecc7 Mon Sep 17 00:00:00 2001 From: liorma Date: Thu, 17 Sep 2026 11:45:00 +0300 Subject: [PATCH 12/12] chore: remove unrelated gateway workflow pinning from experiments PR --- .github/workflows/check-wix-proxy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-wix-proxy.yml b/.github/workflows/check-wix-proxy.yml index 286c6232..7321799f 100644 --- a/.github/workflows/check-wix-proxy.yml +++ b/.github/workflows/check-wix-proxy.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@v4 - name: Wix gateway proxy (mandatory) uses: ./.github/actions/wix-gateway-proxy - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@v6 with: python-version: "3.12"