Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ APPWRITE_COL_INIT_ID=
APPWRITE_API_KEY_INIT=
SENTRY_AUTH_TOKEN=
STATSIG_SERVER_SECRET=
# Optional. Defaults to production when NODE_ENV=production else development. Must match Statsig console environment for diagnostics.
STATSIG_ENVIRONMENT=
244 changes: 151 additions & 93 deletions src/lib/statsig/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ export type StatsigBrowserClient = {
initializeAsync(options?: object): Promise<unknown>;
getExperiment(name: string): { get(key: string, defaultValue: string): string };
logEvent(eventOrName: string, value?: string | number, metadata?: Record<string, string>): void;
shutdown(): Promise<void>;
};

let client: StatsigBrowserClient | null = null;
/** In-flight or last completed browser init (`initializeAsync`); cleared on failure so callers can retry. */
let initPromise: Promise<StatsigBrowserClient | null> | null = null;
/** Server bootstrap JSON for first init (homepage); avoids cache-first experiment checks. */
/** Server bootstrap JSON for init (homepage); avoids cache-first experiment checks. */
let pendingBootstrapJson: string | null | undefined;
/** Same stable ID the server used for SSR + `getClientInitializeResponse` (avoids cookie timing / BootstrapStableIDMismatch). */
let pendingServerStableUserId: string | null | undefined;
/** Non-null once we initialized successfully with a bootstrap payload (SPA: skip re-init on later navigations). */
let appliedBootstrapPayload: string | null = null;

function statsigEnvironmentTier(): 'production' | 'development' {
return import.meta.env.PROD ? 'production' : 'development';
}

function readStableIdFromCookie(): string | null {
if (typeof document === 'undefined' || !document.cookie) return null;
Expand Down Expand Up @@ -68,104 +77,121 @@ function toStringMetadata(data: Record<string, unknown>): Record<string, string>
return out;
}

