Skip to content

About

Feature-flag service: targeting rules and percentage rollouts managed from an API and a React dashboard, pushed live over server-sent events to a Python SDK that evaluates flags locally and keeps working when the server is down.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Flagstaff

A feature-flag service: targeting rules managed through an API and a web dashboard, evaluated inside your app by a Python SDK that the server keeps current over server-sent events.

backend sdk frontend stack Python License: MIT

A project in the dashboard: its flags, each with a switch for every environment

Features

  • Projects with any number of environments (dev, staging and prod to start with). Each flag has variants of one kind (boolean, string, number or JSON) and its own targeting in every environment: ordered rules on user lists and attributes, percentage rollouts, a fallthrough, and a kill switch.
  • The flagstaff SDK evaluates in-process, in about 5 µs a check, and falls back to the default in your code, with a reason, whenever it can't answer. Sync and asyncio clients.
  • An edit reaches a connected SDK in about 100 ms at the median, and the last of 1,000 in about 170 ms, whichever instance each is on: the instance that saved the edit announces the new version on Redis, and each instance pushes the new rules down its own streams.
  • SDKs that can't hold a stream open poll with ETags instead, and a poll for unchanged rules usually costs the server no database query at all.
  • Edits carry the version they were based on in If-Match and fail with 412 rather than overwrite a change made since. Every change lands in a cursor-paged audit log with who made it.
  • A dashboard for all of it: flags with a switch for each environment, an editor for rules and rollouts that shows a conflict instead of overwriting one, API keys and the audit log. Its API client is typed from the server's own OpenAPI document, and CI fails when the two drift apart.
  • API keys stored only as hashes: admin keys for the API, SDK keys that read one environment.
  • Prometheus metrics, JSON logs with a request id on every line, and a shutdown that ends SDK streams at once so they reconnect to the other instance.

Dashboard

A targeting rule on country and plan, serving on to 25% and off to 75%

A rule in the targeting editor: pro customers in Bulgaria and Germany, a quarter of whom get the new checkout.

New checkout's page in the dark theme: on in every environment, with two rules in production

A flag's page: how each environment serves it, in the dark theme the dashboard takes from the system.

The API keys page: two SDK keys reading one environment each, and two admin keys

API keys and what each one can read. A new key's secret is shown once.

The audit log for the shop project: two admins switching flags and changing targeting

The audit log, here for one project: who changed what, and where.

Tech stack

Python 3.13, FastAPI, async SQLAlchemy 2.0 with asyncpg, PostgreSQL 18, Alembic, Redis 8 pub/sub, uvicorn, nginx, Docker Compose. The SDK needs only httpx; its evaluation engine is standard library only, and the server runs the same engine for previews. uv, ruff, mypy (strict), pytest with testcontainers, and GitHub Actions.

The dashboard: React 19, TypeScript (strict), Vite, TanStack Query and React Router, tested with Vitest, Testing Library and MSW, and end to end with Playwright against the running stack.

Quickstart

git clone https://github.com/emilbogdanov/feature-flag-service.git && cd feature-flag-service
docker compose up --build --wait
docker compose exec api-1 flagstaff-server create-admin-key --name "Release tooling"

The last command prints an admin key; it is shown once and only its hash is stored. Sign in with it at http://localhost:8080/dashboard/. The API answers on the same port (set FLAGSTAFF_PORT to move it), with its schema at /docs.

 ✔ Container flagstaff-postgres-1  Healthy
 ✔ Container flagstaff-redis-1     Healthy
 ✔ Container flagstaff-migrate-1   Exited
 ✔ Container flagstaff-api-1-1     Healthy
 ✔ Container flagstaff-api-2-1     Healthy
 ✔ Container flagstaff-proxy-1     Healthy
fs_admin_dq8eAjZLldv...
Admin key issued. Store the secret now: it cannot be shown again.

Usage

The dashboard does the flag, targeting and key steps below without curl; this is the same through the API. A project, a flag, and targeting for production, with ADMIN holding that key:

$ curl -s -X POST localhost:8080/projects -H "Authorization: Bearer $ADMIN" \
    -H "Content-Type: application/json" -d '{"key": "storefront", "name": "Storefront"}'
{"key":"storefront","name":"Storefront","created_at":"2026-10-19T22:45:24.959927Z"}

