435 lines
12 KiB
Markdown
435 lines
12 KiB
Markdown
|
|
# 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=<hex_hmac>
|
||
|
|
```
|
||
|
|
|
||
|
|
Local dev-only cookie override is also supported:
|
||
|
|
|
||
|
|
- cookie name: `exp_debug_<experiment_key>`
|
||
|
|
- 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
|