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
1 change: 1 addition & 0 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
"SENTRY_RELEASE=${{ github.event.release.tag_name }}"

deploy_kubernetes:
if: github.event_name != 'release' || !contains(github.event.release.tag_name, '-rc')
strategy:
matrix:
region: [{ full: fra1, short: fra }]
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ services:
- PUBLIC_APPWRITE_COL_MESSAGES_ID=$PUBLIC_APPWRITE_COL_MESSAGES_ID
- PUBLIC_APPWRITE_FN_TLDR_ID=$PUBLIC_APPWRITE_FN_TLDR_ID
- PUBLIC_POSTHOG_API_KEY=$PUBLIC_POSTHOG_API_KEY
environment:
# Runtime (`$env/dynamic/private`) — keep out of image build args.
- STATSIG_SERVER_SECRET=${STATSIG_SERVER_SECRET}
- STATSIG_ENVIRONMENT=${STATSIG_ENVIRONMENT}
restart: always
networks:
- homepage
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"test:integration": "playwright test",
"test:unit": "vitest",
"optimize": "bun ./scripts/optimize-assets.js",
"optimize:all": "bun ./scripts/optimize-all.js"
"optimize:all": "bun ./scripts/optimize-all.js",
"benchmark:http": "k6 run scripts/benchmark-k6.js"
},
"dependencies": {
"@sentry/sveltekit": "^10.28.0",
Expand Down
66 changes: 66 additions & 0 deletions scripts/benchmark-k6.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* k6 load / smoke test against a local or remote HTTP server.
*
* Requires k6: https://k6.io/docs/get-started/installation/
* brew install k6
*
* Run:
* pnpm benchmark:http
* BASE_URL=http://127.0.0.1:3000 pnpm benchmark:http
* ITERATIONS=200 VUS=10 pnpm benchmark:http
* DURATION=1m VUS=20 COOKIE='statsig_stable_id=...' pnpm benchmark:http
*
* Env (shell → inherited by k6, read via __ENV):
* BASE_URL default http://127.0.0.1:5173
* TARGET_PATH default /
* VUS virtual users (default 5)
* DURATION e.g. 30s, 1m (default 30s) — used when ITERATIONS is unset
* ITERATIONS if set, uses shared-iterations instead of fixed duration
* MAX_DURATION cap for shared-iterations (default 5m)
* COOKIE optional Cookie header
*/

import http from 'k6/http';
import { check } from 'k6';

const base = (__ENV.BASE_URL || 'http://127.0.0.1:5173').replace(/\/$/, '');
const path = __ENV.TARGET_PATH || '/';
const url = `${base}${path.startsWith('/') ? path : `/${path}`}`;

const vus = Number(__ENV.VUS || 5);
const iterationsEnv = __ENV.ITERATIONS;
const hasIterations = iterationsEnv != null && iterationsEnv !== '' && Number(iterationsEnv) > 0;

export const options = hasIterations
? {
scenarios: {
bench: {
executor: 'shared-iterations',
vus: Math.max(1, vus),
iterations: Math.max(1, Number(iterationsEnv)),
maxDuration: __ENV.MAX_DURATION || '5m'
}
},
thresholds: {
http_req_failed: ['rate<0.05']
}
}
: {
vus: Math.max(1, vus),
duration: __ENV.DURATION || '30s',
thresholds: {
http_req_failed: ['rate<0.05']
}
};

const headers = { Accept: 'text/html,*/*' };
if (__ENV.COOKIE) {
headers.Cookie = __ENV.COOKIE;
}

export default function () {
const res = http.get(url, { headers, tags: { name: 'benchmark' } });
check(res, {
'status 2xx': (r) => r.status >= 200 && r.status < 400
});
}
6 changes: 6 additions & 0 deletions src/instrumentation.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as Sentry from '@sentry/sveltekit';
import { getStatsigServerClient } from '$lib/statsig/server';

Sentry.init({
dsn: 'https://27d41dc8bb67b596f137924ab8599e59@o1063647.ingest.us.sentry.io/4507497727000576',
tracesSampleRate: 1.0
});

/** Start Node Statsig `initialize()` before the first HTTP request so `/` load is not cold. */
void getStatsigServerClient().catch(() => {
/* No `STATSIG_SERVER_SECRET` or network failure — homepage uses defaults until init succeeds. */
});
28 changes: 14 additions & 14 deletions src/lib/statsig/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Statsig layout

| Path | Role |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `server.ts` | Node `Statsig` singleton, `toStatsigUser`, bootstrap payload for the browser SDK. |
| `client.ts` | Browser init (`initStatsig`, `whenStatsigReady`), stable id cookie, plugins. |
| `experiment-eval.ts` | Shared readers for typed Statsig evaluations (e.g. layout param keys + `.value` fallback). |
| `experiments/marketing-hero-ids.ts` | Experiment id strings only (safe on **client** and server). |
| `experiments/marketing-hero-client.ts` | Browser-only: `readMarketingHeroExperimentsForExposure` (import from **Svelte** / client code). |
| `experiments/marketing-hero-server.ts` | Server-only: `evaluateHeroDescriptionExperiment`, `evaluateHeroLayoutExperiment` (never import from `.svelte`). |
| `hero-statsig.server.ts` | Thin re-export barrel (legacy import path for `+page.server.ts`). |
| `hero-query-overrides.ts` | URL query overrides for local QA (`?hero_layout=`, `?hero_subtitle=`, …). |
| `hero-layout.ts` | Pure `normalizeHeroLayout` + `HeroLayoutVariant` type (no network). |
| `cta-events.ts` | Which analytics events mirror into Statsig. |
| Path | Role |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `server.ts` | Node `Statsig` singleton, `toStatsigUser`, bootstrap payload for the browser SDK. |
| `client.ts` | Browser init (`initStatsig`, `whenStatsigReady`), stable id cookie, plugins. |
| `experiment-eval.ts` | Shared readers for typed Statsig evaluations (e.g. layout param keys + `.value` fallback). |
| `experiments/marketing-hero-ids.ts` | Experiment id strings only (safe on **client** and server). |
| `experiments/marketing-hero-client.ts` | Browser-only: `readMarketingHeroExperimentsForExposure` (import from **Svelte** / client code). |
| `experiments/marketing-hero-server.ts` | Server-only: `loadMarketingHomeStatsigBundle` (cached homepage batch), `evaluateHero*`, etc. (never import from `.svelte`). |
| `hero-statsig.server.ts` | Thin re-export barrel (legacy import path for `+page.server.ts`). |
| `hero-query-overrides.ts` | URL query overrides for local QA (`?hero_layout=`, `?hero_subtitle=`, …). |
| `hero-layout.ts` | Pure `normalizeHeroLayout` + `HeroLayoutVariant` type (no network). |
| `cta-events.ts` | Which analytics events mirror into Statsig. |

## Adding a new experiment (marketing hero)

Expand All @@ -21,10 +21,10 @@
- In `experiments/marketing-hero-server.ts`, add `evaluateYourThingExperiment(...)` using `getStatsigServerClient()` + `toStatsigUser()` + `getExperiment(..., { disableExposureLogging: true })` for SSR without exposure.
- In `experiments/marketing-hero-client.ts`, extend `readMarketingHeroExperimentsForExposure` so the browser calls `getExperiment(...).get(...)` (exposure for Pulse / Results). Extend `MarketingHeroStatsigBaseline` if SSR + hydration share the field.
3. **`(marketing)/+page.server.ts`** — `Promise.all` your new evaluator next to the existing ones; pass the value into `resolveHeroQueryOverrides` baseline or add a new field on `data`.
4. **Hero Svelte** — Pass the new prop from `data`; merge into `resolveHeroQueryOverrides` if URL overrides should apply.
4. **Hero Svelte** — Add fields to `+page.server.ts` / `+page.ts` load `data`; the marketing hero reads them from `page.data` (not props / not `onMount` state). Merge into `resolveHeroQueryOverrides` if URL overrides should apply.
5. **URL overrides (optional)** — Add a query key + reader in `hero-query-overrides.ts` and document it in the table comment there.

Do **not** read experiment params only in `onMount` without also defining SSR in step 2–3 unless you intentionally want client-only assignment. The marketing homepage uses **`prerender = false`** so `+page.server.ts` + bootstrap match hydration and avoid layout flash.
Do **not** read experiment params only in `onMount` without also defining SSR in step 2–3 unless you intentionally want client-only assignment (e.g. prerender shell + client fill). The marketing homepage is **`prerender = true`**, so production stays fast; expect a possible brief layout/copy update after the Statsig client loads unless you move `/` to per-request SSR (tradeoff: every hit waits on Statsig).

**Why two files (`*-client` / `*-server`)?** `@statsig/statsig-node-core` ships native `.node` binaries. If any module imported by a `.svelte` file pulls in `server.ts` or `marketing-hero-server.ts`, Vite tries to bundle that SDK for the browser and fails with “No loader is configured for `.node` files”.

Expand Down
162 changes: 144 additions & 18 deletions src/lib/statsig/experiments/marketing-hero-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,76 @@
*
* @see ../README.md
*/
import type { StatsigUser } from '@statsig/statsig-node-core';
import type { Statsig, 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 {
getStatsigClientBootstrapPayloadForClient,
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;

export type MarketingHomeStatsigBundle = {
heroSubtitleBase: string;
heroLayoutBase: HeroLayoutVariant;
statsigBootstrap: string | null;
};

/**
* SSR: `best_description`. Client must still call `readMarketingHeroExperimentsForExposure` so Pulse gets an exposure.
* Cache is **per Statsig stable id** (same cookie as `+page.server.ts` / browser SDK): one row per
* anonymous browser, not per logged-in Appwrite user. Lives in this Node process memory only.
*/
export async function evaluateHeroDescriptionExperiment(
user: StatsigUser | StatsigServerUserInput,
/** Longer TTL = fewer Statsig round-trips for returning visitors (stale hero assignment up to this window). */
const MARKETING_HOME_STATSIG_CACHE_TTL_MS = 300_000;
/** Hard cap on distinct stable ids tracked in one process; enforced with LRU eviction below. */
const MARKETING_HOME_STATSIG_CACHE_MAX = 5_000;

type CacheRow = {
expiresAt: number;
/** LRU tie-breaker when the map is full and every row is still inside TTL. */
lastAccessAt: number;
bundle: MarketingHomeStatsigBundle;
};

const marketingHomeStatsigCache = new Map<string, CacheRow>();

function sweepExpiredMarketingHomeStatsigCache(now: number) {
for (const [k, v] of marketingHomeStatsigCache) {
if (v.expiresAt <= now) {
marketingHomeStatsigCache.delete(k);
}
}
}

/** If still above max (e.g. many first-time visitors inside TTL), drop least-recently-used rows. */
function evictMarketingHomeStatsigCacheLru() {
const max = MARKETING_HOME_STATSIG_CACHE_MAX;
const target = Math.floor(max * 0.75);
if (marketingHomeStatsigCache.size <= max) return;

const entries = [...marketingHomeStatsigCache.entries()].sort(
(a, b) => a[1].lastAccessAt - b[1].lastAccessAt
);
while (marketingHomeStatsigCache.size > target && entries.length > 0) {
const next = entries.shift();
if (next === undefined) break;
marketingHomeStatsigCache.delete(next[0]);
}
}

async function evaluateHeroDescriptionWithClient(
client: Statsig,
statsigUser: StatsigUser,
fallback: string
): Promise<string> {
const client = await getStatsigServerClient();
if (!client) return fallback;

try {
const statsigUser = toStatsigUser(user);
const experiment = client.getExperiment(
statsigUser,
MARKETING_HERO_EXPERIMENTS.bestDescription,
Expand All @@ -39,18 +86,12 @@ export async function evaluateHeroDescriptionExperiment(
}
}

/**
* SSR: `hero_layout` (experiment and/or dynamic config with the same id).
*/
export async function evaluateHeroLayoutExperiment(
user: StatsigUser | StatsigServerUserInput,
async function evaluateHeroLayoutWithClient(
client: Statsig,
statsigUser: StatsigUser,
fallback: HeroLayoutVariant
): Promise<HeroLayoutVariant> {
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);
Expand All @@ -64,3 +105,88 @@ export async function evaluateHeroLayoutExperiment(
return fallback;
}
}

/**
* One Statsig client + one user shape for the homepage: parallel eval + bootstrap, with a short
* in-memory cache keyed by stable id to speed repeat `/` loads.
*/
export async function loadMarketingHomeStatsigBundle(
user: StatsigUser | StatsigServerUserInput,
cacheKey: string,
fallbacks: { subtitle: string; layout: HeroLayoutVariant }
): Promise<MarketingHomeStatsigBundle> {
const now = Date.now();
sweepExpiredMarketingHomeStatsigCache(now);

const hit = marketingHomeStatsigCache.get(cacheKey);
if (hit && hit.expiresAt > now) {
hit.lastAccessAt = now;
return hit.bundle;
}

const client = await getStatsigServerClient();
if (!client) {
return {
heroSubtitleBase: fallbacks.subtitle,
heroLayoutBase: fallbacks.layout,
statsigBootstrap: null
};
}

const statsigUser = toStatsigUser(user);

const bootstrapFilters = {
experimentFilter: new Set([
MARKETING_HERO_EXPERIMENTS.bestDescription,
MARKETING_HERO_EXPERIMENTS.heroLayout
]),
dynamicConfigFilter: new Set([MARKETING_HERO_EXPERIMENTS.heroLayout])
};

const [heroSubtitleBase, heroLayoutBase, statsigBootstrap] = await Promise.all([
evaluateHeroDescriptionWithClient(client, statsigUser, fallbacks.subtitle),
evaluateHeroLayoutWithClient(client, statsigUser, fallbacks.layout),
Promise.resolve(
getStatsigClientBootstrapPayloadForClient(client, statsigUser, bootstrapFilters)
)
]);

const bundle: MarketingHomeStatsigBundle = {
heroSubtitleBase,
heroLayoutBase,
statsigBootstrap
};

marketingHomeStatsigCache.set(cacheKey, {
expiresAt: now + MARKETING_HOME_STATSIG_CACHE_TTL_MS,
lastAccessAt: now,
bundle
});
evictMarketingHomeStatsigCacheLru();

return bundle;
}

/**
* SSR: `best_description`. Client must still call `readMarketingHeroExperimentsForExposure` so Pulse gets an exposure.
*/
export async function evaluateHeroDescriptionExperiment(
user: StatsigUser | StatsigServerUserInput,
fallback: string
): Promise<string> {
const client = await getStatsigServerClient();
if (!client) return fallback;
return evaluateHeroDescriptionWithClient(client, toStatsigUser(user), fallback);
}

/**
* SSR: `hero_layout` (experiment and/or dynamic config with the same id).
*/
export async function evaluateHeroLayoutExperiment(
user: StatsigUser | StatsigServerUserInput,
fallback: HeroLayoutVariant
): Promise<HeroLayoutVariant> {
const client = await getStatsigServerClient();
if (!client) return fallback;
return evaluateHeroLayoutWithClient(client, toStatsigUser(user), fallback);
}
4 changes: 3 additions & 1 deletion src/lib/statsig/hero-statsig.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export {
export {
evaluateHeroDescriptionExperiment,
evaluateHeroLayoutExperiment,
MARKETING_HERO_EXPERIMENTS
loadMarketingHomeStatsigBundle,
MARKETING_HERO_EXPERIMENTS,
type MarketingHomeStatsigBundle
} from './experiments/marketing-hero-server';
export type {
MarketingHeroClientExposureDebug,
Expand Down
15 changes: 14 additions & 1 deletion src/lib/statsig/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private';
import {
Statsig,
StatsigUser,
type ClientInitResponseOptions,
type StatsigOptions,
type StatsigUserArgs
} from '@statsig/statsig-node-core';
Expand Down Expand Up @@ -76,12 +77,24 @@ export async function getStatsigClientBootstrapPayload(
): Promise<string | null> {
const client = await getStatsigServerClient();
if (!client) return null;
return getStatsigClientBootstrapPayloadForClient(client, user);
}

/** Same as {@link getStatsigClientBootstrapPayload} when the Node client is already initialized. */
export function getStatsigClientBootstrapPayloadForClient(
client: Statsig,
user: StatsigUser | StatsigServerUserInput,
initOverrides?: Pick<
ClientInitResponseOptions,
'experimentFilter' | 'dynamicConfigFilter' | 'featureGateFilter' | 'layerFilter'
>
): string | null {
try {
const statsigUser = toStatsigUser(user);
const response = client.getClientInitializeResponse(statsigUser, {
hashAlgorithm: 'djb2',
clientSdkKey: STATSIG_CLIENT_SDK_KEY
clientSdkKey: STATSIG_CLIENT_SDK_KEY,
...initOverrides
});
if (response == null || response === '') {
return null;
Expand Down
Loading
Loading