# A/B Testing System This repository now includes an SSR-first, SEO-safe A/B testing system for the SvelteKit frontend and Python API backend. The backend is now the managed source of truth for live experiment definitions. ## Production prerequisite The experiment system requires real SvelteKit SSR in production. - Run `npm install` in `frontend/` after pulling these changes. - The frontend is configured to use `@sveltejs/adapter-node` when installed. - Do not deploy the experiment system as a prerendered static build. ## Goals - Server-side assignment only. - Durable assignment by cookie. - SSR HTML already contains the chosen variant. - No bot targeting or user-agent-based variant serving. - Stable canonical signals across variants. - Frontend sends analytics only; it does not decide variants after render. ## Frontend flow ### Request lifecycle 1. `frontend/src/hooks.server.js` runs for every request. 2. It calls `buildRequestExperimentContext()` in `frontend/src/lib/experiments/server.js`. 3. The hook fetches the current experiment definitions from the backend using `frontend/src/lib/experiments/provider.server.js`. 4. The hook reads or creates the anonymous session cookie: - `gw_session_id` 5. For each eligible backend-managed experiment: - it reads the persisted experiment cookie if present - otherwise it hashes `gw_session_id + experiment_key` - it maps that hash into the configured traffic weights - it persists the assigned variant in a cookie such as `exp_homepage_hero` 6. The hook stores the resolved assignments in `event.locals`. 7. `+layout.server.js` exposes assignments, active experiments, analytics settings, and SEO metadata to the app. 8. Route server loads expose page-specific assignments: - `frontend/src/routes/+page.server.js` - `frontend/src/routes/our-pricing/+page.server.js` 9. Page components render the chosen variant directly during SSR: - `frontend/src/routes/Home-2.svelte` - `frontend/src/routes/Pricing.svelte` ### Cookies - `gw_session_id` - purpose: stable anonymous session id used for deterministic assignment and experiment analytics - scope: `path=/` - flags: `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS - expiry: 180 days - `exp_homepage_hero` - purpose: persists the homepage hero assignment - expiry: 90 days - `exp_pricing_cta` - purpose: persists the pricing CTA assignment - expiry: 90 days - `gw_analytics_consent` - purpose: enables non-essential experiment analytics when set to `granted` ### Backend-managed definitions Live definitions are read from: - `GET /api/experiments` The frontend uses a short-lived server cache and falls back to the local seed registry only if the backend cannot be reached. Relevant server-side env var: - `EXPERIMENTS_API_INTERNAL_URL` - optional internal URL the SvelteKit server should use to fetch experiment definitions - useful when the frontend and backend run on different internal hosts ### Deterministic assignment Assignment is deterministic and stable for a session: - hash input: `gw_session_id + ":" + experiment_key` - hash function: FNV-1a style integer hash in `hashToBucket()` - bucket space: `0..9999` - allocation: weighted selection via `pickVariant()` If the assignment cookie already exists and contains a valid variant, the cookie wins. ### Consent behavior Consent is split from assignment. - Variant assignment can stay enabled even without analytics consent. - Experiment analytics can be disabled until consent is granted. Relevant env vars: - `PUBLIC_EXPERIMENTS_ENABLED` - `false` disables all experiments globally - any other value leaves the system enabled - `PUBLIC_EXPERIMENT_ASSIGNMENT_COOKIE_MODE` - `necessary` means assignment cookies are allowed without analytics consent - `consent` means no experiment assignment cookie is set until consent is granted - `PUBLIC_EXPERIMENT_ANALYTICS_REQUIRES_CONSENT` - `true` means no experiment impression/click/form/conversion events are sent until consent is granted Recommended production values: - `PUBLIC_EXPERIMENTS_ENABLED=true` - `PUBLIC_EXPERIMENT_ASSIGNMENT_COOKIE_MODE=necessary` - `PUBLIC_EXPERIMENT_ANALYTICS_REQUIRES_CONSENT=true` ## SEO protection ### What makes this safe - Assignment happens on the server before HTML is rendered. - The same SSR HTML is served to crawlers and normal users for a given assigned variant. - No user-agent detection is used for variant serving. - No Googlebot special casing exists in the SvelteKit layer. - Variants do not use separate URLs. - Canonical handling is done in `frontend/src/routes/+layout.svelte` and remains stable across variants. - The current experiments change presentation, CTA emphasis, and component framing, not the underlying subject of the page. ### Canonical handling The canonical URL is generated in `frontend/src/lib/experiments/server.js`. - canonical source: request path only - query params are excluded from canonical output - variant does not affect canonical output ### QA/debug query params Supported debug query params: - `exp_key` - `exp_variant` - `exp_until` - `exp_sig` If any of these are present: - canonical still points to the clean path - the layout outputs `meta name="robots" content="noindex, nofollow"` This prevents QA links from becoming indexable. ### Signed QA override Production QA override format: - payload string: `${exp_key}:${exp_variant}:${exp_until}` - signature: `hex(HMAC_SHA256(EXPERIMENT_DEBUG_SECRET, payload))` Required params: - `exp_key` - `exp_variant` - `exp_until` - `exp_sig` Rules: - the signature must match - `exp_until` must be a future Unix timestamp in seconds - the variant must exist in the registry Example shape: ```text /?exp_key=homepage_hero_test&exp_variant=tiny_gang_social_proof&exp_until=1770000000&exp_sig= ``` Local dev-only cookie override is also supported: - cookie name: `exp_debug_` - example: `exp_debug_homepage_hero_test=tiny_gang_social_proof` - ignored outside localhost/127.0.0.1 ## Frontend analytics Experiment analytics live in `frontend/src/lib/experiments/client.js`. It sends: - `experiment_key` - `variant_key` - `session_id` - `path` - `event_name` - `timestamp` - `conversion_value` - `metadata` ### Tracked events - `impression` - auto-fired once per experiment variant per path per browser session - `cta_click` - fired from experiment-controlled CTAs on the homepage and pricing page - `form_start` - fired once when the Meet & Greet form is first engaged - `form_submit` - fired on submit attempt - `conversion` - fired after a successful Meet & Greet submission ### Delivery behavior - `navigator.sendBeacon()` first - `fetch(..., { keepalive: true })` fallback - failures are swallowed so UX does not block ## Backend API The Python experiment endpoints are implemented in `backend/app/routers/experiments.py`. ### Public endpoints - `GET /api/experiments` - returns current experiment definitions and variants - `POST /api/experiments/impression` - `POST /api/experiments/event` - `POST /api/experiments/conversion` ### Admin endpoint - `GET /api/v1/experiments/results` - optional query param: `experiment_key` - auth required ### Validation Payload validation is defined in `backend/app/schemas/experiments.py`. Checks include: - experiment/variant key format - session id format - path must start with `/` - allowed event names per endpoint - scalar-only metadata values - known experiment + variant validation against the backend registry ### Bot filtering Analytics quality filtering only applies to the analytics endpoints. - bot-like user agents are dropped from experiment analytics ingestion - this is not used for page serving - dropped bot requests return `{"ok": true, "accepted": false}` ### Rate limiting Each public experiment ingestion endpoint is limited to: - `30/minute` ## Database model Migration: - `backend/alembic/versions/bd9f6a8b7c1d_add_experiments.py` Tables: - `experiments` - `experiment_variants` - `experiment_events` Important indexed fields on `experiment_events`: - `experiment_key` - `variant_key` - `session_id` - `user_id` - `path` - `event_type` - `created_at` - composite `(experiment_key, variant_key, created_at)` - composite `(session_id, created_at)` ## Registry and sync Seed registry: - `backend/app/experiments/registry.py` Frontend fallback registry: - `frontend/src/lib/experiments/registry.js` Startup seed sync: - `backend/app/main.py` calls `sync_experiment_registry()` during app lifespan startup Important: - startup sync creates missing experiments and variants from code defaults - it does not overwrite existing admin-managed values - admin edits therefore persist across restarts ## Backend management Admin APIs: - `GET /api/admin/experiments` - `GET /api/admin/experiments/{experiment_key}` - `PUT /api/admin/experiments/{experiment_key}` Admin UI: - `/admin/experiments` Editable fields: - `cookie_name` - `name` - `description` - `enabled` - `eligible_routes` - variant labels - variant allocations - control variant selection ## Current experiments ### `homepage_hero_test` - route: `/` - variants: - `control` - `tiny_gang_social_proof` - changes: - hero headline - supporting copy - hero CTA order/text - trust-pill wording ### `pricing_cta_test` - route: `/our-pricing` - variants: - `control` - `meet_greet_emphasis` - changes: - pricing card CTA label - pricing CTA panel headline - pricing CTA panel body - pricing CTA panel button label ## How to add a new experiment 1. Add a seed definition to `backend/app/experiments/registry.py`. 2. Optionally add a fallback definition to `frontend/src/lib/experiments/registry.js`. 3. Start the backend so startup sync creates the DB rows. 4. Adjust the live definition in `/admin/experiments` if needed. 5. Expose the assignment in a route `+page.server.js` or `+layout.server.js`. 6. Render the variant in SSR using the server-provided assignment. 7. Only change presentation/CTA/component framing unless you are intentionally testing content that still preserves canonical meaning. 8. Add `experimentClick` tracking to experiment-owned CTAs. 9. If a form conversion should be attributed, make sure the page participates in `trackActiveExperiments()`. 10. Add or update tests. ## How to analyse results 1. Query `GET /api/v1/experiments/results`. 2. Compare variants on: - impressions - CTA clicks - form starts - form submits - conversions - unique sessions - conversion rate - conversion value total 3. Sanity-check sample size before making rollout decisions. 4. Review qualitative differences separately from SEO, because SEO is intentionally held constant by URL/canonical behavior. ## How to disable experiments safely ### Disable everything Set: ```text PUBLIC_EXPERIMENTS_ENABLED=false ``` Effect: - new requests render control - no active experiments are tracked - canonical behavior is unchanged ### Disable one experiment Set the experiment to disabled in `/admin/experiments`. Effect: - the page falls back to control - existing cookies are ignored because the backend-managed definition marks the experiment inactive ### Stop analytics but keep assignment Set: ```text PUBLIC_EXPERIMENT_ANALYTICS_REQUIRES_CONSENT=true ``` and do not grant `gw_analytics_consent`. Effect: - SSR variant assignment can continue - no experiment analytics events are sent ## Verification status Verified locally: - backend experiment endpoints and related analytics tests: - `python -m pytest backend/tests/test_experiments.py backend/tests/test_analytics.py backend/tests/test_analytics_ingest.py` Not verified locally: - frontend Vitest suite Reason: - `npm test` hit a local sandbox `spawn EPERM` restriction while loading Vitest config - escalation to rerun outside the sandbox was not approved in this session