function startStatsig(): void {
if (!browser || ENV.TEST) return;
if (initPromise) return;
function wrapInitFailure(
p: Promise<StatsigBrowserClient | null>
): Promise<StatsigBrowserClient | null> {
return p.catch((err: unknown) => {
console.error('[Statsig] Failed to initialize', err);
client = null;
initPromise = null;
return null;
});
}

initPromise = (async (): Promise<StatsigBrowserClient | null> => {
try {
const [
{ StatsigClient, Storage },
{ StatsigSessionReplayPlugin },
{ StatsigAutoCapturePlugin }
] = await Promise.all([
import('@statsig/js-client'),
import('@statsig/session-replay'),
import('@statsig/web-analytics')
]);

// `initializeAsync` waits for this so `customIDs.stableID` applies synchronously in
// `_configureUser`. `initializeSync` does not — without it, StableID can lag userID and
// evaluations/bootstrap can disagree (dashboard mismatch + bad reasons).
if (typeof Storage?.isReady === 'function' && !Storage.isReady()) {
const ready = Storage.isReadyResolver?.();
if (ready != null) {
await ready;
}
}
async function runStatsigInit(): Promise<StatsigBrowserClient | null> {
const [
{ StatsigClient, Storage },
{ StatsigSessionReplayPlugin },
{ StatsigAutoCapturePlugin }
] = await Promise.all([
import('@statsig/js-client'),
import('@statsig/session-replay'),
import('@statsig/web-analytics')
]);

// No plugins during init — bind after init on a macrotask so the client finishes any sync
// `values_updated` work before Session Replay / Auto Capture touch the client.
const stableId = getStableUserId();
const instance = new StatsigClient(
STATSIG_CLIENT_SDK_KEY,
{
userID: stableId,
customIDs: { stableID: stableId }
},
{
plugins: []
}
);
if (typeof Storage?.isReady === 'function' && !Storage.isReady()) {
const ready = Storage.isReadyResolver?.();
if (ready != null) {
await ready;
}
}

const bootstrap = pendingBootstrapJson;
pendingBootstrapJson = undefined;
const serverStableOverride = pendingServerStableUserId;
pendingServerStableUserId = undefined;

const adapter = (
instance as unknown as {
dataAdapter: { setData(data: string): void | Promise<void> };
}
).dataAdapter;
let stableId = getStableUserId();
if (typeof serverStableOverride === 'string' && serverStableOverride.length > 0) {
stableId = serverStableOverride;
try {
localStorage.setItem(STATSIG_STABLE_ID_KEY, stableId);
persistStableIdToCookie(stableId);
} catch {
/* ignore */
}
}

try {
if (bootstrap) {
try {
// https://docs.statsig.com/client/javascript-mono/UsingEvaluationsDataAdapter#bootstrapping
await Promise.resolve(adapter.setData(bootstrap));
instance.initializeSync();
} catch (bootstrapErr: unknown) {
console.error(
'[Statsig] bootstrap init failed, falling back to initializeAsync',
bootstrapErr
);
await instance.initializeAsync();
}
} else {
await instance.initializeAsync();
}
} catch (err: unknown) {
console.error('[Statsig] initialize failed', err);
client = null;
initPromise = null;
return null;
const bootstrap = pendingBootstrapJson;
pendingBootstrapJson = undefined;

const instance = new StatsigClient(
STATSIG_CLIENT_SDK_KEY,
{
userID: stableId,
customIDs: { stableID: stableId }
},
{
plugins: [],
environment: { tier: statsigEnvironmentTier() },
networkConfig: {
initializeHashAlgorithm: 'djb2'
}
}
);

// Register the client for `logEvent` as soon as core init succeeded. Previously we set
// `client` only after Session Replay / Auto Capture `bind()`; if either threw (CSP,
// privacy extensions, rrweb errors), the whole init looked failed and **all** Statsig
// logging stopped.
client = instance as StatsigBrowserClient;
const adapter = (
instance as unknown as {
dataAdapter: { setData(data: string): void | Promise<void> };
}
).dataAdapter;

try {
if (bootstrap) {
try {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const sessionPlugin = new StatsigSessionReplayPlugin();
const autoPlugin = new StatsigAutoCapturePlugin();
sessionPlugin.bind(instance as never);
autoPlugin.bind(instance as never);
} catch (pluginErr: unknown) {
console.error('[Statsig] Session Replay / Auto Capture bind failed', pluginErr);
// https://docs.statsig.com/client/javascript-mono/UsingEvaluationsDataAdapter#bootstrapping
await Promise.resolve(adapter.setData(bootstrap));
instance.initializeSync();
} catch (bootstrapErr: unknown) {
console.error(
'[Statsig] bootstrap init failed, falling back to initializeAsync',
bootstrapErr
);
await instance.initializeAsync();
}

return client;
} catch (err: unknown) {
console.error('[Statsig] Failed to initialize', err);
client = null;
initPromise = null;
return null;
} else {
await instance.initializeAsync();
}
})();
} catch (err: unknown) {
console.error('[Statsig] initialize failed', err);
client = null;
initPromise = null;
return null;
}

client = instance as StatsigBrowserClient;

if (typeof bootstrap === 'string' && bootstrap.length > 0) {
appliedBootstrapPayload = bootstrap;
}

try {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const sessionPlugin = new StatsigSessionReplayPlugin();
const autoPlugin = new StatsigAutoCapturePlugin();
sessionPlugin.bind(instance as never);
autoPlugin.bind(instance as never);
} catch (pluginErr: unknown) {
console.error('[Statsig] Session Replay / Auto Capture bind failed', pluginErr);
}

return client;
}

function startStatsig(): void {
if (!browser || ENV.TEST) return;
if (initPromise) return;
initPromise = wrapInitFailure(runStatsigInit());
}

/**
Expand All @@ -185,12 +211,44 @@ export function whenStatsigNetworkReady(): Promise<void> {
}

/**
* Loads Statsig after a full async init. Pass `statsigBootstrap` from `+page.server.ts` on `/` when
* the server has `STATSIG_SERVER_SECRET` so the client can bootstrap and avoid cache/loading checks.
* Loads Statsig. Pass `statsigBootstrap` + `statsigStableUserId` from `(marketing)/+page.server.ts` on `/`
* when the server has `STATSIG_SERVER_SECRET` so the client matches SSR and avoids bootstrap user mismatch.
* Call from `afterNavigate` so client-side navigations (e.g. /docs → /) still receive bootstrap.
*/
export function initStatsig(clientBootstrapJson?: string | null): Promise<void> {
if (!initPromise && typeof clientBootstrapJson === 'string' && clientBootstrapJson.length > 0) {
pendingBootstrapJson = clientBootstrapJson;
export function initStatsig(
clientBootstrapJson?: string | null,
serverStableUserId?: string | null
): Promise<void> {
pendingServerStableUserId =
typeof serverStableUserId === 'string' && serverStableUserId.length > 0
? serverStableUserId
: undefined;
pendingBootstrapJson =
typeof clientBootstrapJson === 'string' && clientBootstrapJson.length > 0
? clientBootstrapJson
: undefined;
Comment on lines +226 to +229
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Bootstrap payload silently cleared on rapid navigation

initStatsig unconditionally overwrites pendingBootstrapJson with undefined when called with a null/empty bootstrap (e.g., navigating away from / before the in-flight runStatsigInit has consumed the value). If the user navigates //docs quickly, the second afterNavigate call clears the bootstrap before runStatsigInit reaches line 123. The in-flight init then sees bootstrap = undefined and falls through to initializeAsync without the SSR data. The reinit-on-next-navigate path self-corrects, but the initial homepage render loses the bootstrap alignment.

A safer pattern would be to only update the pending values when the init hasn't yet consumed them (e.g., guard with if (!initPromise || client) before overwriting).


const hasBootstrap =
typeof pendingBootstrapJson === 'string' && pendingBootstrapJson.length > 0;

if (client && hasBootstrap && appliedBootstrapPayload == null) {
const previous = client as unknown as StatsigBrowserClient;
client = null;
initPromise = wrapInitFailure(
(async (): Promise<StatsigBrowserClient | null> => {
try {
await previous.shutdown();
} catch {
/* ignore */
}
return runStatsigInit();
})()
);
return whenStatsigNetworkReady();
}
Comment on lines +234 to +248
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Reinit triggered on every navigation to / after non-bootstrap start

The condition client && hasBootstrap && appliedBootstrapPayload == null triggers a full shutdown() + reinit every time a navigation brings bootstrap data if appliedBootstrapPayload is still null. This is intentional for the /docs → / case, but if afterNavigate fires twice in quick succession (e.g., hash change then real navigation) for the homepage, two concurrent reinit chains could run — the second one shutting down the client the first one is still initializing. appliedBootstrapPayload is only set after runStatsigInit returns successfully, so a slow init leaves the window open.

Consider setting appliedBootstrapPayload to a sentinel value (e.g., 'pending') at the top of the reinit path to prevent double-triggering while the shutdown/reinit is in-flight.


if (!initPromise) {
startStatsig();
}
return whenStatsigNetworkReady();
}
Expand Down
21 changes: 19 additions & 2 deletions src/lib/statsig/hero-statsig.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import { env } from '$env/dynamic/private';
import { Statsig, StatsigUser, type StatsigUserArgs } from '@statsig/statsig-node-core';
import {
Statsig,
StatsigUser,
type StatsigOptions,
type StatsigUserArgs
} from '@statsig/statsig-node-core';
import { STATSIG_CLIENT_SDK_KEY, STATSIG_EXPERIMENT_BEST_DESCRIPTION } from './constants';

function buildStatsigServerOptions(): StatsigOptions {
const explicit = env.STATSIG_ENVIRONMENT?.trim();
return {
environment:
explicit && explicit.length > 0
? explicit
: process.env.NODE_ENV === 'production'
? 'production'
: 'development'
};
}

/** User fields used by marketing home load + Statsig bootstrap (server). */
export type StatsigServerUserInput = { userID: string } & Partial<
Pick<StatsigUserArgs, 'customIDs' | 'userAgent' | 'ip' | 'email'>
Expand All @@ -16,7 +33,7 @@ async function getStatsigClient(): Promise<Statsig | null> {

if (!initPromise) {
initPromise = (async () => {
const client = new Statsig(secret);
const client = new Statsig(secret, buildStatsigServerOptions());
await client.initialize();
statsigClient = client;
})().catch((err: unknown) => {
Expand Down
7 changes: 6 additions & 1 deletion src/routes/(marketing)/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,10 @@ export const load: PageServerLoad = async ({ cookies, request, url }) => {
getStatsigClientBootstrapPayload(user)
]);

return { heroSubtitle, statsigBootstrap };
return {
heroSubtitle,
statsigBootstrap,
/** Same value as `STATSIG_STABLE_ID_KEY` cookie — pass to client init to avoid bootstrap / stableID mismatch. */
statsigStableUserId: stableId
};
};
12 changes: 9 additions & 3 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
import { navigating, updated } from '$app/state';
import { onMount } from 'svelte';
import { loggedIn } from '$lib/utils/console';
import { beforeNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { trackEvent } from '$lib/actions/analytics';
import { initStatsig } from '$lib/statsig/client';
import { saveReferrerAndUtmSource } from '$lib/utils/utm';
Expand All @@ -76,9 +76,15 @@

const { children } = $props();

afterNavigate(() => {
const data = page.data as {
statsigBootstrap?: string | null;
statsigStableUserId?: string | null;
};
void initStatsig(data.statsigBootstrap ?? null, data.statsigStableUserId ?? null);
});

onMount(() => {
const bootstrap = (page.data as { statsigBootstrap?: string | null }).statsigBootstrap;
void initStatsig(bootstrap);
displayHiringMessage();
saveReferrerAndUtmSource(page.url);

Expand Down
Loading