Monitoring and Observability
The API exposes Prometheus metrics at /metrics, writes structured logs, and
answers health probes. On AWS, the CDK's monitoring stack adds the CloudWatch
dashboards, the alarms and the SNS topic that emails them: see
What Gets Deployed.
Prometheus Metrics
The API exposes a Prometheus-compatible metrics endpoint:
- With
METRICS_TOKENset, a scrape sends it asAuthorization: Bearer <token>or as?token=<token>. Without it, or with another value, the answer is401. - With no
METRICS_TOKEN, the endpoint answers in development and test, and answers403in every other environment. METRICS_ENABLED=falseturns the endpoint off: it answers404.
Key Metrics Exported
| Metric | Type | Description |
|---|---|---|
http_requests_total |
Counter | Total HTTP requests by method, endpoint and status code |
http_request_duration_seconds |
Histogram | Request latency (p50, p95, p99 percentiles) |
experiment_assignments_total |
Counter | Variant assignments by experiment and variant |
feature_flag_evaluations_total |
Counter | Flag evaluations by flag key and result |
events_tracked_total |
Counter | Tracked events by type |
rate_limit_redis_fallback_active |
Gauge | 1 while this process counts rate limits per process because Redis is unavailable; 0 while it counts them in Redis |
rate_limit_redis_fallbacks_total |
Counter | Times this process switched rate limiting from Redis to per-process counting |
Each API process exports its own values: with more than one worker per container, a scrape sees
the worker that answered it. While rate_limit_redis_fallback_active is 1, per-IP rate limits are
multiplied by the number of API processes; the limiter tries Redis again every 30 s.
Sample Prometheus Scrape Config
A job for the scrape_configs list of prometheus.yml, with the token in a file:
- job_name: 'experimentation-api'
metrics_path: '/metrics'
scheme: 'https'
authorization:
credentials_file: /etc/prometheus/experimently-metrics-token
static_configs:
- targets: ['app.example.com']
scrape_interval: 30s
Structured Logging (structlog)
The API uses structlog for JSON-formatted structured logs. Logging Standards lists the fields each line carries.
Standard Fields
| Field | Description |
|---|---|
timestamp |
ISO 8601 UTC timestamp |
level |
debug, info, warning, error, critical |
logger |
Module path of the logging component |
request_id |
Unique identifier for the HTTP request (correlates all log entries for one request) |
Health Check
The API answers GET /health without authentication, for load balancers and
monitoring. It is the readiness probe, the same as GET /health/ready:
Outside production it reports each check with its detail:
{
"status": "healthy",
"timestamp": "2026-10-07T19:42:43.499684+00:00",
"profile": "core",
"version": "0.26.3",
"environment": "development",
"checks": {
"database": {"status": "healthy", "latency_ms": 2.13},
"redis": {"status": "healthy", "latency_ms": 0.87},
"disk": {"status": "healthy", "free_gb": 41.7},
"modules": {"status": "healthy", "profile": "core"}
}
}
With ENVIRONMENT=production it leaves out version, environment and every
detail except each check's status:
{
"status": "healthy",
"timestamp": "2026-10-07T19:43:19.062038+00:00",
"profile": "core",
"checks": {
"database": {"status": "healthy"},
"redis": {"status": "healthy"},
"disk": {"status": "healthy"},
"modules": {"status": "healthy"}
}
}
It answers 503 Service Unavailable with "status": "unhealthy" when
PostgreSQL does not answer, when Redis does not answer and REDIS_REQUIRED=true,
or when the disk check fails. A failed check names the error's type outside
production:
{
"status": "unhealthy",
"timestamp": "2026-10-07T19:42:54.171329+00:00",
"profile": "core",
"version": "0.26.3",
"environment": "development",
"checks": {
"database": {"status": "unhealthy", "error": "OperationalError"},
"redis": {"status": "unhealthy", "error": "ConnectionError"},
"disk": {"status": "healthy", "free_gb": 41.7},
"modules": {"status": "healthy", "profile": "core"}
}
}
Without REDIS_REQUIRED=true, a Redis that does not answer is reported as
unhealthy under checks while the response stays 200 and "healthy". Free
disk space of 1 GB or less is reported as "low" and does not fail the probe.
GET /health/live is the liveness probe: it answers 200 whenever the process
is up and checks neither PostgreSQL nor Redis.
The ALB health check targets GET /health with a 5-second timeout and a 2/3 healthy/unhealthy threshold. Tasks that fail health checks are automatically replaced by ECS.