Architecture Overview
This document describes the high-level technical architecture of the platform: how the components fit together, how data flows, and how the system is deployed on AWS.
High-Level Components
┌──────────────────────────────────────────────────────────────────┐
│ Client Applications │
│ (web, mobile, server — via SDK or REST API) │
└──────────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Application Load Balancer (HTTPS) │
│ (no CloudFront; /api/* → API, everything else → the dashboard) │
└──────────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ Experiments / Feature Flags / Analytics / Auth │
└──────┬───────────────┬───────────────┬───────────────────────────┘
│ │ │
▼ ▼ ▼
PostgreSQL Redis DynamoDB
(Aurora) (ElastiCache) (real-time counters,
counters module)
A full-profile deployment also creates a Kinesis stream, Firehose, an S3 data lake, an OpenSearch domain and Glue ETL jobs. The API does not write to them today; see Analytics Pipeline.
API Layer
FastAPI Application
The core of the platform is a FastAPI application that exposes a REST API. It is deployed on ECS Fargate and scales horizontally behind an Application Load Balancer.
The API is organized into resource-specific endpoint groups:
| Endpoint Group | Description |
|---|---|
/api/v1/experiments |
Create, update, start, pause, stop, and query experiments |
/api/v1/feature-flags |
Manage feature flags, targeting rules, and rollout percentages |
/api/v1/results |
Read statistical results, with CUPED, Bayesian and sequential analyses, FDR correction, daily series and sample sizes |
/api/v1/tracking |
Ingest assignment and event data from client applications |
/api/v1/users |
User management and authentication |
/api/v1/compliance |
Audit event log and compliance reports |
/api/v1/integrations |
Jira, Salesforce, and GitHub integrations |
/api/v1/notifications |
Slack and email alert preferences |
/api/v1/rbac |
Role management and permission grants |
/api/v1/bandit |
Multi-armed bandit state and weight management |
Interactive API documentation is available at /api/v1/docs (Swagger UI) and /api/v1/redoc (ReDoc).
Background Schedulers
Several background tasks run on a configurable cycle alongside the API process:
| Scheduler | Default Cycle | Responsibility |
|---|---|---|
| Experiment scheduler | 15 minutes (EXPERIMENT_SCHEDULER_INTERVAL_MINUTES) |
Auto-starts draft experiments at start_date, resumes a paused experiment only at a resume_at scheduled after the pause, auto-stops at end_date |
| Rollout scheduler | 15 minutes | Advances rollout schedule stages based on time-based triggers |
| Safety monitor | 5 minutes | Checks error rate and latency thresholds; triggers auto-rollback if breached |
| Bandit scheduler | 5 minutes (BANDIT_UPDATE_INTERVAL_MINUTES) |
Recomputes variant weights for active multi-armed bandit experiments from DynamoDB counters when every variant has at least PostgreSQL's pulls and successes there, otherwise from PostgreSQL assignments + converting users (counted as /results counts them); /tracking/assign routes new users by these weights |
Database Layer
PostgreSQL (Aurora)
Amazon Aurora PostgreSQL is the primary relational database. It stores all persistent application data:
- Experiments, variants, metrics definitions
- Feature flags, targeting rules, rollout schedules
- User accounts, roles, permissions
- Audit events and compliance records
- Integration configurations
- Results aggregations
All schema changes are managed with Alembic migrations. The schema is namespaced under the experimentation PostgreSQL schema.
The production Aurora cluster is configured with a read replica. The API is given the writer endpoint only, so every query goes to the writer.
Redis (ElastiCache)
Redis holds the rate limiter's counters and the API's caches:
-
Rate-limit counters: the request counts the rate limiter keeps per client and route group.
-
Application cache: experiments, for an hour, and results. The API does not cache feature flags: it reads them from the database on every request, so a flag change is served on the next one.
Lambda Functions
No Lambda function serves requests or processes events. SDKs call the API for assignment
(POST /api/v1/tracking/assign) and flag evaluation (GET /api/v1/feature-flags/evaluate/{key}).
The stacks deploy three functions: DatabaseAccessLambda (compute stack) and
AnalyticsLambda (analytics stack, full profile) are placeholders whose inline code returns
200 and does nothing, and ETLTriggerLambda (Glue ETL stack, full profile) starts the Glue
jobs daily. The event-processor and flag-evaluation code under backend/lambda/
is not deployed by any stack. See AWS Integration.
Counters (DynamoDB, counters module)
The counters module keeps assignment and conversion counts in DynamoDB, incremented with atomic ADD operations through its own routes (POST /api/v1/counters/{experiment_id}/increment and POST /api/v1/counters/bulk). Assignment and tracking do not write them.
Counter Schema
The table is experiment-counters-<env>, keyed by pk and sk, with an
experiment-id-index global secondary index
(modules/infrastructure/cdk/stacks/dynamodb_counters_stack.py).
The bandit scheduler reads these counters first when it updates a bandit's weights, and falls
back to PostgreSQL when they are unavailable (backend/app/core/bandit_scheduler.py).
Analytics Pipeline
Tracked events are stored in PostgreSQL: POST /api/v1/tracking/track and
/tracking/batch write rows to the events table, and every analysis reads them from there.
The full profile's analytics stack creates a pipeline beside that, which the API does not feed today:
Kinesis Data Stream ──> Firehose ──> S3 data lake ──> Glue ETL (daily)
│
└──> AnalyticsLambda (placeholder: returns 200, processes nothing)
OpenSearch domain: created; nothing writes to it
Split URL Testing (Lambda@Edge)
A split URL experiment gives each variant its own URL. The split-URL module ships
a CloudFront construct (modules/infrastructure/constructs/split_url_distribution.py) with a
Lambda@Edge viewer-request router. No stack uses it: infrastructure/cdk/app.py creates
no CloudFront distribution, so a deployment has it only if you add it to a stack yourself.
The router (modules/lambda/split_url_router/handler.py):
Viewer request
|
+-- Read the experiment config from the X-Split-URL-Config request header
| - Missing, invalid, or fewer than two variants: pass the request through
|
+-- Read the assignment cookie
| - Holds a known variant URL: pass through
| - Absent or unknown: hash the client fingerprint (IP + User-Agent) to pick a variant
|
+-- Return 302 to the variant URL
|
+-- Set-Cookie with Max-Age of cookie_ttl_days (default 30 days)
The construct does not set X-Split-URL-Config, so how the configuration reaches the router is
left to you. Lambda@Edge functions must be deployed in us-east-1. See
Split URL testing.
Authentication
Sign-in
Users sign in with a local account by default (AUTH_PROVIDER=local): the API checks the password and issues a JWT access token, valid for 12 hours by default (LOCAL_AUTH_TOKEN_TTL_MINUTES). The token is passed in the Authorization: Bearer <token> header. Cognito Integration describes the other provider, an AWS Cognito user pool.
API Key Authentication
SDK clients and server-to-server integrations use API keys instead of JWT tokens. API keys are:
- Created by any signed-in user, for themselves, via
POST /api/v1/api-keys; whatever its creator's role, a key writes tracking data (assignments and events) - Passed in the
X-API-Key: <key>header - Not limited by their
scopes, with one exception: any active key authenticates every API-key route as the user who created it, except the server-side SDK routes (GET /api/v1/sdk/ruleset,POST /api/v1/tracking/evaluations,POST /api/v1/tracking/assign/batch), which require thesdk:rulesetscope (see API Key Management) - Revocable without affecting user accounts
Role-Based Access Control
Every user has one of four roles, each with progressively broader permissions:
| Role | Description |
|---|---|
| VIEWER | Reads every experiment, flag and result and changes none; can create an API key, which writes tracking data |
| ANALYST | View all experiments, results, audit logs, and reports |
| DEVELOPER | Create and manage experiments and feature flags |
| ADMIN | Full access: user management, global settings, compliance exports |
Custom roles and direct permission grants are stored and shown alongside the base role, but no permission check reads them yet: routes check the base role (#891).
CDK Infrastructure
The AWS infrastructure is defined as code using AWS CDK v2, in Python (infrastructure/cdk/app.py), which defines the stacks below; cdk deploy --all from infrastructure/cdk deploys them.
The Fargate stack runs the dashboard as its own ECS service behind the API's load
balancer: /api/*, /health, /health/* and /metrics go to the API, everything
else to the dashboard. cdk deploy starts it on experimentation-platform/web:bootstrap;
the Deploy workflow rolls each release onto it by digest, after the API. No
stack creates CloudFront. The split-URL module's CloudFront construct
exists but app.py does not use it. See AWS CDK Deployment.
Stacks
Stack (<env> is dev, staging or prod) |
Contents |
|---|---|
experimentation-vpc-<env> |
VPC, public/private/isolated subnets, NAT, route tables, network ACLs, gateway endpoints, security groups |
experimentation-auth-<env> |
Cognito user pool and app client |
experimentation-database-<env> |
Aurora PostgreSQL cluster, parameter group, KMS key, security group |
experimentation-redis-<env> |
ElastiCache Redis replication group, subnet group, security group |
experimentation-dynamodb-<env> |
Three DynamoDB tables (events, feature flags, overrides) |
experimentation-compute-<env> |
ECS cluster, task security group, a placeholder Lambda (DatabaseAccessLambda) |
experimentation-fargate-<env> |
ALB, HTTPS + test listeners, blue/green target groups, the API's Fargate service, CodeDeploy application and deployment group, auto-scaling; the dashboard's ECS service and target group |
experimentation-migrations-<env> |
One-off ECS task definition that runs the alembic upgrade |
experimentation-monitoring-<env> |
CloudWatch dashboards and alarms, and an SNS topic |
experimentation-dynamodb-counters-<env> |
Full profile only — the real-time experiment-counters table |
experimentation-analytics-<env> |
Full profile only — Kinesis stream, Firehose, S3 data lake, OpenSearch domain, a placeholder consumer Lambda |
experimentation-glue-etl-<env> |
Full profile only — Glue database and crawler, two ETL jobs, Athena results bucket, daily trigger |
A core deployment builds the first nine; a full one builds all twelve. The
names are the stack ids cdk deploy takes, not class names -- run
cdk list to see them for your environment.
Deployment Model
The API service uses blue/green deployment via AWS CodeDeploy:
- A new task definition is registered (the "green" environment)
- CodeDeploy gradually shifts traffic from the old (blue) to the new (green) deployment
- If health checks fail, traffic is automatically shifted back to blue
- The canary sends 10% of traffic to the new tasks for 15 minutes, then shifts the rest
Health and Observability
| Endpoint / Resource | Purpose |
|---|---|
GET /health |
The same as GET /health/ready: {"status": "healthy"} with 200 when the API can serve, "unhealthy" with 503 when it cannot |
GET /metrics |
Prometheus-format metrics (request count, latency histograms, error rates) |
| CloudWatch (CDK deployments) | The dashboards and alarms the monitoring and Fargate stacks create; see Monitoring |
| Structured JSON logs | Each request's log lines carry its request_id, path and method |