diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index aa1a583f3b..77780914c3 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -20,8 +20,6 @@ type Payload = { }; const plausible = (domain: string): AnalyticsPlugin => { - if (!browser) return { name: 'analytics-plugin-plausible' }; - const instance = Plausible({ domain }); @@ -51,15 +49,26 @@ const plausible = (domain: string): AnalyticsPlugin => { }; }; -const analytics = Analytics({ - app: 'appwrite', - plugins: [plausible('appwrite.io')] -}); +/** Lazily created in the browser only — avoids `Analytics()` (and any internal `fetch`) during SSR. */ +let analyticsClient: ReturnType | null = null; + +function getAnalyticsClient(): ReturnType | null { + if (!browser) return null; + if (!analyticsClient) { + analyticsClient = Analytics({ + app: 'appwrite', + plugins: [plausible('appwrite.io')] + }); + } + return analyticsClient; +} export type TrackEventArgs = { name: string; data?: object }; export const trackEvent = (eventArgs?: string | TrackEventArgs): void => { if (!eventArgs || ENV.TEST) return; + /** All vendors below use `fetch`; they must not run during SSR. */ + if (!browser) return; const path = page.url.pathname; const route = @@ -70,7 +79,7 @@ export const trackEvent = (eventArgs?: string | TrackEventArgs): void => { const data = typeof eventArgs === 'string' ? { path, route } : { ...eventArgs.data, path, route }; - if (browser && shouldForwardAnalyticsToStatsig(name, path)) { + if (shouldForwardAnalyticsToStatsig(name, path)) { logStatsigCtaEvent(name, data as Record); } @@ -80,5 +89,5 @@ export const trackEvent = (eventArgs?: string | TrackEventArgs): void => { } posthogEvent.capture(name, data); - analytics.track(name, data).then(); + getAnalyticsClient()?.track(name, data).then(); }; diff --git a/src/lib/components/homepage-variations/custom-hero.svelte b/src/lib/components/homepage-variations/custom-hero.svelte index a29531ab7b..f2b1c3ad53 100644 --- a/src/lib/components/homepage-variations/custom-hero.svelte +++ b/src/lib/components/homepage-variations/custom-hero.svelte @@ -1,11 +1,18 @@ @@ -43,8 +82,8 @@ class={cn( 'relative flex max-w-screen items-center overflow-hidden', layoutAside - ? 'py-12 md:py-0 lg:min-h-[700px]' - : 'pt-16 pb-8 md:pt-20 md:pb-10 lg:min-h-[600px] lg:pt-24' + ? 'py-10 md:py-0 lg:min-h-[680px]' + : 'pt-10 pb-6 md:pt-14 md:pb-8 lg:min-h-[560px] lg:pt-16' )} >
.ts` that only imports `getStatsigServerClient` / `toStatsigUser` from `$lib/statsig/server`. +2. Put **browser** reads next to the component or in the same `experiments/.ts`, using `whenStatsigReady()` from `$lib/statsig/client`. +3. Reuse `experiment-eval.ts` if you need the same “try `.get`, then `.value`” pattern for typed params. diff --git a/src/lib/statsig/client.ts b/src/lib/statsig/client.ts index 7b467ece0c..5eedadf2f9 100644 --- a/src/lib/statsig/client.ts +++ b/src/lib/statsig/client.ts @@ -1,21 +1,12 @@ import { browser } from '$app/environment'; import { ENV } from '$lib/system'; -import { - STATSIG_STABLE_ID_KEY, - STATSIG_EXPERIMENT_BEST_DESCRIPTION, - STATSIG_EXPERIMENT_HERO_LAYOUT, - STATSIG_CLIENT_SDK_KEY -} from '$lib/statsig/constants'; - -export { STATSIG_EXPERIMENT_BEST_DESCRIPTION, STATSIG_EXPERIMENT_HERO_LAYOUT }; +import { STATSIG_STABLE_ID_KEY, STATSIG_CLIENT_SDK_KEY } from '$lib/statsig/constants'; +import type { StatsigClientEvalSource } from '$lib/statsig/experiment-eval'; /** Narrow surface we use from `@statsig/js-client` (avoids static `import type` from that package, which can break Vite named re-exports). */ -export type StatsigBrowserClient = { +export type StatsigBrowserClient = StatsigClientEvalSource & { initializeSync(options?: object): unknown; initializeAsync(options?: object): Promise; - getExperiment(name: string): { - get(key: string, defaultValue: string | number): string | number; - }; logEvent(eventOrName: string, value?: string | number, metadata?: Record): void; shutdown(): Promise; }; diff --git a/src/lib/statsig/constants.ts b/src/lib/statsig/constants.ts index ffb8834ba0..0205948f7a 100644 --- a/src/lib/statsig/constants.ts +++ b/src/lib/statsig/constants.ts @@ -4,11 +4,6 @@ export const STATSIG_STABLE_ID_KEY = 'statsig_stable_id'; /** Browser SDK key (public). Server uses it for `getClientInitializeResponse` / client bootstrap. */ export const STATSIG_CLIENT_SDK_KEY = 'client-TRp4ODQ3Yfsha0XwmRayqwb7WW0ujUbiGrNlB0pfhTH'; -export const STATSIG_EXPERIMENT_BEST_DESCRIPTION = 'best_description'; - -/** Experiment param key: `layout` (values `0` | `1` | `2`). */ -export const STATSIG_EXPERIMENT_HERO_LAYOUT = 'hero_layout'; - export const DEFAULT_HERO_SUBTITLE = 'Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.'; diff --git a/src/lib/statsig/experiment-eval.ts b/src/lib/statsig/experiment-eval.ts new file mode 100644 index 0000000000..2bbc185db7 --- /dev/null +++ b/src/lib/statsig/experiment-eval.ts @@ -0,0 +1,80 @@ +import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout'; + +/** + * Minimal shape shared by Statsig `Experiment` and `DynamicConfig` for reading typed params. + * Used by the browser client type and by server-side evaluation objects. + */ +export type StatsigParamEvaluation = { + get(key: string, defaultValue: string | number): string | number; + value?: Record; +}; + +export type StatsigLayoutReadSource = 'none' | 'get' | 'value' | 'dynamic'; + +const LAYOUT_PARAM_KEYS = ['layout', 'hero_layout', 'heroLayout'] as const; + +/** + * Numeric sentinel for `.get` — Statsig types `hero_layout` as number; a string default triggers SDK warnings. + */ +const LAYOUT_GET_SENTINEL = -999_999; + +/** + * Reads a 0|1|2 layout from one experiment/dynamic-config evaluation: try `.get` for common param + * names, then `evaluation.value` (explicit-parameter / SDK quirks). + */ +export function readLayoutVariantFromStatsigEvaluation( + evaluation: StatsigParamEvaluation, + fallback: HeroLayoutVariant +): { layout: HeroLayoutVariant; source: StatsigLayoutReadSource } { + for (const key of LAYOUT_PARAM_KEYS) { + const raw = evaluation.get(key, LAYOUT_GET_SENTINEL); + if (raw !== LAYOUT_GET_SENTINEL) { + return { layout: normalizeHeroLayout(raw, fallback), source: 'get' }; + } + } + + const record = evaluation.value; + if (record && typeof record === 'object') { + const map = record as Record; + for (const key of LAYOUT_PARAM_KEYS) { + if (key in map) { + const raw = map[key]; + if (raw !== undefined && raw !== null) { + return { layout: normalizeHeroLayout(raw, fallback), source: 'value' }; + } + } + } + } + + return { layout: fallback, source: 'none' }; +} + +/** Browser Statsig client: `getExperiment` first, then optional `getDynamicConfig` with the same id. */ +export type StatsigClientEvalSource = { + getExperiment(name: string): StatsigParamEvaluation; + getDynamicConfig?: (name: string, options?: object) => StatsigParamEvaluation; +}; + +export function readLayoutVariantFromStatsigClient( + client: StatsigClientEvalSource, + experimentOrConfigName: string, + fallback: HeroLayoutVariant +): { layout: HeroLayoutVariant; source: StatsigLayoutReadSource } { + let r = readLayoutVariantFromStatsigEvaluation( + client.getExperiment(experimentOrConfigName), + fallback + ); + if (r.source !== 'none') { + return r; + } + if (typeof client.getDynamicConfig === 'function') { + r = readLayoutVariantFromStatsigEvaluation( + client.getDynamicConfig(experimentOrConfigName), + fallback + ); + if (r.source !== 'none') { + return { layout: r.layout, source: 'dynamic' }; + } + } + return { layout: fallback, source: 'none' }; +} diff --git a/src/lib/statsig/experiments/marketing-hero-client.ts b/src/lib/statsig/experiments/marketing-hero-client.ts new file mode 100644 index 0000000000..223dc881b7 --- /dev/null +++ b/src/lib/statsig/experiments/marketing-hero-client.ts @@ -0,0 +1,63 @@ +/** + * Browser-only marketing hero Statsig reads (`@statsig/js-client`). + * Do not import `marketing-hero-server.ts` or `server.ts` from Svelte/components — that pulls native `@statsig/statsig-node-core` into Vite client. + * + * @see ../README.md + */ +import type { StatsigBrowserClient } from '../client'; +import { readLayoutVariantFromStatsigClient } from '../experiment-eval'; +import type { HeroLayoutVariant } from '../hero-layout'; +import { normalizeHeroSubtitle } from '../hero-query-overrides'; +import { MARKETING_HERO_EXPERIMENTS } from './marketing-hero-ids'; + +export { MARKETING_HERO_EXPERIMENTS }; + +export type MarketingHeroStatsigBaseline = { + heroSubtitle: string; + heroLayout: HeroLayoutVariant; +}; + +export type MarketingHeroClientExposureDebug = Record; + +/** + * Read experiments once the JS client is ready — logs exposures for Pulse / Results. + * Pass the same baselines as SSR (or prerender defaults) so `.get` defaults stay consistent. + */ +export function readMarketingHeroExperimentsForExposure( + client: StatsigBrowserClient, + baseline: MarketingHeroStatsigBaseline +): { + heroSubtitle: string; + heroLayout: HeroLayoutVariant; + debug: MarketingHeroClientExposureDebug; +} { + const rawDescription = client + .getExperiment(MARKETING_HERO_EXPERIMENTS.bestDescription) + .get('description', baseline.heroSubtitle); + + const layoutRead = readLayoutVariantFromStatsigClient( + client, + MARKETING_HERO_EXPERIMENTS.heroLayout, + baseline.heroLayout + ); + + const heroSubtitle = normalizeHeroSubtitle(rawDescription, baseline.heroSubtitle); + + return { + heroSubtitle, + heroLayout: layoutRead.layout, + debug: { + [MARKETING_HERO_EXPERIMENTS.bestDescription]: { + raw: rawDescription, + rawType: typeof rawDescription, + normalizedLen: heroSubtitle.length, + ssrBaselineLen: baseline.heroSubtitle.length + }, + [MARKETING_HERO_EXPERIMENTS.heroLayout]: { + normalized: layoutRead.layout, + readSource: layoutRead.source, + ssrBaselineLayout: baseline.heroLayout + } + } + }; +} diff --git a/src/lib/statsig/experiments/marketing-hero-ids.ts b/src/lib/statsig/experiments/marketing-hero-ids.ts new file mode 100644 index 0000000000..fc87321d75 --- /dev/null +++ b/src/lib/statsig/experiments/marketing-hero-ids.ts @@ -0,0 +1,12 @@ +/** + * Statsig experiment / config ids for the marketing hero only. + * Safe to import from **client** code (no Node Statsig SDK). + */ +export const MARKETING_HERO_EXPERIMENTS = { + /** Param: `description` (string). */ + bestDescription: 'best_description', + /** + * Experiment or dynamic config id. Params tried: `layout`, `hero_layout`, `heroLayout`; then `.value`. + */ + heroLayout: 'hero_layout' +} as const; diff --git a/src/lib/statsig/experiments/marketing-hero-server.ts b/src/lib/statsig/experiments/marketing-hero-server.ts new file mode 100644 index 0000000000..bc65b3a058 --- /dev/null +++ b/src/lib/statsig/experiments/marketing-hero-server.ts @@ -0,0 +1,66 @@ +/** + * Server-only marketing hero Statsig evaluation (`@statsig/statsig-node-core`). + * Import only from `+page.server.ts`, `server.ts`, or other server-only modules. + * + * @see ../README.md + */ +import type { StatsigUser } from '@statsig/statsig-node-core'; +import { readLayoutVariantFromStatsigEvaluation } from '../experiment-eval'; +import type { HeroLayoutVariant } from '../hero-layout'; +import { normalizeHeroSubtitle } from '../hero-query-overrides'; +import { getStatsigServerClient, toStatsigUser, type StatsigServerUserInput } from '../server'; +import { MARKETING_HERO_EXPERIMENTS } from './marketing-hero-ids'; + +export { MARKETING_HERO_EXPERIMENTS }; + +const DISABLE_EXPOSURE = { disableExposureLogging: true } as const; + +/** + * SSR: `best_description`. Client must still call `readMarketingHeroExperimentsForExposure` so Pulse gets an exposure. + */ +export async function evaluateHeroDescriptionExperiment( + user: StatsigUser | StatsigServerUserInput, + fallback: string +): Promise { + const client = await getStatsigServerClient(); + if (!client) return fallback; + + try { + const statsigUser = toStatsigUser(user); + const experiment = client.getExperiment( + statsigUser, + MARKETING_HERO_EXPERIMENTS.bestDescription, + DISABLE_EXPOSURE + ); + const raw = experiment.get('description', fallback); + return normalizeHeroSubtitle(raw, fallback); + } catch { + return fallback; + } +} + +/** + * SSR: `hero_layout` (experiment and/or dynamic config with the same id). + */ +export async function evaluateHeroLayoutExperiment( + user: StatsigUser | StatsigServerUserInput, + fallback: HeroLayoutVariant +): Promise { + const client = await getStatsigServerClient(); + if (!client) return fallback; + + try { + const statsigUser = toStatsigUser(user); + const id = MARKETING_HERO_EXPERIMENTS.heroLayout; + const experiment = client.getExperiment(statsigUser, id, DISABLE_EXPOSURE); + let r = readLayoutVariantFromStatsigEvaluation(experiment, fallback); + if (r.source !== 'none') { + return r.layout; + } + const dc = client.getDynamicConfig(statsigUser, id, DISABLE_EXPOSURE); + r = readLayoutVariantFromStatsigEvaluation(dc, fallback); + return r.layout; + } catch { + return fallback; + } +} diff --git a/src/lib/statsig/hero-layout.ts b/src/lib/statsig/hero-layout.ts index 30a1d8e574..ba897c6c34 100644 --- a/src/lib/statsig/hero-layout.ts +++ b/src/lib/statsig/hero-layout.ts @@ -2,8 +2,11 @@ export type HeroLayoutVariant = 0 | 1 | 2; export function normalizeHeroLayout(raw: unknown, fallback: HeroLayoutVariant): HeroLayoutVariant { - if (typeof raw === 'number' && (raw === 0 || raw === 1 || raw === 2)) { - return raw; + if (typeof raw === 'number' && Number.isFinite(raw)) { + const r = Math.round(raw); + if (r === 0 || r === 1 || r === 2) { + return r as HeroLayoutVariant; + } } const n = parseInt(String(raw).trim(), 10); if (n === 0 || n === 1 || n === 2) { diff --git a/src/lib/statsig/hero-query-overrides.ts b/src/lib/statsig/hero-query-overrides.ts index 62741ffde6..ea04cdf198 100644 --- a/src/lib/statsig/hero-query-overrides.ts +++ b/src/lib/statsig/hero-query-overrides.ts @@ -9,6 +9,17 @@ export const HERO_TITLE_QUERY_KEY = 'hero_title'; const MAX_SUBTITLE_LEN = 560; const MAX_TITLE_LEN = 160; +/** + * Same rules as `hero_subtitle` URL overrides: collapse whitespace, trim, max length. + * Use for `best_description` experiment values from Statsig (server or client). + */ +export function normalizeHeroSubtitle(raw: unknown, fallback: string): string { + if (typeof raw !== 'string') return fallback; + const t = raw.replace(/\s+/g, ' ').trim(); + if (t.length === 0) return fallback; + return t.length > MAX_SUBTITLE_LEN ? t.slice(0, MAX_SUBTITLE_LEN) : t; +} + export type HeroQueryBaseline = { heroLayout: HeroLayoutVariant; heroSubtitle: string; @@ -52,9 +63,7 @@ function readHeroSubtitleOverride(params: URLSearchParams, fallback: string): st if (!params.has(HERO_SUBTITLE_QUERY_KEY)) return fallback; const raw = params.get(HERO_SUBTITLE_QUERY_KEY); if (raw == null) return fallback; - const t = raw.replace(/\s+/g, ' ').trim(); - if (t.length === 0) return fallback; - return t.length > MAX_SUBTITLE_LEN ? t.slice(0, MAX_SUBTITLE_LEN) : t; + return normalizeHeroSubtitle(raw, fallback); } function readHeroTitleOverride(params: URLSearchParams, fallback: string): string { diff --git a/src/lib/statsig/hero-statsig.server.ts b/src/lib/statsig/hero-statsig.server.ts index 7e7cb3baff..034ca11bf6 100644 --- a/src/lib/statsig/hero-statsig.server.ts +++ b/src/lib/statsig/hero-statsig.server.ts @@ -1,146 +1,19 @@ -import { env } from '$env/dynamic/private'; -import { - Statsig, - StatsigUser, - type StatsigOptions, - type StatsigUserArgs -} from '@statsig/statsig-node-core'; -import { - STATSIG_CLIENT_SDK_KEY, - STATSIG_EXPERIMENT_BEST_DESCRIPTION, - STATSIG_EXPERIMENT_HERO_LAYOUT -} from './constants'; -import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout'; - -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 ->; - -let statsigClient: Statsig | null = null; -let initPromise: Promise | null = null; - -async function getStatsigClient(): Promise { - const secret = env.STATSIG_SERVER_SECRET?.trim(); - if (!secret) return null; - - if (!initPromise) { - initPromise = (async () => { - const client = new Statsig(secret, buildStatsigServerOptions()); - await client.initialize(); - statsigClient = client; - })().catch((err: unknown) => { - console.error('[Statsig server] initialize failed', err); - statsigClient = null; - initPromise = null; - throw err; - }); - } - - try { - await initPromise; - return statsigClient; - } catch { - return null; - } -} - -function toStatsigUser(user: StatsigUser | StatsigServerUserInput): StatsigUser { - if (user instanceof StatsigUser) { - return user; - } - return new StatsigUser({ - userID: user.userID, - ...(user.customIDs ? { customIDs: user.customIDs } : {}), - ...(user.userAgent ? { userAgent: user.userAgent } : {}), - ...(user.ip ? { ip: user.ip } : {}), - ...(user.email ? { email: user.email } : {}) - }); -} - /** - * Evaluates `best_description` for SSR. Exposure is not logged here — the marketing hero client - * must call `getExperiment(...).get('description', …)` after `whenStatsigReady()` so Statsig - * records enrollments for Results / Pulse (see `hero.svelte`). + * Re-exports the marketing-home Statsig server API. + * Prefer `$lib/statsig/server` + `$lib/statsig/experiments/marketing-hero-server` in new server code. + * For browser exposure reads, import from `$lib/statsig/experiments/marketing-hero-client` only (never the `-server` module in `.svelte` files). */ -export async function evaluateHeroDescriptionExperiment( - user: StatsigUser | StatsigServerUserInput, - fallback: string -): Promise { - const client = await getStatsigClient(); - if (!client) return fallback; - - try { - const statsigUser = toStatsigUser(user); - const experiment = client.getExperiment(statsigUser, STATSIG_EXPERIMENT_BEST_DESCRIPTION, { - disableExposureLogging: true - }); - return experiment.get('description', fallback) as string; - } catch { - return fallback; - } -} - -/** - * Evaluates `hero_layout` for SSR. Exposure is logged on the client when the hero reads - * `getExperiment(STATSIG_EXPERIMENT_HERO_LAYOUT).get('layout', …)` after `whenStatsigReady()`. - * - * Configure a dynamic config / experiment parameter named `layout` with values `0`, `1`, or `2`. - */ -export async function evaluateHeroLayoutExperiment( - user: StatsigUser | StatsigServerUserInput, - fallback: HeroLayoutVariant -): Promise { - const client = await getStatsigClient(); - if (!client) return fallback; - - try { - const statsigUser = toStatsigUser(user); - const experiment = client.getExperiment(statsigUser, STATSIG_EXPERIMENT_HERO_LAYOUT, { - disableExposureLogging: true - }); - const raw = experiment.get('layout', fallback); - return normalizeHeroLayout(raw, fallback); - } catch { - return fallback; - } -} - -/** - * JSON payload for `StatsigClient.dataAdapter.setData` + `initializeAsync`, so the JS SDK skips - * cache-first experiment checks (Group Assignment Health). Requires `STATSIG_SERVER_SECRET`. - * Node Core returns this string directly (no extra `JSON.stringify`). - * @see https://docs.statsig.com/client/javascript-mono/UsingEvaluationsDataAdapter#bootstrapping - */ -export async function getStatsigClientBootstrapPayload( - user: StatsigUser | StatsigServerUserInput -): Promise { - const client = await getStatsigClient(); - if (!client) return null; - - try { - const statsigUser = toStatsigUser(user); - const response = client.getClientInitializeResponse(statsigUser, { - hashAlgorithm: 'djb2', - clientSdkKey: STATSIG_CLIENT_SDK_KEY - }); - if (response == null || response === '') { - return null; - } - return response; - } catch { - return null; - } -} +export { + type StatsigServerUserInput, + getStatsigClientBootstrapPayload, + toStatsigUser +} from './server'; +export { + evaluateHeroDescriptionExperiment, + evaluateHeroLayoutExperiment, + MARKETING_HERO_EXPERIMENTS +} from './experiments/marketing-hero-server'; +export type { + MarketingHeroClientExposureDebug, + MarketingHeroStatsigBaseline +} from './experiments/marketing-hero-client'; diff --git a/src/lib/statsig/server.ts b/src/lib/statsig/server.ts new file mode 100644 index 0000000000..b6637f6df2 --- /dev/null +++ b/src/lib/statsig/server.ts @@ -0,0 +1,93 @@ +import { env } from '$env/dynamic/private'; +import { + Statsig, + StatsigUser, + type StatsigOptions, + type StatsigUserArgs +} from '@statsig/statsig-node-core'; +import { STATSIG_CLIENT_SDK_KEY } 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 +>; + +let statsigClient: Statsig | null = null; +let initPromise: Promise | null = null; + +/** Shared Node Statsig client for SSR + bootstrap. Returns null when `STATSIG_SERVER_SECRET` is unset. */ +export async function getStatsigServerClient(): Promise { + const secret = env.STATSIG_SERVER_SECRET?.trim(); + if (!secret) return null; + + if (!initPromise) { + initPromise = (async () => { + const client = new Statsig(secret, buildStatsigServerOptions()); + await client.initialize(); + statsigClient = client; + })().catch((err: unknown) => { + console.error('[Statsig server] initialize failed', err); + statsigClient = null; + initPromise = null; + throw err; + }); + } + + try { + await initPromise; + return statsigClient; + } catch { + return null; + } +} + +export function toStatsigUser(user: StatsigUser | StatsigServerUserInput): StatsigUser { + if (user instanceof StatsigUser) { + return user; + } + return new StatsigUser({ + userID: user.userID, + ...(user.customIDs ? { customIDs: user.customIDs } : {}), + ...(user.userAgent ? { userAgent: user.userAgent } : {}), + ...(user.ip ? { ip: user.ip } : {}), + ...(user.email ? { email: user.email } : {}) + }); +} + +/** + * JSON payload for `StatsigClient.dataAdapter.setData` + `initializeAsync`, so the JS SDK skips + * cache-first experiment checks (Group Assignment Health). Requires `STATSIG_SERVER_SECRET`. + * @see https://docs.statsig.com/client/javascript-mono/UsingEvaluationsDataAdapter#bootstrapping + */ +export async function getStatsigClientBootstrapPayload( + user: StatsigUser | StatsigServerUserInput +): Promise { + const client = await getStatsigServerClient(); + if (!client) return null; + + try { + const statsigUser = toStatsigUser(user); + const response = client.getClientInitializeResponse(statsigUser, { + hashAlgorithm: 'djb2', + clientSdkKey: STATSIG_CLIENT_SDK_KEY + }); + if (response == null || response === '') { + return null; + } + return response; + } catch { + return null; + } +} diff --git a/src/routes/(marketing)/(components)/hero.svelte b/src/routes/(marketing)/(components)/hero.svelte index 6d1b07733c..5aac07b473 100644 --- a/src/routes/(marketing)/(components)/hero.svelte +++ b/src/routes/(marketing)/(components)/hero.svelte @@ -5,16 +5,16 @@ import { onMount } from 'svelte'; import { trackEvent } from '$lib/actions/analytics'; import { DEFAULT_HERO_SUBTITLE, DEFAULT_HERO_TITLE } from '$lib/statsig/constants'; + import { initStatsig, whenStatsigReady } from '$lib/statsig/client'; import { - whenStatsigReady, - STATSIG_EXPERIMENT_BEST_DESCRIPTION, - STATSIG_EXPERIMENT_HERO_LAYOUT - } from '$lib/statsig/client'; + MARKETING_HERO_EXPERIMENTS, + readMarketingHeroExperimentsForExposure + } from '$lib/statsig/experiments/marketing-hero-client'; import { hasHeroExperimentQueryOverrides, resolveHeroQueryOverrides } from '$lib/statsig/hero-query-overrides'; - import { normalizeHeroLayout, type HeroLayoutVariant } from '$lib/statsig/hero-layout'; + import type { HeroLayoutVariant } from '$lib/statsig/hero-layout'; import { ENV } from '$lib/system'; import AppwriteIn100Seconds from '$lib/components/AppwriteIn100Seconds.svelte'; import GradientText from '$lib/components/fancy/gradient-text.svelte'; @@ -37,11 +37,18 @@ heroLayout = 0 }: Props = $props(); - /** Statsig baseline from SSR; URL query params override in the browser (see `resolveHeroQueryOverrides`). */ + /** + * Filled after the browser Statsig client runs (`initializeAsync` when `/` is prerendered). + * Until then, SSR/prop baselines apply. URL query overrides still win via `resolveHeroQueryOverrides`. + */ + let clientHeroLayout = $state(undefined); + let clientHeroSubtitle = $state(undefined); + + /** Statsig baseline from SSR + optional client overrides; URL query params override in the browser. */ const resolved = $derived( resolveHeroQueryOverrides(building ? new URLSearchParams() : page.url.searchParams, { - heroLayout, - heroSubtitle: subtitle, + heroLayout: clientHeroLayout ?? heroLayout, + heroSubtitle: clientHeroSubtitle ?? subtitle, heroTitle: title }) ); @@ -67,8 +74,8 @@ if (hasHeroExperimentQueryOverrides(page.url.searchParams)) { log('URL query overrides (client layout)', { ...resolveHeroQueryOverrides(page.url.searchParams, { - heroLayout, - heroSubtitle: subtitle, + heroLayout: clientHeroLayout ?? heroLayout, + heroSubtitle: clientHeroSubtitle ?? subtitle, heroTitle: title }), ssrBaseline: { heroLayout, subtitleLen: subtitle.length } @@ -76,31 +83,43 @@ return; } + const data = page.data as { + statsigBootstrap?: string | null; + statsigStableUserId?: string | null; + statsigUserAgent?: string | null; + }; + void initStatsig( + data.statsigBootstrap ?? null, + data.statsigStableUserId ?? null, + data.statsigUserAgent ?? null + ); + void whenStatsigReady().then((client) => { if (!client) { - log('Statsig client not ready; SSR baseline only', { heroLayout }); + log('Statsig client not ready; SSR baseline only', { + heroLayout, + subtitleLen: subtitle.length + }); return; } - const rawDescription = client - .getExperiment(STATSIG_EXPERIMENT_BEST_DESCRIPTION) - .get('description', subtitle); - const rawLayout = client - .getExperiment(STATSIG_EXPERIMENT_HERO_LAYOUT) - .get('layout', heroLayout); - const normalizedLayout = normalizeHeroLayout(rawLayout, heroLayout); + const { + heroSubtitle: nextSubtitle, + heroLayout: nextLayout, + debug + } = readMarketingHeroExperimentsForExposure(client, { + heroSubtitle: subtitle, + heroLayout + }); + + clientHeroSubtitle = nextSubtitle; + clientHeroLayout = nextLayout; log('experiment values (after get → exposure)', { - [STATSIG_EXPERIMENT_BEST_DESCRIPTION]: { - raw: rawDescription, - rawType: typeof rawDescription - }, - [STATSIG_EXPERIMENT_HERO_LAYOUT]: { - raw: rawLayout, - rawType: typeof rawLayout, - normalized: normalizedLayout, - ssrBaselineLayout: heroLayout - } + [MARKETING_HERO_EXPERIMENTS.bestDescription]: + debug[MARKETING_HERO_EXPERIMENTS.bestDescription], + [MARKETING_HERO_EXPERIMENTS.heroLayout]: + debug[MARKETING_HERO_EXPERIMENTS.heroLayout] }); }); }); @@ -110,8 +129,8 @@ class={cn( 'relative flex max-w-screen items-center overflow-hidden', layoutAside - ? 'py-12 md:py-0 lg:min-h-[700px]' - : 'pt-16 pb-8 md:pt-20 md:pb-10 lg:min-h-[600px] lg:pt-24' + ? 'py-10 md:py-0 lg:min-h-[680px]' + : 'pt-10 pb-6 md:pt-14 md:pb-8 lg:min-h-[560px] lg:pt-16' )} >
{ + // Prerendered `/` must not embed per-user Statsig bootstrap or stable IDs — one static HTML is + // served to everyone; the hero applies experiments after `initializeAsync` in the browser. + if (building) { + return { + heroSubtitle: DEFAULT_HERO_SUBTITLE, + heroLayout: 0 as HeroLayoutVariant, + heroTitle: DEFAULT_HERO_TITLE, + statsigBootstrap: null, + statsigStableUserId: null, + statsigUserAgent: null + }; + } + let stableId = cookies.get(STATSIG_STABLE_ID_KEY); if (!stableId) { stableId = crypto.randomUUID(); @@ -41,7 +55,7 @@ export const load: PageServerLoad = async ({ cookies, request, url }) => { // `url.searchParams` is unavailable while prerendering (`+page.ts` has `prerender = true`). // Query overrides still apply in the browser via `hero.svelte` + `page.url.searchParams`. - const heroQueryParams = building ? new URLSearchParams() : url.searchParams; + const heroQueryParams = url.searchParams; const { heroSubtitle, heroLayout, heroTitle } = resolveHeroQueryOverrides(heroQueryParams, { heroLayout: heroLayoutBase, heroSubtitle: heroSubtitleBase,