$ curl -s -X POST localhost:8080/projects/storefront/flags -H "Authorization: Bearer $ADMIN" \
    -H "Content-Type: application/json" -d '{"key": "new-checkout", "name": "New checkout",
    "kind": "boolean", "variants": [{"key": "on", "value": true}, {"key": "off", "value": false}],
    "off_variant": "off", "on_variant": "on"}'
{"key":"new-checkout","name":"New checkout","kind":"boolean","version":1,"environments":{"dev":
{"enabled":false,"off_variant":"off","rules":[],"fallthrough":{"variant":"on"}},...}

$ curl -s -X PUT localhost:8080/projects/storefront/flags/new-checkout/environments/prod/targeting \
    -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" -H 'If-Match: "1"' \
    -d @targeting.json

where targeting.json switches the flag on for two testers, and for a quarter of pro customers in Bulgaria and Germany:

{
  "enabled": true,
  "off_variant": "off",
  "rules": [
    {
      "id": "beta-testers",
      "conditions": [{"type": "user_list", "user_keys": ["user-7", "user-1042"]}],
      "serve": {"variant": "on"}
    },
    {
      "id": "eu-pro",
      "conditions": [
        {"type": "attribute", "attribute": "country", "operator": "in", "values": ["BG", "DE"]},
        {"type": "attribute", "attribute": "plan", "operator": "in", "values": ["pro"]}
      ],
      "serve": {"rollout": [{"variant": "on", "weight": 25000}, {"variant": "off", "weight": 75000}]}
    }
  ],
  "fallthrough": {"variant": "off"}
}

Saving it again from the same If-Match fails, since the flag has moved on:

{"detail":"flag 'new-checkout' is no longer at version 1, it is at 2","code":"version_mismatch"}

An SDK key for production:

$ curl -s -X POST localhost:8080/api-keys -H "Authorization: Bearer $ADMIN" \
    -H "Content-Type: application/json" \
    -d '{"kind": "sdk", "name": "checkout-api", "project": "storefront", "environment": "prod"}'
{"id":"2832b1be-08b1-45af-9886-fda290becc3a","kind":"sdk","name":"checkout-api",
"project":"storefront","environment":"prod",...,"secret":"fs_sdk_5on8MtLbHkVd..."}

What the SDK receives over its stream, before and after someone hits the kill switch:

$ curl -sN localhost:8080/sdk/stream -H "Authorization: Bearer $SDK"
retry: 1000

id: 2
event: ruleset
data: {"format":1,"version":2,"flags":[{"key":"new-checkout","kind":"boolean","enabled":true,...

id: 3
event: ruleset
data: {"format":1,"version":3,"flags":[{"key":"new-checkout","kind":"boolean","enabled":false,...

Switched back on, the flag read from an app:

from flagstaff import EvaluationContext, FlagstaffClient

with FlagstaffClient(sdk_key, base_url="http://localhost:8080") as flags:
    flags.wait_until_ready(timeout=5)
    print(flags.bool_value("new-checkout", "user-7", default=False))
    shopper = EvaluationContext("user-310", {"country": "DE", "plan": "pro"})
    print(flags.bool_detail("new-checkout", shopper, default=False))
True
FlagDetail(value=False, variant='off', reason=<Reason.RULE_MATCH: 'rule_match'>, rule_id='eu-pro', ruleset_version=4)

The SDK README covers the clients and their options, and the server README every endpoint, command and error.

Performance

Measured on a laptop (Ryzen 7 4800H, Windows 11, Docker Desktop) with the stack above and the load generated on the same machine; the method and the full tables are in docs/load-test.md. The first column is the first run; the second is after caching authenticated keys and ruleset versions, which the profiler showed were most of what an unchanged-rules poll cost.

First run Now
Flag check in the SDK 5.4 µs 5.3 µs
Polls answered 304, 40 connections 288 a second, p50 127.6 ms 457 a second, p50 46.8 ms
Polls answered with the whole ruleset 189 a second, p50 203.5 ms 242 a second, p50 159.6 ms
An edit reaching each of 1,000 streams, p50 253 ms 103.5 ms
... reaching the last of them, p50 310 ms 170 ms
Opening 1,000 streams 9.4 s 4.4 s

Part of the stream improvement is the measurement itself: the first run read all 1,000 streams in one process, whose own lag counted as delivery time. The 304 tail is still long (p99 704 ms), and two single-process instances on Docker Desktop are what these numbers describe.

Project structure

backend/        the API: domain, application services, Postgres and Redis adapters, routes
  migrations/   Alembic
  tests/        unit, API, integration (testcontainers) and stack tests
sdk/            the flagstaff package; src/flagstaff/engine is the evaluation engine
frontend/       the dashboard: React and TypeScript, built into the proxy's image
  src/api/      the API client, with schema.ts generated from the server's OpenAPI document
  e2e/          Playwright tests that drive the dashboard in the compose stack
loadtest/       flagstaff-load, which produced docs/load-test.md
deploy/         nginx and Prometheus configuration for the compose stack
compose.yaml    Postgres, Redis, migrations, two API instances, nginx serving the dashboard

Configuration

The server reads FLAGSTAFF_ environment variables, or a .env file (example).

Variable Default
FLAGSTAFF_DATABASE_URL required postgresql+asyncpg://...
FLAGSTAFF_REDIS_URL required carries change notifications between instances
FLAGSTAFF_LOG_LEVEL INFO DEBUG, INFO, WARNING or ERROR
FLAGSTAFF_LOG_FORMAT json json for shipping, text for a terminal
FLAGSTAFF_STREAM_HEARTBEAT_SECONDS 15 keep-alive on quiet streams, below any proxy's idle timeout
FLAGSTAFF_RULESET_RESYNC_SECONDS 30 how often streams are checked against the database, in case a notification was lost
FLAGSTAFF_KEY_CACHE_SECONDS 10 how long an instance trusts an authenticated key; 0 checks every time
FLAGSTAFF_VERSION_CACHE_SECONDS 5 how long an instance trusts a ruleset version it heard of
FLAGSTAFF_PORT 8080 compose only: where nginx listens on the host

Running tests

Each of backend/, sdk/ and loadtest/ is its own uv project:

cd backend && uv run pytest

The backend's integration tests start Postgres and Redis with testcontainers, so Docker has to be running. ruff check, ruff format --check and mypy (strict) run the same way, and pre-commit runs them on every commit. CI runs each project on every push, the SDK on Python 3.12, 3.13 and 3.14, and a stack job that starts the compose stack and tests it from outside, through a rolling restart of both instances.

The dashboard's checks are npm scripts in frontend/:

cd frontend && npm ci && npm test

npm run lint, npm run typecheck and npm run format:check run the rest. With the stack up and an admin key in FLAGSTAFF_ADMIN_KEY, npm run e2e drives the dashboard in Chromium and checks what it did through the API; the stack job runs that too. The dashboard's README has the details.

Design notes

  • One engine, everywhere. Evaluation lives in the SDK as plain Python with no dependencies, and the server imports the same package for its evaluate endpoints, so what the API says a user gets and what an app serves can't disagree. Users land in rollouts by SHA-256 of flag and user key over 100,000 buckets: the same user gets the same variant in every process, and widening a two-variant rollout only adds users.
  • Everything hangs on a version. Each environment's ruleset has a number that every change moves on, in the same transaction as the change. It is the SSE event id, so a reconnecting SDK sends Last-Event-ID and isn't sent rules it has; it is the ETag for polls; and the SDK ignores anything older than what it holds.
  • Redis only says "environment X is at version N". Each instance loads that version from Postgres once, however many of its streams want it. Pub/sub can drop a message, so instances also compare their streams with the database every 30 s; a lost notification costs at most that.
  • Ending streams on shutdown. uvicorn waits for open connections before it shuts the app down, and an SDK stream never closes by itself, so every deploy waited out the graceful timeout and then cut the streams. flagstaff-server serve ends them as shutdown begins; nginx sends the reconnects to the other instance and never takes either out of rotation.
  • The dashboard is a client like any other. It signs in with an admin key, keeps it in the tab's session storage and calls the public API, through a client typed from the OpenAPI document the server generates. nginx serves it under /dashboard/ on the API's own origin, so there is no CORS to configure and no other service to run.
  • Bounded staleness for speed. An instance trusts a key for 10 s and a ruleset version for 5 s. Revoking a key through one instance stops it there at once; through the other, within 10 s.

Limitations and roadmap

  • The dashboard can't create projects or environments yet; those are still API calls.
  • A revoked SDK key keeps a stream it already has open until that stream reconnects.
  • Admin keys can do everything, and the dashboard signs in with one; there are no user accounts, roles or per-project admin keys.
  • An SDK never accepts an older ruleset, so after restoring the database to an earlier version, running SDKs keep their newer rules until the version passes them again.
  • The SDK is Python only, and has no hook yet for being told when a flag changes.
  • The SDK isn't on PyPI yet, so apps install it from a tag of this repository.
  • Each instance is one process. Throughput grows by running more instances behind nginx; the numbers above are for two.

License

Flagstaff is released under the MIT License. Copyright © 2026 Emil Bogdanov.

About

Feature-flag service: targeting rules and percentage rollouts managed from an API and a React dashboard, pushed live over server-sent events to a Python SDK that evaluates flags locally and keeps working when the server is down.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages