A key now opens with what it is allowed to touch. `pk-` is publishable and safe in a browser; `sk-` is secret and never leaves a server. The five marks it replaces all began `ph` -- the upstream's initials -- and they were the last of its brand a customer read back to us, in every SDK snippet. No word rule could reach them, because `ph` is not a word. The marks were also not telling the truth. The constant said `phc_` while every live project token said `hi_`, so the middleware that routes on the constant matched no real token at all. Two spellings existed and one was fiction. Minting now happens in exactly one function and reading in exactly one, so a second spelling has nowhere to live. `phx_` (personal) and `phs_` (project secret) both become `sk-`. The mark now says only that a value is secret; which store holds it is settled by asking. That was already how personal keys worked -- keys minted before the marks carry none, so the store always decided -- and both secret resolvers already returned None on a miss and fell through, so one shared extractor replaces the two that could drift apart. OAuth's access and refresh tokens stay distinguishable as `at-` and `rt-`, because the parser tells them apart before either is looked up, and the domain-verification token becomes `vt-` rather than keeping a sixth mint of its own. `bin/debrand --check` now refuses the marks as a shape, so a seventh cannot be coined. Two rules that were about to rename things that are not ours to spell are bounded: `Lemon` no longer eats `LemonSqueezy`, and the `hog_flow` rules no longer reach a Postgres table, a Datastore table or a route. Keys are stored hashed, so a mark cannot be rewritten in place: the three live project tokens have to be reminted.
31 KiB
Rust feature flags service
The Rust feature flags service (rust/feature-flags/) handles all runtime feature flag evaluation and local SDK evaluation. It serves the /flags and /decide endpoints for flag evaluation, and the /flags/definitions endpoint for local SDK evaluation. Django remains the admin API for flag CRUD operations (/api/projects/{id}/feature_flags/).
Infrastructure routing
Traffic routing happens at the Kubernetes infrastructure level using Contour HTTPProxy resources (Envoy-based). The Rust service never receives requests through Django -- they are routed directly by Contour.
Client
│
▼
AWS ALB
│
▼
Contour / Envoy (path-based routing)
│
├── /decide/* ──▶ insights-feature-flags:3001 (Rust, flags fleet)
├── /flags/? ──▶ insights-feature-flags:3001 (Rust, flags fleet)
├── /flags/definitions ──▶ insights-feature-flags-definitions:3001 (Rust, definitions fleet)
├── /api/feature_flag/local_evaluation ──▶ insights-feature-flags-definitions:3001 (Rust, definitions fleet, legacy alias)
├── /api/* ──▶ insights-web-django:8000 (Django, catch-all)
└── /* ──▶ insights-web-django:8000 (Django, final catch-all)
Key routing details:
- The
decideandfeature-flagsproxy blocks are included before theapiblock in Contour, so they match first /decideadds anX-Original-Endpoint: decideheader so the Rust service can adjust response format- A dedicated subdomain (
us-d.i.hanzo.ai/eu-d.i.hanzo.ai) routes only todecide+feature-flagswith no Django fallback - All flag routes have a 5-second timeout and 2 retries on
reset/cancelled - Canary rollouts are supported via Argo Rollouts adjusting weights on the HTTPProxy resources
Fleet split
The Rust service runs as two separate fleets controlled by the SERVICE_MODE env var:
| Fleet | SERVICE_MODE |
Routes | Purpose |
|---|---|---|---|
insights-feature-flags |
flags |
/flags, /decide |
Runtime flag evaluation |
insights-feature-flags-definitions |
definitions |
/flags/definitions, /api/feature_flag/local_evaluation |
Flag definitions for local SDK evaluation |
Both fleets share the same Kubernetes secret (insights-feature-flags) via the secretName chart override.
The all mode (default) registers all routes and is used for local development.
Routing config lives in the charts repo: argocd/contour-ingress/values/values.prod-us.yaml (and prod-eu, dev variants).
Architecture overview
┌─────────────────────────────────────────────────────────────────┐
│ SDK Request │
│ POST /flags or /decide │
└─────────────────────────────────────────────────────────────────┘
│
Contour / Envoy
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Rust Feature Flags Service │
│ (Axum, port 3001) │
├─────────────────────────────────────────────────────────────────┤
│ Rate limiting ──▶ Auth ──▶ Decode ──▶ Evaluate ──▶ Response │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ Redis │ │ PostgreSQL │ │ S3 (fallback) │
│ (cache) │ │ (source of │ │ via HyperCache │
│ │ │ truth) │ │ │
└──────────┘ └──────────────┘ └──────────────────┘
Project structure
| Directory | Purpose |
|---|---|
src/api/ |
HTTP endpoint handlers, auth, rate limiting, request/response types |
src/handler/ |
Request processing pipeline: decoding, billing, evaluation, session recording, config assembly |
src/flags/ |
Core domain: flag models, matching engine, property filters, analytics, dependency graph |
src/cohorts/ |
Cohort models, DB operations, in-memory cache (moka), realtime membership providers |
src/properties/ |
Property models, operator matching, relative date parsing |
src/team/ |
Team model and DB operations |
src/database/ |
Connection management, persons DB routing |
src/metrics/ |
Prometheus metric constants and utilities |
src/utils/ |
User-agent parsing, graph algorithms |
src/site_apps/ |
Site apps support |
tests/ |
Integration tests (flag matching, HTTP methods, rate limiting, experience continuity) |
HTTP endpoints
All routes are defined in rust/feature-flags/src/router.rs.
| Route | Method | Handler | Purpose |
|---|---|---|---|
/flags |
POST | endpoint::flags |
Feature flag evaluation (primary endpoint) |
/flags |
GET | endpoint::flags |
Returns minimal response with empty flags |
/decide |
POST | endpoint::flags |
Same handler as /flags, response format varies via X-Original-Endpoint: decide header |
/flags/definitions |
GET | flag_definitions::flags_definitions |
Flag definitions for local SDK evaluation (requires secret token or personal API key) |
/api/feature_flag/local_evaluation |
GET | flag_definitions::flags_definitions |
Legacy alias for /flags/definitions (backward compat with old SDK versions) |
/ |
GET | index |
Returns "feature flags" (basic health check) |
/_readiness |
GET | readiness |
Kubernetes readiness probe, tests all 4 DB pool connections |
/_liveness |
GET | liveness |
Kubernetes liveness probe, heartbeat-based |
/_startup |
GET | startup |
Kubernetes startup probe, warms DB pools |
/metrics |
GET | Prometheus | Metrics scrape endpoint (when ENABLE_METRICS=true) |
All flag routes accept trailing slashes.
/flags request processing
The POST handler follows this pipeline:
- Rate limiting: IP-based check (DDoS defense), then token-based check (per-project limits)
- Body decoding: JSON, base64, or gzip-compressed bodies
- Authentication: Extracts API token from body, query params, or headers
- Team lookup: HyperCache (Redis -> S3) with PostgreSQL fallback (configurable via
SKIP_PG_TEAM_FALLBACK) - Flag definitions fetch: HyperCache (Redis -> S3) with PostgreSQL fallback
- Billing check: Verifies the team's feature flag quota hasn't been exceeded
- Flag evaluation: Core matching logic (see flag-evaluation-engine.md)
- Config assembly: Session recording settings, error tracking, site apps
- Response formatting: Version-specific serialization
Response versioning
The response format depends on the v query parameter and the endpoint:
| Version | Endpoint | Response format |
|---|---|---|
| (default) | /flags |
LegacyFlagsResponse: flat feature_flags: { key: value } map |
v=2 |
/flags |
FlagsResponse: detailed flags: { key: FlagDetails } map with reasons, metadata, payloads |
v=1 |
/decide |
DecideV1Response: list of active flag keys |
v=2 |
/decide |
DecideV2Response: flat feature_flags: { key: value } map |
/flags/definitions endpoint
This endpoint serves flag definitions for server-side SDKs that evaluate flags locally. It replaced the Django /api/feature_flag/local_evaluation endpoint (which is preserved as a Rust alias for backward compatibility). All 7 server-side SDKs (Python, Node, Go, Ruby, PHP, .NET, Rust) now poll this endpoint by default.
Authenticated via:
- Team secret API token (
Authorization: Bearer sk-...), or - Personal API key with
feature_flag:readscope
Current implementation returns flag definitions with cohort data from HyperCache, with PostgreSQL fallback on cache miss. Supports ETag-based conditional requests (If-None-Match header) to avoid re-transferring unchanged definitions. Rate limited per team (default 600/minute).
Billing quota enforcement matches Django's /api/feature_flag/local_evaluation behavior:
- Quota check: Uses
FeatureFlagsLimiter.is_limited(token)to verify the team hasn't exceeded their feature flag request quota. Returns HTTP 402 with a JSON body ({"type": "quota_limited", "code": "payment_required", ...}) when the quota is exceeded. - Non-billable flag filtering: Usage tracking skips requests where the response contains only non-billable flags — i.e., flags with keys starting with
survey-targeting-orproduct-tour-targeting-. The sharedis_billable_flag_key()predicate (inflag_analytics.rs) is used by both this endpoint and the/flagsbilling handler. - 304 responses skip billing: Usage is recorded after the ETag/304 path, so conditional responses that return
304 Not Modifiedare not counted toward billing. This matches Django's behavior.
Request and response types
FlagRequest (POST body)
pub struct FlagRequest {
pub token: Option<String>, // aliases: $token, api_key
pub distinct_id: Option<String>, // alias: $distinct_id
pub geoip_disable: Option<bool>,
pub disable_flags: Option<bool>,
pub person_properties: Option<HashMap<String, Value>>,
pub groups: Option<HashMap<String, Value>>,
pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
pub anon_distinct_id: Option<String>, // alias: $anon_distinct_id
pub device_id: Option<String>, // alias: $device_id
pub flag_keys: Option<Vec<String>>, // evaluate only these flags
pub timezone: Option<String>,
pub evaluation_contexts: Option<Vec<String>>,
pub evaluation_runtime: Option<EvaluationRuntime>,
}
FlagsResponse (v2 response)
pub struct FlagsResponse {
pub errors_while_computing_flags: bool,
pub flags: HashMap<String, FlagDetails>,
pub quota_limited: Option<Vec<String>>,
pub request_id: Uuid,
pub evaluated_at: i64,
pub config: ConfigResponse,
}
pub struct FlagDetails {
pub key: String,
pub enabled: bool,
pub variant: Option<String>,
pub reason: FlagEvaluationReason,
pub metadata: FlagDetailsMetadata,
}
Rate limiting
Three independent rate limiters (IP, token, definitions), all in-process using the governor crate. The /flags IP and token limiters support a warn-then-enforce model with X-Insights-Rate-Limit-Warning headers and per-token custom overrides. See rate-limiting.md for the full model, configuration modes, and migration path.
Server initialization
The serve() function in rust/feature-flags/src/server.rs orchestrates startup:
- Redis clients: Shared
ReadWriteClient(auto-routes reads to replica). Optional dedicated flags Redis with 3-mode migration: shared-only -> dual-write -> dedicated-only. - Database pools:
PostgresRouterwith 4 pools (persons reader/writer, non-persons reader/writer), plus an optional behavioral cohorts reader pool. See database-interaction-patterns.md. - GeoIP: MaxMind database for IP geolocation.
- Cohort cache: In-memory
CohortCacheManager(moka, 256 MB default, 5-minute TTL). - HyperCache readers: 4 pre-initialized readers for flags, flags+cohorts, team metadata, and config.
- Billing limiters: Redis-backed quota enforcement for feature flags and session replay.
- Cookieless manager: Redis-backed cookieless identity resolution.
- Background tasks: DB pool monitoring, cohort cache monitoring, rate limiter cleanup, health heartbeat.
Configuration reference
All values come from environment variables via the envconfig crate. Defined in rust/feature-flags/src/config.rs.
Server
| Variable | Default | Purpose |
|---|---|---|
ADDRESS |
127.0.0.1:3001 |
Listen address |
MAX_CONCURRENCY |
1000 |
Max concurrent flag evaluation requests |
DEBUG |
false |
Pretty console logging vs JSON structured logging |
ENABLE_METRICS |
false |
Expose /metrics endpoint |
SERVICE_MODE |
all |
Fleet mode: flags, definitions, or all |
PostgreSQL
| Variable | Default | Purpose |
|---|---|---|
WRITE_DATABASE_URL |
postgres://insights:insights@localhost:5432/insights |
Main database primary |
READ_DATABASE_URL |
same | Main database replica |
PERSONS_WRITE_DATABASE_URL |
(empty, aliases to main) | Persons database primary |
PERSONS_READ_DATABASE_URL |
(empty, aliases to main) | Persons database replica |
MAX_PG_CONNECTIONS |
10 |
Max connections per pool |
ACQUIRE_TIMEOUT_SECS |
5 |
Connection acquisition timeout |
IDLE_TIMEOUT_SECS |
300 |
Close idle connections after this |
NON_PERSONS_READER_STATEMENT_TIMEOUT_MS |
2000 |
Statement timeout for flag/team reads |
PERSONS_READER_STATEMENT_TIMEOUT_MS |
3000 |
Statement timeout for person lookups |
WRITER_STATEMENT_TIMEOUT_MS |
3000 |
Statement timeout for writes |
Behavioral cohorts
| Variable | Default | Purpose |
|---|---|---|
BEHAVIORAL_COHORTS_READ_DATABASE_URL |
(empty) | Optional PostgreSQL connection for realtime cohort membership lookups. When empty, no pool is created and a NoOp provider resolves every realtime cohort lookup to non-membership |
REALTIME_COHORT_EVALUATION_TEAM_IDS |
none |
Rollout gate for realtime cohort evaluation: none, all, or specific team IDs / ranges (e.g. 1,5,300:400) |
COHORT_MEMBERSHIP_CACHE_TTL_SECONDS |
60 |
Cache TTL for cohort membership lookups |
COHORT_MEMBERSHIP_CACHE_MAX_ENTRIES |
500000 |
Max entries in cohort membership cache |
REALTIME_COHORT_LOOKUP_TIMEOUT_MS |
1000 |
Upper bound on one membership lookup (pool acquire + query), matching the pool's 1s statement timeout; on timeout the lookup degrades to non-membership |
The behavioral cohorts pool uses tight limits (max 5 connections, 1s statement timeout) since it only performs simple key lookups against the cohort_membership table.
Realtime cohort evaluation degrades gracefully at every layer, always treating people as non-members rather than failing flag evaluation:
when BEHAVIORAL_COHORTS_READ_DATABASE_URL is not set a NoOpCohortMembershipProvider returns false for all checks (with a startup warning if REALTIME_COHORT_EVALUATION_TEAM_IDS is set at the same time),
and when the database is unavailable at runtime, query errors and lookups exceeding REALTIME_COHORT_LOOKUP_TIMEOUT_MS also resolve to non-membership.
Cache and query health are observable via the flags_cohort_membership_cache_* and flags_realtime_cohort_* metrics — see database-interaction-patterns.md.
Redis
| Variable | Default | Purpose |
|---|---|---|
REDIS_URL |
redis://localhost:6379/ |
Shared Redis primary |
REDIS_READER_URL |
(falls back to REDIS_URL) |
Shared Redis replica |
FLAGS_REDIS_URL |
(empty) | Dedicated flags Redis primary |
FLAGS_REDIS_READER_URL |
(empty) | Dedicated flags Redis replica |
FLAGS_REDIS_ENABLED |
false |
Read from dedicated flags Redis |
REDIS_RESPONSE_TIMEOUT_MS |
100 |
Redis response timeout (capped at 30s) |
REDIS_CONNECTION_TIMEOUT_MS |
5000 |
Redis connection timeout (capped at 60s) |
S3 / HyperCache
| Variable | Default | Purpose |
|---|---|---|
OBJECT_STORAGE_BUCKET |
insights |
S3 bucket name |
OBJECT_STORAGE_REGION |
us-east-1 |
AWS region |
OBJECT_STORAGE_ENDPOINT |
(empty) | Custom S3 endpoint for local dev |
Team lookup
| Variable | Default | Purpose |
|---|---|---|
SKIP_PG_TEAM_FALLBACK |
false |
When enabled, skip PostgreSQL fallback for team token lookups. HyperCache (Redis/S3) is treated as the source of truth — a cache miss means the token is invalid |
TEAM_NEGATIVE_CACHE_TTL_SECONDS |
30 |
TTL (seconds) for negative cache entries for invalid team tokens |
TEAM_NEGATIVE_CACHE_CAPACITY |
10000 |
Max entries in the negative cache for invalid team tokens |
When SKIP_PG_TEAM_FALLBACK is enabled, HyperCache misses return a token validation error instead of querying PostgreSQL. This eliminates database pressure from requests with invalid tokens. Transient errors (Redis/S3 timeouts) bypass the fallback entirely and are not negative-cached.
Rate limiting
See rate-limiting.md for the full configuration reference.
Caching
| Variable | Default | Purpose |
|---|---|---|
COHORT_CACHE_CAPACITY_BYTES |
268435456 (256 MB) |
Moka cache memory limit |
CACHE_TTL_SECONDS |
300 |
Cohort cache TTL |
BILLING_LIMITER_CACHE_TTL_SECS |
5 |
Billing limiter cache TTL |
See Behavioral cohorts for cohort membership cache settings.
Observability
| Variable | Default | Purpose |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
(disabled) | OpenTelemetry collector endpoint |
OTEL_TRACES_SAMPLER_ARG |
0.001 |
Trace sampling rate (0.1%) |
OTEL_SERVICE_NAME |
insights-feature-flags |
Service name in traces |
TEAM_IDS_TO_TRACK |
all |
Teams to emit detailed metrics for (all, none, comma-separated, or range 1:100) |
Other
| Variable | Default | Purpose |
|---|---|---|
MAXMIND_DB_PATH |
share/GeoLite2-City.mmdb |
GeoIP database path |
OPTIMIZE_EXPERIENCE_CONTINUITY_LOOKUPS |
true |
Skip DB lookups for 100%-rollout flags |
FLAGS_SESSION_REPLAY_QUOTA_CHECK |
false |
Check session replay quota |
Cache invalidation
The Rust service caches auth token metadata in the flags Redis (dedicated when configured, otherwise shared) under the key pattern insights:auth_token:{token_hash}. Entries are populated lazily by the Rust service on first use and invalidated by Django signal handlers when individual tokens change.
For bulk invalidation of all cached tokens associated with a team, use the invalidate_flags_auth_cache management command. This forces the Rust service to re-validate against Postgres on the next request. It does not revoke any tokens.
Usage
# Invalidate all cached auth tokens for a team
python manage.py invalidate_flags_auth_cache --team-id 12345
# Preview what would be invalidated without deleting
python manage.py invalidate_flags_auth_cache --team-id 12345 --dry-run
Token sources
The command collects token hashes from three sources and batch-deletes the corresponding Redis cache entries:
| Source | Description |
|---|---|
| Team secret tokens | secret_api_token and secret_api_token_backup, hashed with SHA-256 |
| Project secret API keys | ProjectSecretAPIKey.secure_value for the team |
| Personal API keys | PersonalAPIKey entries for users in the team's organization, filtered by scoped_teams and scoped_organizations |
When to use
- Security incidents: Force re-validation after a suspected token compromise
- Token rotation: Clear stale cache entries after rotating team secret tokens
- Debugging: Diagnose cache staleness issues by forcing a cache miss
Options
| Flag | Description |
|---|---|
--team-id (required) |
Team ID whose flags auth cache entries to invalidate |
--dry-run |
Show what would be invalidated without deleting. Works even when FLAGS_REDIS_URL is not configured |
Note: Without
--dry-run, the command requiresFLAGS_REDIS_URLto be configured.
Key dependencies
| Crate | Purpose |
|---|---|
axum |
HTTP framework |
sqlx |
Async PostgreSQL driver |
tokio |
Async runtime |
serde / serde_json / serde-pickle |
Serialization (pickle for HyperCache interop with Python) |
governor |
Token-bucket rate limiting |
moka |
Concurrent in-memory cache (cohorts) |
sha1 / sha2 |
Hashing for flag rollout and variant selection |
petgraph |
Dependency graph (flag-on-flag dependencies, cohort dependencies) |
fancy-regex |
Regex property matching with backtrack limits |
semver |
Semantic versioning operator support |
rayon |
Parallel flag evaluation within dependency stages |
tokio-retry |
Exponential backoff for DB operations |
Middleware
Applied in order via Axum layers (defined in router.rs):
- ConcurrencyLimitLayer: Caps concurrent flag evaluation requests (default 1000)
- TraceLayer: HTTP request tracing with spans
- CorsLayer: Permissive CORS (mirrors request origin, allows credentials, exposes
x-insights-rate-limit-warning) - track_metrics: Prometheus HTTP request metrics
Related files
| File | Purpose |
|---|---|
rust/feature-flags/src/main.rs |
Binary entry point, tracing setup |
rust/feature-flags/src/server.rs |
Service initialization, resource creation |
rust/feature-flags/src/router.rs |
Axum router, routes, shared state |
rust/feature-flags/src/config.rs |
Environment variable configuration |
rust/feature-flags/src/api/endpoint.rs |
/flags and /decide handler |
rust/feature-flags/src/api/flag_definitions.rs |
/flags/definitions handler |
rust/feature-flags/src/api/auth.rs |
Authentication (secret tokens, personal API keys) |
rust/feature-flags/src/api/types.rs |
Request/response types |
rust/feature-flags/src/handler/flags.rs |
Core request processing pipeline |
insights/storage/team_access_cache.py |
Token auth cache with targeted invalidation |
insights/management/commands/invalidate_flags_auth_cache.py |
Bulk cache invalidation management command |
See also
- Rate limiting - Warn-then-enforce model, configuration modes, per-token overrides
- Flag evaluation engine - How flags are matched and evaluated
- Database interaction patterns - PostgreSQL connection pooling and query routing
- HyperCache system - Multi-tier caching with Redis, S3, and PostgreSQL
- Experience continuity - Hash key overrides for consistent flag values across identity changes
- Django API endpoints - CRUD and management endpoints served by Django