Files
Krzysztof CzerwinskiandClaude Opus 5.5 3c06724a3e refactor(platform): remove LaunchDarkly and read every flag from PostHog (#15853)
### Why / What / How

**Why:** LaunchDarkly gets users' email, email domain, role and signup
date, without consent. Dev and prod have served every flag from PostHog
since 16 Oct (infra #437/#439), and #15583 made PostHog the code
default, so LaunchDarkly no longer answers any flag. Removing it closes
gap 3 of the data-flow audit and drops a vendor. The team decided on 23
Sep to remove it entirely, experiments included. Ticket:
[SECRT-2708](https://linear.app/autogpt/issue/SECRT-2708).

**What:** Both LaunchDarkly SDKs go, along with the `launchdarkly` and
`dual` flag backends and the switch between them, on the backend and the
frontend. Flags behave exactly as they do today: the backend asks
PostHog with the same person properties, and the browser reads
`/api/feature-flags` as it already does everywhere.

**How:** The backend's LaunchDarkly `Context` was also the user context
PostHog's person properties were built from. It is now a small dataclass
with the same fields, cache and degraded-lookup rules. The backend sends
the same properties: `role`, `email`, `email_domain`, `created_at`, the
per-request `country`, `environment` and `is_anonymous`.

### Changes 🏗️

**Backend**
- Removed the `launchdarkly-server-sdk` dependency; `poetry.lock`
regenerated with Poetry 2.5.1. The `launchdarkly` extra on `sentry-sdk`
was already gone (#14805).
- `backend/util/feature_flag`:
  - Every read goes to PostHog.
- Removed: LaunchDarkly init/shutdown, `get_client`, the dual shadow
reads and mismatch log, and `mock_flag_variation`.
- Settings: removed `FeatureFlagBackend`, `feature_flag_backend` and
`launch_darkly_sdk_key`.
- Infra #452 (merged 23 Oct) already removed `LAUNCH_DARKLY_SDK_KEY` and
the flag-backend setting from prod. If any environment still sets either
variable, `Settings` allows extra keys, so it is ignored and boot is
unaffected. A test covers this.
- Deleted `scripts/feature_flag_sync.py` and
`scripts/sync_feature_flags_to_posthog.py` with their tests. They only
copied LaunchDarkly flags into PostHog.
- `country` (#14799) already reaches PostHog as the person property
`country`, per read and never stored.
- It stays `country`, not `$geoip_country_code`: server-side reads get
no GeoIP, and the trial-offer flag in PostHog targets `country`.
- An anonymous stand-in context (a failed user lookup) still sends no
country, as before.
- `.env.default`: removed `LAUNCH_DARKLY_SDK_KEY` and
`FEATURE_FLAG_BACKEND`.
- LaunchDarkly wording in comments and docstrings now says PostHog or
feature flag.
- Two messages Otto reads now say "feature flag" instead of
"LaunchDarkly flag".
  - Three OpenAPI descriptions changed; `openapi.json` was regenerated.

**Frontend**
- Removed `launchdarkly-react-client-sdk` (lockfile regenerated),
`LDProvider` and the LaunchDarkly context building, including the device
context from #14288.
- Removed the `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` switch with its
`launchdarkly`, `dual` and client-side `posthog` paths. Every
environment runs `server`, so `useFlagSource` now always reads
`/api/feature-flags`.
- Removed `environment.areFeatureFlagsEnabled` /
`getLaunchDarklyClientId`, and the `setPersonPropertiesForFlags` call,
which only ran in the client-side `posthog`/`dual` modes.
- Retired the `feature_flag_mismatched` event, which only the dual
backend sent.
- Experiments: `useExperiment.ts` and `useReportAssignment.ts` stay on
PostHog (the LaunchDarkly experiment bridge was already deleted). The
browser's assignment `source` is now only `"posthog"`.
- `.env.default`: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`,
`NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID` and
`NEXT_PUBLIC_FEATURE_FLAG_BACKEND`.
- Tests for the LaunchDarkly, dual and client-side PostHog paths are
removed. Tests for the remaining behaviour now mock the flag seam
instead of the LaunchDarkly SDK.

**Docs and image**
- Updated `docs/engineering/feature-flags.md`, `activation-metrics.md`,
`tracking-plan.md`, `docs/platform/contributing/managing-llm-models.md`,
`frontend/CONTRIBUTING.md` and the threat model.
- Single-container image: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED` and
the step that deleted `ldclient`'s bundled test certificates.

**Kept on purpose**
- The backend `AssignmentSource` still accepts `"launchdarkly"`, so
existing `experiment_assignment` rows recorded with it stay valid.
- The stored `routingSource` value `"ld"` is unchanged, because it is a
stored value.

**Config changes**
- Removed env vars: `LAUNCH_DARKLY_SDK_KEY`, `FEATURE_FLAG_BACKEND`
(backend); `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`,
`NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID`, `NEXT_PUBLIC_FEATURE_FLAG_BACKEND`
(frontend).
- Leftover values are ignored. Infra #452 already removed the
LaunchDarkly key and flag-backend setting from prod. Any remaining
`FEATURE_FLAG_BACKEND` or `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` values in
infra or Vercel can be dropped whenever convenient.

**Rollback:** revert this PR and [infra
#452](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/452),
so `LAUNCH_DARKLY_SDK_KEY` and the Vercel LaunchDarkly vars come back.
Then set `FEATURE_FLAG_BACKEND=launchdarkly` (backend) and
`NEXT_PUBLIC_FEATURE_FLAG_BACKEND=launchdarkly` (frontend, needs a
rebuild). LaunchDarkly's flags must still match PostHog's, because
LaunchDarkly hasn't served since 16 Oct. Reverting this PR alone keeps
PostHog serving, since it is already the code default.

### Agents and large language models used

- Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Backend flag unit tests (`backend/util/feature_flag/`,
`api/features/feature_flags/routes_test.py`,
`data/subscription_trial_config_test.py`, `util/metrics_test.py`) pass
locally, including real PostHog clients evaluating the country rule, the
employee cohort and the visitor split in-process
- [x] Frontend `pnpm lint`, `pnpm types` and `pnpm test:unit` pass
(flag, experiment, PostHog provider and affected page tests)
- [x] `openapi.json` regenerated; `poetry.lock` and `pnpm-lock.yaml`
regenerated
  - [ ] CI green
- [ ] After merge and deploy: flags on dev and prod still match today's
(e.g. `/api/feature-flags` for an employee and for a signed-out visitor)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 14:59:19 +00:00

10 KiB
Raw Permalink Blame History

Feature flags

Every flag is read from PostHog. LaunchDarkly was removed in October 2026 (SECRT-2708), after Dev and Prod had served every flag from PostHog since 16 October. Without POSTHOG_API_KEY every flag reads as its code default, which is what a self-hosted install and CI get. Call sites read through evaluate_feature_flag() on the backend and useGetFlag / useFlagStatus on the frontend.

Retired product rollout flags

The following features are permanent product paths. The backend and browser no longer evaluate these keys, and their former FORCE_FLAG_* / NEXT_PUBLIC_FORCE_FLAG_* overrides have no effect:

  • artifacts-page
  • hire-experts
  • skills-hub
  • onboarding-brain-dump
  • onboarding-expert-team
  • graphiti-memory
  • graphiti-communities-enabled
  • expert-spend-approval
  • ai-agent-execution-summary

The sidebar and search flags, autogpt-new-layout and chat-search, were retired separately. Authentication, ownership, user preferences, billing configuration and provider requirements still apply. Spend approval is bypassed when ENABLE_CREDIT=false. Dream passes, skill learning, voice mode, payments and other independent rollout controls retain their existing gates.

Memory still requires FalkorDB and configured embedding/model endpoints. The standard compose and single-container stacks include FalkorDB; custom installs must provide it. Memory operations can fail when these services are unavailable; removing the rollout flags does not make missing services a supported no-op. Community rebuilds and execution summaries can perform model work.

Retired provider definitions can remain during a rolling deployment for older releases. Removing code consumers does not delete the flags' PostHog configuration. Use the Tuesday audit prompt to identify further retirement candidates.

One PostHog project per environment

Dev and Prod each have their own PostHog project, and each project holds its own flags. A deployment reads its flags from the same project its analytics go to, the one POSTHOG_API_KEY names, and evaluates them locally with that project's Feature Flags Secure API Key in POSTHOG_SECRET_KEY. Where local evaluation can't decide a read, the SDK asks PostHog's /flags endpoint instead, unless the read is local-only, as a signed-out visitor's own read is.

A flag the code reads but the project doesn't have is served its code default. The backend still asks /flags about it once, because PostHog answers some flags there that it leaves out of the definitions (one in its newer config format, for instance). Once /flags has had no answer either, the backend serves the default without asking again for five minutes, so the read costs no network round trip. Creating the flag takes effect at the next definitions poll, or within those five minutes for a flag the definitions leave out.

Dev Prod
PostHog project 281733 117338
POSTHOG_API_KEY, POSTHOG_SECRET_KEY (backend) Dev project's Prod project's
NEXT_PUBLIC_POSTHOG_KEY (Vercel) Dev project's Prod project's

PostHog has no environments within a project, so a flag both deployments read is defined in each project. The Projects tab on the flag list and on each flag's page shows a flag across both projects side by side.

A flag is changed in that environment's PostHog project.

The browser. The browser gets its flags from the backend's GET /api/feature-flags, which evaluates them through the same path as the backend's own reads, so the browser and the backend give one answer from one project. A Vercel preview gets the answer of the backend it calls, and a backend without PostHog answers configured: false, so the browser serves its own defaults and stops asking. The set of flags it serves is FrontendFlag in backend/util/feature_flag/frontend.py. Experiments work the same way: useExperiment fetches the user's stored arm from the backend's experiments API, so enrolment follows the backend's PostHog project and secret. NEXT_PUBLIC_POSTHOG_KEY only decides where the browser reports exposures.

What a read sends. A signed-in read sends PostHog the person properties role, email, email_domain and created_at from the auth user row, and a caller that knows the visitor's country (the trial offer) adds country, the ISO code from the signed country token. These ride on the read only and are never stored on the person profile. country is our own property, not PostHog's $geoip_country_code: server-side reads get no GeoIP, so a rule on the country must target country.

The environment property. Every PostHog flag read the backend makes, its own and the browser's, carries the person property environment: the backend's APP_ENV (local, dev or prod), the same value its analytics events carry. A release condition can use it in either project like any other person property. It is only an evaluation input, never stored on a person.

Splitting signed-out visitors. A visitor who allowed analytics is bucketed under visitor:<their PostHog id>, but only by conditions their read can decide: a rollout percentage, environment and is_anonymous, with no email. A condition group on another person property or a cohort (the migrated "email in employees", for one) can't be decided for a visitor, so a visitor whom no decidable group matches gets the shared signed-out answer instead. "Employees OR 20%" is then all or nothing for visitors, whichever side the shared anonymous key lands on. For a real 20% of visitors, add is_anonymous is not true to the rollout group: the shared answer is then off, and only each visitor's own bucket turns the flag on.

Sharing PostHog's flag definitions

PostHog evaluates flags locally, in the process, from a set of flag definitions it polls for every 30 seconds. PostHog bills one definitions fetch as ten flag requests, so a poller in every process makes that bill scale with replica count: at two polls a minute, each process costs 28,800 flag requests a day whether or not it reads a single flag.

So one process is elected to refresh the definitions and write them to Redis, and every other process evaluates from that shared copy. The fleet then costs one process's worth of polling — 28,800 flag requests a day in total, instead of 28,800 per replica.

This is PostHog's own FlagDefinitionCacheProvider interface; the SDK calls it from its polling thread, never from a flag read, so no request can block on Redis.

Settings

Setting Default What it does
POSTHOG_FLAG_DEFINITION_CACHE redis redis shares definitions through Redis; memory keeps them in the process; none restores one poller per process.
POSTHOG_FLAG_DEFINITION_REFRESH_SECONDS 30 How often the refresher fetches, and how often everyone else re-reads the shared copy.
POSTHOG_FLAG_DEFINITION_CACHE_TTL_SECONDS 600 How long the shared copy stays readable after the last refresh.

None of them is read unless POSTHOG_SECRET_KEY is set — without that key the SDK evaluates over the wire and has no definitions to share.

Per environment. Leave the defaults on dev and prod: both run several replicas against a shared Redis, which is what the cache is for. Local development and CI have no PostHog keys, so none of this is reached; set POSTHOG_FLAG_DEFINITION_CACHE=memory if you want to exercise PostHog locally without Redis.

What the refresher is

Every process tries SET <key> <id> NX PX on one Redis key each time it polls. The one that gets it fetches the definitions from PostHog and writes them to Redis; the others read that copy. The winner renews its own lock on every poll, so it keeps the job. The lock expires two poll intervals after its last renewal, and the next process to poll takes it, so a refresher that dies is replaced within 90 seconds at the defaults. One shutting down cleanly releases the lock immediately, and is replaced on the next poll.

Telling it is working

One process logs, once, when it takes the job:

This process now refreshes the shared PostHog flag definitions (<host>:<pid>:<id>)

and the others log Another process now refreshes the shared PostHog flag definitions when they lose it.

The counter autogpt_posthog_flag_definition_cache_events_total on /metrics carries the same story, by outcome:

outcome meaning
refresher / stored this process holds the lock and wrote its definitions to the shared copy, which it does every poll whether or not PostHog reported a change
follower / cached this process read the shared copy
stale the shared copy has not been rewritten for five poll intervals, so no refresher is writing it; it is served anyway
empty nothing in the cache; a process with no definitions yet fetches from PostHog directly, and one that has some keeps them
error Redis was unreachable

Healthy, with N replicas: stored increments once per poll on exactly one process, cached once per poll on each of the other N−1, and empty and error stay flat.

Failure modes

What happens What the platform does
Cache empty at boot The process fetches from PostHog directly, once, and the refresher fills the cache.
Redis unreachable Every process polls PostHog for itself, as it would without the cache, and logs once per five minutes. Flag reads are unaffected.
Shared copy goes stale It is served anyway, with a warning, while the refresher catches up. Stale definitions beat none.
The refresher dies Its lock expires and another process takes the job on its next poll.
A definitions fetch fails The definitions already in memory stay in use, and the refresher keeps writing that copy to the cache.

Nothing here can make a flag read wait on Redis: evaluation reads the definitions already in the process, and only a read they can't decide waits on PostHog's /flags.