Repository navigation
feat(platform): add a PostHog feature-flag backend behind the existing API - #14317
Conversation
…ng API Adds a second implementation of feature-flag evaluation, selected by the new FEATURE_FLAG_BACKEND setting: launchdarkly (default, unchanged), posthog, or dual. Dual evaluates both, returns LaunchDarkly's answer and logs a structured mismatch record, so re-created targeting can be measured before the switch. PostHog reports natively what #14308 had to infer: get_flag() returns None when a flag could not be evaluated and False when it conclusively matched no release condition, so the (enabled, authoritative) contract collapses to one comparison. Payload-valued flags stand in for LaunchDarkly's JSON variations, covering the JSON config flags and the "system"-keyed ones without a call-site change. Person properties come from the same cached user-context lookup LaunchDarkly uses, so both vendors see one database read and cannot drift. FORCE_FLAG_* short-circuits before any vendor and works in every mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScQJDePYEbZ4Vc9pU6G3La
Routes every flag read through useFlagSource, with LaunchDarkly, PostHog and dual implementations selected by NEXT_PUBLIC_FEATURE_FLAG_BACKEND. useGetFlag and useFlagStatus keep their names, signatures and fallback behaviour, so the 68 call sites and the 75 test files that mock the LaunchDarkly SDK are unchanged; launchdarkly stays the default and never calls a PostHog hook. PostHog answers useFlagStatus's "has the vendor replied yet" natively — useFeatureFlagEnabled returns undefined until it has — and payload flags carry the JSON-valued variations. Dual serves LaunchDarkly's value and reports a disagreement to the console and to PostHog as feature_flag_mismatch. posthog.identify sends the flag-targeting attributes only when PostHog answers flag reads, so the analytics call is untouched on the default backend. Mocking flag-source is now the single seam a test needs, which is what makes migrating the 75 LaunchDarkly mocks in phase 2 mechanical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScQJDePYEbZ4Vc9pU6G3La
|
🤖 Summary — no code context neededTest evidence for this PR, which adds a second feature-flag vendor behind the API the code already calls. Every number below is from an execution in this worktree, not a reasoned claim; the two runs that did not go clean are explained rather than omitted. Backend —
|
| Suite | Result | What it proves |
|---|---|---|
backend/util/feature_flag_test.py |
44 passed, file unchanged | the LaunchDarkly evaluation path is untouched |
backend/util/feature_flag_backends_test.py (new) |
28 passed | backend selection, PostHog evaluation, dual-mode mismatch logging, FORCE_FLAG_* in all three modes |
backend/util/feature_flag_posthog_test.py (new) |
14 passed | client construction and lifecycle, the raw read, payload preference, failure paths |
backend/executor/scheduler_test.py -k FeatureFlagLifecycle |
4 passed | the renamed scheduler lifecycle hooks still gate on non-LOCAL |
backend/util (whole package) |
851 passed | nothing else in util regressed |
The "no-op" claim rests on three tests specifically: test_the_default_never_touches_posthog (the PostHog evaluator is never called), test_readiness_asks_only_launchdarkly (nor is it asked about readiness), and test_lifecycle_starts_only_launchdarkly — plus the 44 unchanged tests.
One exclusion: backend/util/service_test.py was excluded from the package run. It binds fixed ports and another worktree on this machine was running poetry run app on 8001–8009 at the time, so it hangs for anyone right now. It is not touched by this PR — the diff contains no backend/util/service.py change.
Frontend — pnpm vitest run
| Suite | Result |
|---|---|
src/services/feature-flags |
42 passed (5 files: 3 pre-existing unchanged, 2 new) |
| whole unit suite | 6102 passed / 6103 |
The single failure is MarketplaceStep.test.tsx > searches marketplace and library workflows, which passes on its own (4 passed) and passed in a second full-suite run — flaky under load, and it touches no flag code.
Locale note for anyone reproducing this: an earlier full run showed 18 failures across 14 files, all thousands-separators and date formatting ('28 apr 2026' vs /Apr 28, 2026/, 1.200 runs vs 1,200 runs). That is this machine's nl_NL locale, not the code. Re-running those files with LANG=en_US.UTF-8 gave 162 passed / 162. CI runs en-US, so it never sees them.
Not executed
The PostHog evaluator is exercised against a stubbed SDK snapshot, not against live PostHog: no PostHog flags exist for these keys yet, which is phase 2's first task. What is verified is the mapping — that get_flag() is None becomes "not authoritative", that a payload stands in for a JSON variation, that person properties carry every attribute the LaunchDarkly rules target on (role, email, email_domain, created_at), and that a degraded user-context lookup makes the answer unauthoritative exactly as it does on LaunchDarkly.
Operational note for the phase-2 diff week
Run dual with POSTHOG_PERSONAL_API_KEY set. Without it the PostHog SDK cannot evaluate locally and every flag read pays a remote /flags call on top of the LaunchDarkly read. The SDK dedupes its $feature_flag_called events per (distinct id, flag, value), so event volume during the diff week is bounded rather than per-read.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe pull request adds configurable LaunchDarkly, PostHog, and dual feature-flag backends. It updates backend lifecycle handling, frontend flag sources, gated consumers, targeting properties, overrides, and related tests. ChangesFeature-flag backend support
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~60 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant FeatureGatedPage
participant useFlagStatus
participant useFlagSource
participant LaunchDarkly
participant PostHog
FeatureGatedPage->>useFlagStatus: request flag status
useFlagStatus->>useFlagSource: read value and resolution
useFlagSource->>LaunchDarkly: read selected flag
useFlagSource->>PostHog: read selected or shadow flag
useFlagSource-->>useFlagStatus: return value and readiness
useFlagStatus-->>FeatureGatedPage: render or redirect
Merge Risk: 🟡 Moderate · up to PostHog flag-read workers can remain active during shutdown and delay service termination. Define executor cancellation, active-read bounds, and reinitialization behavior before merging. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The public docstrings named LaunchDarkly for behaviour that is now the configured backend's. The LaunchDarkly-specific functions keep their names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScQJDePYEbZ4Vc9pU6G3La
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev #14317 +/- ##
==========================================
+ Coverage 83.96% 84.02% +0.05%
==========================================
Files 4091 4099 +8
Lines 308804 309590 +786
Branches 27971 28268 +297
==========================================
+ Hits 259289 260130 +841
+ Misses 44828 44767 -61
- Partials 4687 4693 +6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…ckend Every finding below is reachable only by setting FEATURE_FLAG_BACKEND (or its frontend twin) away from launchdarkly, which is the point of the PR, so they are fixed here rather than during the migration. Frontend: withFeatureFlag, FeatureFlagPage, FeatureFlagRedirect and the onboarding wizard read the LaunchDarkly SDK directly, so in posthog mode — where no LDProvider mounts — /settings/memory spun forever and the wizard never finished waiting. They read through the seam now, and the SDK is imported in exactly two places. Dual reported a mismatch whenever the two vendors answered on different render ticks, which is every flag on every page load; it now compares only once both have resolved, and serves whichever vendor is actually configured instead of stalling 5s on LaunchDarkly's empty flag set. isPostHogFlagsEnabled gains the cloud check that gates PostHogProvider, so the seam cannot claim flags are enabled where no provider mounts. Backend: dual awaited PostHog on every read and discarded the answer, adding up to a 3s remote /flags call to the request path, and walked the deliberately uncached user-context lookup twice. It now shares one lookup and evaluates PostHog off the caller's path under a bounded backlog. A conclusive "off" carrying a payload read as "could not evaluate" — the one distinction this evaluator exists to preserve. The PostHog client could not be rebuilt after shutdown, an invalid FEATURE_FLAG_BACKEND crashed at import rather than falling back, and sentry_init built a LaunchDarkly client in posthog mode. Mismatch records now mark the 19 flags whose LaunchDarkly targeting is segment or individual-user membership, which disagree by construction until phase 2 builds the cohorts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScQJDePYEbZ4Vc9pU6G3La
|
🤖 Summary — no code context neededWhat the blast-radius review of this PR asked for, and what each fix is verified by. The PR adds a second feature-flag vendor (PostHog) behind the API the code already calls; every finding below is reachable only by moving Findings and how each was closed
One defect the probe found that the report did not list
Suites executed
The onboarding tests needed |
…osed A dual-mode shadow evaluation still in flight at teardown resumed after shutdown, called get_flag_client() and — because shutdown clears the "did we try" gate so an in-process restart works — built a fresh PostHog client with a poller thread nothing closes. Shutdown now cancels the in-flight reads and refuses new ones until the next init, which keeps the restart working. This is the hazard shutdown_launchdarkly already documents for its own gate. Also correct the dual docs: the frontend falls back to PostHog where LaunchDarkly is not configured, so "serve LaunchDarkly" is only unconditionally true on the backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScQJDePYEbZ4Vc9pU6G3La
|
🤖 Summary — no code context neededTwo follow-up items from the re-review of this PR, which adds a second feature-flag vendor (PostHog) behind the API the code already calls. Both are reachable only in the non-default modes. Fixed in N1 — a shadow flag read racing teardown resurrected the clientIn
I cancelled and gated rather than cancelled and awaited: Re-running the reviewer's probe shape: Previously Three tests pin it: shutdown cancels the in-flight set, a read arriving during teardown never reaches N2 — the dual docs claimed an invariant the F4 fix retired
Suites executed
|
🤖 Evidence and review historyReview roundA blast-radius review confirmed the default path is byte-identical and found nine issues, all reachable only by flipping the setting. Fixed here: two live components read the LaunchDarkly SDK directly and would have hung |
|
This pull request has conflicts with the base branch, please resolve those so we can evaluate the pull request. |
One conflict, in the onboarding wizard's hook: dev added card-required trial handling (#14353) while this branch replaced the LaunchDarkly client dance with the vendor-independent useFlagStatus seam. Neither side had the other's lines at the merge base, so both are kept — dev's trial-checkout lines are byte-identical, and useLDClient goes because this branch removed its only use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Conflicts have been resolved! 🎉 A maintainer will review the pull request shortly. |
|
This pull request has conflicts with the base branch, please resolve those so we can evaluate the pull request. |
|
🤖 Review of |
There was a problem hiding this comment.
✅ Verdict: Approved — No findings require changes before approval; this review counts as an approval.
Opt-in PostHog feature-flag backend added behind the existing API, off by default, with a byte-identical LaunchDarkly path, preserved fail-closed gates, minimized PII, and strong tests proving the no-op default two ways. CI is green on de63bd3; only low-severity advisories on non-default paths remain.
Risk level: low | Human review: not required | Duration: 488s | Reviewed: de63bd3b
Findings: 🔴 0 blockers | 🟠 1 should fix | 🟡 1 nice to have | 🔵 1 nit
Optional advice — does not block approval
Follow-ups
- 🟠
autogpt_platform/frontend/src/services/feature-flags/backends/dual.ts:53De-duplicate dual-mode mismatch capture — In opt-in dual mode the feature_flag_mismatch capture effect is keyed on the recomputed record JSON, so a stable disagreement can re-emit on re-renders. Harmless on the default path but could inflate event volume during the phase-2 diff week.
Suggestion: Track already-reported records (e.g. a ref keyed on flag+value) so a stable mismatch is captured once.
Optional advice — does not block approval
🟡 Nice to Have
- 🔵
autogpt_platform/backend/backend/util/feature_flag/posthog.py:66Batch flag reads when serving PostHog without local evaluation — Without POSTHOG_PERSONAL_API_KEY each evaluate_flag is a single-flag remote /flags call, so a multi-flag request pays N sequential round-trips. Opt-in, isolated to a dedicated pool, and documented.
Suggestion: Keep local evaluation on (personal key set) for dual mode as instructed; consider request-level batching for any future PostHog-on-request-path mode.
Optional advice — does not block approval
🔵 Nits
- 🔵
autogpt_platform/backend/backend/executor/scheduler.py:1915Make scheduler flag-lifecycle comments vendor-agnostic — Comments at lines 1915 and 2177 name LaunchDarkly, but the paired helpers now initialize/tear down whichever backend is configured. Misleading to future readers.
Suggestion: Rewrite both comments to reference the feature-flag backend generically.
GitHub CI on reviewed head: success
Validation and specialist details
Specialist Reports
| Specialist | Status | Summary |
|---|---|---|
| security | ✅ PASS | A carefully-scoped, well-tested PostHog flag backend behind the existing API; default path is unchanged, PII to PostHog is minimized to non-identifying targeting attributes, secrets are blank in .env.default, and no fail-closed gate is bypassed. |
| architect | ✅ PASS | Clean single-dispatch-point seam on both backend and frontend that preserves the default LaunchDarkly path byte-for-byte, keeps every public signature and test stub working, and is well instrumented for a measurable migration; only minor comment-drift advisories. |
| performance | ✅ PASS | Performance-sound: the default flag path is byte-identical, and all new costs (remote per-flag reads, shadow evaluation) are opt-in, bounded, and isolated to a dedicated worker pool off the request path. |
| testing | ✅ PASS | Comprehensive, high-signal test suite that proves the Phase-1 no-op guarantee two ways and asserts exact evaluator/mismatch semantics; no blocking test defects found. |
| quality | ✅ PASS | High-quality, well-documented feature-flag backend abstraction with comprehensive tests and a byte-identical default path; no concrete code-quality defects found. |
| product | ✅ PASS | PostHog flag backend added behind the existing API as an opt-in, default-preserving no-op with strong test coverage and preserved UX/loading/redirect behavior; no user-facing defects found. |
| discussion | ✅ PASS | CI is green on head de63bd3 and there are no open human-reviewer requests. |
| ui-reviewer (local) | ✅ PASS | PostHog flag backend is fully opt-in behind FEATURE_FLAG_BACKEND; the default launchdarkly path on backend and frontend is preserved and introduces no payment or other UI in local/self-hosted mode. |
| ui-reviewer (hosted) | ✅ PASS | Phase-1 PostHog flag backend added behind an unchanged API; the default launchdarkly path is a verified no-op on both backend and frontend, the PostHog SDK contract matches the installed 7.53.0 API, and the targeted backend suites (20+42 tests) pass. |
Brings in #14799's per-request flag attributes. The request attributes are now layered onto the shared user context once, in _evaluate_flag_value, so LaunchDarkly, PostHog and the dual run all evaluate the visitor's country; PostHog receives it as a `country` person property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c66a462
|
🤖 Merged dev (#14799) into this branch at c66a462. Evidence below. Resolution, mutations, suitesConflict. One conflict, in Added-lines proof. The sorted set of lines added against Country rule, all three modes.
Suites, local.
Not run. The |
|
/review |
|
🤖 Review of |
There was a problem hiding this comment.
✅ Verdict: Approved — No findings require changes before approval; this review counts as an approval.
An opt-in PostHog feature-flag backend behind the existing API, with a byte-identical LaunchDarkly default proven no-op two ways. Security, architecture, performance, tests, and product all approve with no concrete defects; only low-severity advisories on non-default paths remain. GitHub CI is green on head c66a462.
Risk level: low | Human review: not required | Duration: 590s | Reviewed: c66a4620
Findings: 🔴 0 blockers | 🟠 1 should fix | 🟡 2 nice to have | 🔵 0 nits
Optional advice — does not block approval
Follow-ups
- 🟠
autogpt_platform/backend/backend/executor/scheduler.py:1916Generalize stale LaunchDarkly-specific scheduler comments — Comments at 1916 and 2182 reference 'LaunchDarkly'/'LD' initialization and teardown, but the helpers they sit above now initialize/shut down whichever backend is configured. Misleading to a future reader after the migration.
Suggestion: Rewrite both comments to be vendor-neutral (feature flag backend rather than LaunchDarkly).
Optional advice — does not block approval
🟡 Nice to Have
- 🔵
autogpt_platform/frontend/src/services/feature-flags/backends/dual.ts:31Serialize dual-mode mismatch record lazily — record = JSON.stringify({...}) runs on every render of every flag consumer even when the mismatch is not comparable or the vendors agree, discarding the result on most renders.
Suggestion: Compute the string inside the useEffect or guard with comparable && !agree. Dual is a temporary diagnostic mode. - 🔵
autogpt_platform/backend/backend/util/metrics.py:273Add test for serves_launchdarkly() Sentry gate — The gate that stops wiring LaunchDarklyIntegration into Sentry under posthog mode has no test; metrics_test.py never asserts the integration list is empty in posthog mode.
Suggestion: Add a unit test spying sentry_init that patches serves_launchdarkly() to False and asserts no LaunchDarklyIntegration is appended.
GitHub CI on reviewed head: success
Validation and specialist details
Specialist Reports
| Specialist | Status | Summary |
|---|---|---|
| security | ✅ PASS | PostHog feature-flag backend behind an opt-in setting with the default path unchanged; secrets kept blank, user ids pseudonymized in logs, no raw email sent to flag evaluation, and route gates fail closed — no concrete security defect found. |
| architect | ✅ PASS | A cleanly-dispatched, backwards-compatible PostHog flag backend behind the existing API with a byte-identical default path; only minor stale-comment nits. |
| performance | ✅ PASS | Default flag path is unchanged and the opt-in PostHog/dual paths are carefully bounded (off-path shadow eval, 100-task cap, dedicated 8-worker pool, shared cached context lookup); only minor advisory optimizations remain. |
| testing | ✅ PASS | Thorough, faithful test suite covering the default no-op path, each vendor evaluator, dual-mode mismatch logging, shutdown/concurrency races, force-overrides on both gates, and the frontend seam; only a minor advisory gap on the Sentry LaunchDarkly-integration gating. |
| quality | ✅ PASS | High-quality, well-documented, well-tested PostHog flag backend behind the existing API; default path is a verified no-op with no concrete code-quality defects. |
| product | ✅ PASS | PostHog flag backend added behind the existing API; a no-op in the default launchdarkly configuration with prior user-facing behavior preserved and thorough tests, so no blocking product/UX defect. |
| discussion | ✅ PASS | CI is green on head c66a462 with no merge conflict, and there are no open unanswered requests from human reviewers. |
| ui-reviewer (local) | ✅ PASS | PostHog flag backend added behind the existing API as a verified no-op on the default LaunchDarkly path; SDK usage confirmed against posthog 7.53.0 and new tests pass (74), with the only local failures traced to sandbox FORCE_FLAG_* env vars rather than PR code. |
| ui-reviewer (hosted) | ✅ PASS | Default-preserving phase-1 addition of a PostHog feature-flag backend behind the existing API; targeted checks pass, the two local test failures are sandbox .env force-flag leakage (proven correct in a clean env), and no reachable defect is introduced. |
…ble (Significant-Gravitas#14799) ### Why / What / How **Why.** The trial costs us money — every trial can burn up to its `total_cost_limit` in LLM usage plus onboarding credits. We want two controls before opening it wider: 1. **Who gets it.** Don't spend trial money on markets unlikely to afford our prices. India is a large share of our traffic and converts at roughly nothing; offering them a free trial for a product they can't afford is wasted spend and a poor experience. **Nobody excluded should ever see the offer** — not see it and be turned away later. 2. **How many at once.** A ceiling on concurrent trials, which bounds worst-case trial spend at roughly `cap × total_cost_limit`. **What.** - **Country targeting lives in the feature flag**, not in code. On the existing `card-required-trial-offer` flag, a targeting rule decides which countries are offered the trial — in LaunchDarkly today, in PostHog after the move. The backend passes the visitor's country to the flag as a `country` attribute. - **The cap is a new `max_active_trials` field** in the offer JSON, enforced by our database — a flag tool can't count our trials. Both change without a deploy. **How.** *Country.* Choosing an audience is exactly what feature-flag tools are for. They have is / is-not operators, a UI, an audit log, and — in PostHog — conversion analytics alongside. What the tool can't do on its own is know the visitor's country **when our server asks**: - Our backend never sees the user's IP. Every browser call arrives from the Next.js proxy on Vercel, and the backend's Cloudflare sees Vercel's egress IP, not the user's. - LaunchDarkly has no geolocation. - PostHog's server SDKs [disable GeoIP by default](https://posthog.com/docs/libraries/python) because they would geolocate *our server*, so the country has to be [passed in `person_properties`](https://posthog.com/docs/feature-flags/property-overrides). The decision can't move to the browser either. Checkout and the trial spend are server-side, so a browser-only gate is just a UI hint. So **the flag holds the rule and we supply the fact**: 1. The proxy signs Vercel's edge-geolocated `x-vercel-ip-country` into a 60-second frontend service JWT and sends it as `X-Client-Country-Token`. The JWT has scope `client-country` and is signed with the Better Auth JWKS key the backend already trusts, so there's no new secret. The backend reads the country only from a token that verifies. A plain country header, or a token the caller made, means no country. This matters because the browser can call the backend directly with its own bearer token. The header is hidden from the OpenAPI schema. 2. The backend adds it to the flag evaluation as a per-request `country` attribute. It's layered onto a copy of the day-cached LaunchDarkly context, so the cache is never modified. `key` and `kind` can't be overridden this way. 3. Whatever the flag returns decides both views. `GET /credits/trial` returns `eligible: false` with no offer, and `POST /credits/trial` returns a 409, for new **and** resumed checkouts, using the *current* flag — someone the flag now excludes can't finish a checkout they started earlier. *Capacity.* New `subscription_trial_capacity.py` counts seats and hands them out under a global Postgres advisory lock, so two simultaneous enrolments can't both take the last seat. A seat is held by a running trial **or** by a checkout opened in the last 30 minutes. Without counting in-flight checkouts, a burst of simultaneous checkouts would all be admitted. Someone coming back to a checkout after their 30 minutes is re-admitted under the same lock, and their hold restarts, so returners can't all pass on one free seat either. It's enforced **before** the card screen: a full trial hides the offer and refuses checkout. Lowering the cap, or setting it to `0` to pause enrolment, never revokes a seat already held. **What was removed, and why.** An earlier revision of this PR also rejected trials at fulfilment by the card's issuing country. That check could only fire *after* someone had seen the offer and entered a card — the experience we're trying to prevent — and it saved nothing, since a rejected user never started a trial. People who get past the edge with a VPN still need a card, get one trial per card and account (the existing identity claims), and self-select for motivation. Removed. ### Configuring it (LaunchDarkly, production) Today production serves the off variation to everyone (no rules, fallthrough `{"enabled": false}`). To launch with an exclusion list: 1. Add a rule: **`country` is not one of `IN`** → serve the offer variation. 2. Leave the **fallthrough on the off variation**. 3. Optionally add `"max_active_trials": 500` (or your number) to the offer variation's JSON, but only after the release-order steps below. ####⚠️ Release order - **Add `max_active_trials` to the flag only after every backend pod runs this code.** `TrialOffer` is `extra="forbid"`, so an old pod that sees the key fails validation and serves **no offer at all** until it's updated. That fails closed, not open, but it hides the trial. - **Add the country rule only after the frontend and backend are both deployed.** The backend now ignores a plain country header. Until the frontend that signs the country is live, every country is unknown, and the rule withholds the offer from everyone. - **Before reverting this PR, remove `max_active_trials` from the flag first**, or old code rejects the flag again (see the first point). Enrolment rows are safe either way: they don't store the cap. - **Merge this PR before the PostHog stack (Significant-Gravitas#14317 → Significant-Gravitas#14767 → Significant-Gravitas#14805).** They conflict in `util/feature_flag`. Whoever resolves it must pass the per-request `attributes` to both vendors, and add `country` to Significant-Gravitas#14317's `_person_properties` allowlist. Otherwise PostHog never sees the country, and neither does LaunchDarkly: a naive merge drops `attributes=` in the vendor dispatcher. I verified with LaunchDarkly's real SDK evaluator that **a missing attribute never matches a clause, even a negated one**. So this shape also withholds the trial when the country is unknown. The opposite shape ("`country` is one of `IN` → off; fallthrough → offer") would show it to unknown countries. An allowlist ("`country` is one of `US, GB, …` → offer") works too. All three are pure configuration. Worth knowing for the goal: the context already carries `email_domain`, so a rule like *"India **and** a company email domain → trial"* is possible without code. So is serving borderline markets a variation with a smaller `total_cost_limit` — spend less instead of nothing. **PostHog move:** the property is **`country`**, the same name LaunchDarkly uses. `scripts/feature_flag_sync.py` ports LaunchDarkly's `country` rules to a PostHog `country` person property, and Significant-Gravitas#14317 builds `person_properties` from the same LaunchDarkly context, so nothing needs renaming. That PR only has to carry the request attributes through and allowlist `country` (see *Release order*). Don't switch to `$geoip_country_code`: the ported rules wouldn't match it. ### Changes 🏗️ - **`util/feature_flag.py`** — `get_feature_flag_value(..., attributes=)` adds per-request attributes to a copy of the cached context (`Context.from_dict`). - **`subscription_trial_config.py`** — `max_active_trials` on `TrialOffer`. `get_trial_offer(user_id, country=)` sends the country to the flag and treats a blank one as unknown. A country that isn't an assigned ISO 3166-1 alpha-2 code (`backend/util/country.py`) means no offer, without asking the flag. The off variation (`{"enabled": false}` or `null`) is now a deliberate "no offer". **Pre-existing bug fixed:** that variation used to fail validation and log an ERROR on every status request — soon every visitor from an excluded country. - **`subscription_trial_capacity.py`** *(new)* — seat counting, the advisory lock, `trial_seat_available()`. There's one SQL definition of "holding a seat", shared by the count and the holder check. - **`subscription_trial.py`** — `reserve_subscription_trial()` takes the seat atomically (lock → count → insert) in its own short transaction, so the global lock doesn't wait on the caller's Stripe calls. This replaces the previous `UniqueViolationError` retry, because the lock serialises same-user races. - **`subscription_trial_checkout.py`** — one gate with the current flag and current cap, covering new and resumed checkouts. A resumed checkout goes through `renew_trial_seat()`, which locks, checks and restarts the hold in one short transaction before any Stripe call. - **`subscription_trial_routes.py`** — reads the country on both endpoints, only from a verified `X-Client-Country-Token` (`frontend_service_claims` in `autogpt_libs/auth/service.py`). The capacity gate is on status. - **Frontend proxy** — signs `x-vercel-ip-country` into `X-Client-Country-Token` (`lib/auth/country-token.ts`, one token per country reused for 30s) and never forwards a browser-supplied country or token. If signing fails, the request still goes through, with no country. `openapi.json` only gains FastAPI's standard 422 entry on `GET /credits/trial`, because the endpoint now has a (hidden) parameter. **Configuration changes:** none to `.env.default` or `docker-compose.yml`. Local dev and preview behave as today unless *their* flag environment has a country rule, since there's no Vercel header locally. ### What changed since Nick's version Nick's head was e6220ae. Bently took the PR over from there: - **9e719bcc54** — cap `0` keeps a seat that's already held (CodeRabbit's Major). - **32f11f03c9** — the country comes only from a proxy-signed token, and only as an assigned ISO code (kcze's two Should Fixes). - **a544faf147** — the cap is kept out of stored enrolments, and the seat race runs against real Postgres (kcze's test finding, and the revert half of `extra="forbid"`). - **6d1faa48a4** — a returning checkout is re-admitted under the capacity lock (kcze's resume finding). ### Review outcomes | Finding | Outcome | |---|---| | kcze: spoofable country header | **Fixed** in 32f11f0 | | kcze: unvalidated country fails open | **Fixed** in 32f11f0. `XK` is excluded; say if it should be allowed | | kcze: no executed test of the seat race | **Fixed** in a544faf | | kcze: resume admitted without the lock | **Fixed** in 6d1faa4 | | kcze: PostHog property name | **Settled on `country`**; the doc that suggested `$geoip_country_code` is corrected | | kcze: `extra="forbid"` rollout and revert | **Revert fixed** in a544faf (rows don't store the cap). **Rollout** can't be fixed from here (old pods reject the key), so it's in *Release order* | | CodeRabbit (inline and outside-diff Major): cap 0 must keep a held seat | **Fixed** in 9e719bc | | Sentry: advisory-lock cast, live-vs-reserved offer, 30-min hold vs 24h session, cap 0 holder | Already resolved in b5d1c90, f718eb4 and 9e719bc | ### Tester VM evidence (PR merged onto today's dev, against a dev baseline) The VMs have no LaunchDarkly or Stripe keys. A VM-only harness stands in for exactly those two, and is never committed: - LaunchDarkly's real SDK evaluator on `TestData`, carrying the recommended rule. - A small stateful fake Stripe: customers, prices, Checkout Sessions, subscriptions, a stand-in hosted checkout page, and **signed webhooks sent to the real `/api/credits/stripe_webhook`**. The Next.js proxy, Better Auth JWKS signing, backend verification, Postgres and every webhook handler are the real ones. Edge geolocation is simulated by setting `x-vercel-ip-country` on the proxy request. | Case | dev (before) | this PR (after) | |---|---|---| | US / GB via proxy | offer (rule-free flag). With the country rule, **nobody** gets it: dev never sends a country | offer | | IN, unknown, `ZZ`, `"IN, US"`, `XK`, `india` | offer | none | | IN visitor + `X-Client-Country: US`, forged token, own bearer token, direct to backend | offer | none | | POST as IN / spoofed | 200 checkout URL | 409 | | `max_active_trials` in the flag | dev rejects the whole offer (`Invalid card-required-trial-offer`) | cap enforced | | Two users POST at once for the last seat | no cap exists | one 200, one 409 "full" | | Cap full / cap 0 | – | no offer. At cap 0 the checkout holder still sees `eligible: true` | | Two returners with lapsed holds, one free seat | (6d1faa4 before/after) both re-admitted, neither holds a seat | first re-admitted and holds it, second 409 | | Billing UI, US: offer → checkout → back → trial running → paid | works | works identically (screenshots compared) | | Billing and onboarding plan step, IN | trial card shown | no trial card, plan cards unchanged | | With master's Significant-Gravitas#14768 (plan cards embed the trial) applied on top | – | US Pro card shows "7 days free / Start 7-day trial"; IN gets a plain "Get Pro" | **Tests on the VM:** - **Backend:** 513 passed, 0 failed. That's 22 trial/subscription/checkout data suites, the trial routes, feature flag, country and the flag sync, with the real-Postgres trial integration tests enabled. - **New returner race test:** red with the old unlocked check (`[True, True] == [False, True]`), green with the fix. - **Frontend:** 687/689 across the proxy, auth tokens, billing, onboarding, TrialCard and trial services. The 2 failures are pre-existing: `formatShortDate` fails on dev too, and the brain-dump test is load-flaky (17/17 in isolation). - black, isort, ruff and pyright are clean on the touched files. ### Agents and large language models used Claude Code with Claude Opus 5, Claude Fable 5.1 and 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] **Real flag evaluation, end to end** — a test builds the recommended production rule in LaunchDarkly's own SDK (`TestData`) and evaluates through our code: `US`/`BR` → offer, `IN` → none, unknown → none - [x] **Attribute layering** — `country` reaches the evaluated context, `email_domain` is kept, the cached context is unchanged, `key`/`kind` can't be overridden - [x] **Country is the flag's decision** — `IN` / ` in ` → `{"country": "IN"}`; missing, empty or whitespace → no attribute (unknown, not "a non-excluded country") - [x] **Off variation** — `{"enabled": false}` and `null` → no offer, and no ERROR log - [x] **Visibility** — the header reaches the flag from `GET` and `POST`; an excluded visitor gets `eligible: false` and no offer; a pending enrolment is hidden once excluded; an excluded visitor can't resume - [x] **Header hygiene** — the proxy signs the edge value and drops a browser-supplied country or token. The backend ignores a plain `X-Client-Country`, and a forged, HS256, user-audience, other-scope or expired token. The header isn't in the OpenAPI schema - [x] **Capacity** — below/at/over cap; `0` and `null` answered without a query; a held seat survives others filling up; resume is checked against the *current* cap; a full trial is a 409, not a 500 - [x] **SQL against real Postgres** — both seat queries and the advisory lock run against local `platform."SubscriptionTrial"` - [x] **Suites** — backend: 165 trial data-layer, 33 trial API, 73 feature-flag tests; frontend: full `pnpm test:unit`; `format`/`lint`/`types`/pyright clean; all pre-commit hooks pass **Not covered:** the live Vercel → proxy → backend hop, and Stripe's real hosted checkout (the fake above drives the same webhooks). The proxy → backend hop, with a real Better Auth JWKS signature, was checked on a tester VM. After this deploys, add the country rule in a non-production environment, exclude your own country, and confirm the offer disappears — then set the production rule. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes subscription trial eligibility, billing checkout, and feature-flag evaluation for a revenue-sensitive path; incorrect country or cap logic could hide offers or admit excess trials. > > **Overview** > Adds **configurable trial rollout controls** without deploys: **country audience** via the existing `card-required-trial-offer` flag, and **concurrent enrollment cap** via new `max_active_trials` on the offer JSON enforced in Postgres. > > **Country.** The Next.js API proxy forwards Vercel’s `x-vercel-ip-country` as hidden `X-Client-Country` (browser-supplied values are stripped). Trial `GET`/`POST` pass that into `get_trial_offer(..., country=)`, which evaluates the flag with a per-request `country` attribute layered onto the cached LaunchDarkly context (`get_feature_flag_value(..., attributes=)`). Excluded or unknown countries get **no offer** on status and **409** on checkout, including users resuming a pending enrollment when the flag no longer serves them. > > **Capacity.** New `subscription_trial_capacity` counts “seats” (active trials plus `checkout_pending` updated within 30 minutes), serializes reservations with a global advisory lock, and gates status/checkout before Stripe. `reserve_subscription_trial` takes seats in a short transaction; a full trial hides the offer and maps to retryable `TrialUnavailable`. **`max_active_trials` is excluded from the offer token** so cap tweaks do not invalidate in-flight checkouts. LaunchDarkly→PostHog sync gains `country` and safer negated country rules. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e6220ae. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (Claude Code) <noreply@anthropic.com> Co-authored-by: with-persian[bot] <with-persian[bot]@users.noreply.github.com> Co-authored-by: Bentlybro <Github@bentlybro.com>
Conflicts: - posthog-provider.tsx: keep dev's consent-gated provider (#14838) and the PageEvent.PAGEVIEW constant. - brain-dump-analytics.ts: keep the constants-based type; dev only reworded a comment, which moves into posthog-events.ts. New PostHog event from dev (#14317): feature_flag_mismatch, sent by the dual flag backend. Added as FeatureFlagEvent to the constants, the pin test and the tracking plan, and dual.ts now sends it through the constant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts: - posthog-provider.tsx: keep dev's consent gating (#14838) and register the base properties right after init, before following consent. - posthog-events.ts / pin test: keep ExperimentEvent removed; keep the new FeatureFlagEvent from the layer below. Follow-ups for code that arrived from dev: - feature_flag_mismatch (#14317, never on master) is renamed to feature_flag_mismatched and reserved in the pin test; the tracking plan lists it under Renamed. - SubscriptionStep.trial.test.tsx asserts the renamed trial_offer_viewed. - activation-goals.ts comment names the renamed PostHog events; the DataFast run_agent / schedule_agent goals are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…back-merge (Significant-Gravitas#14854) Fixes the seven frontend tests that turned `dev` red after the master back-merge (ead8f94). The failures are in the tests only. The product code on `dev` is correct. ### Why / What / How **Why:** Significant-Gravitas#14838 (Cookiebot consent, merged to `master`) reached `dev` through the back-merge and met the PostHog flag stack (Significant-Gravitas#14317, Significant-Gravitas#14767, Significant-Gravitas#14805) for the first time. Every consent read now calls `environment.getCookiebotCBID()`. The PostHog user-tracker tests and the LaunchDarkly timeout tests mock `@/services/environment` with a partial object that lacks that getter, so all seven died on `TypeError: environment.getCookiebotCBID is not a function` before they reached an assertion. **What:** The environment mocks now provide the getter. The tracker tests grant analytics consent through the Cookiebot test helper that Significant-Gravitas#14838 added (`src/tests/integrations/cookiebot.ts`), because `PostHogUserTracker` now identifies only with consent. A new case pins the merged behaviour: without analytics consent, PostHog receives neither `identify` nor the flag person properties. The LaunchDarkly tests run with no consent manager configured, the same as a self-hosted install. **How:** The change is test-only. Two test files change and no product code does. Behaviour to be aware of: in the merged tree, `setPersonPropertiesForFlags` sits inside Significant-Gravitas#14838's consent gate. So when `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` is `posthog` or `dual`, a signed-in user without analytics consent gets PostHog flags evaluated against a per-page anonymous id, not their user id. With the default `launchdarkly` backend nothing changes. Whether that is the intended behaviour is a separate decision, and this PR does not change it. ### Verified On this branch I ran `vitest` for `src/providers/posthog`, `src/services/feature-flags`, `src/services/consent` and `src/services/analytics` (28 files, 284 passed, `LC_ALL=en_US.UTF-8`), then `pnpm tsc --noEmit` (clean), and eslint and prettier on the two files. I also mutated the product code once per file to check the tests still fail when broken. Dropping the consent gate in `PostHogUserTracker` fails the new no-consent case. Multiplying the LaunchDarkly timeout by 1000 fails "gives the SDK a timeout in seconds". Before the fix, both files failed 7/7 on `dev`'s tip. ### Agents and large language models used - Claude Code — Claude Opus 5.5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…back-merge (#14854) Fixes the seven frontend tests that turned `dev` red after the master back-merge (ead8f94). The failures are in the tests only. The product code on `dev` is correct. ### Why / What / How **Why:** #14838 (Cookiebot consent, merged to `master`) reached `dev` through the back-merge and met the PostHog flag stack (#14317, #14767, #14805) for the first time. Every consent read now calls `environment.getCookiebotCBID()`. The PostHog user-tracker tests and the LaunchDarkly timeout tests mock `@/services/environment` with a partial object that lacks that getter, so all seven died on `TypeError: environment.getCookiebotCBID is not a function` before they reached an assertion. **What:** The environment mocks now provide the getter. The tracker tests grant analytics consent through the Cookiebot test helper that #14838 added (`src/tests/integrations/cookiebot.ts`), because `PostHogUserTracker` now identifies only with consent. A new case pins the merged behaviour: without analytics consent, PostHog receives neither `identify` nor the flag person properties. The LaunchDarkly tests run with no consent manager configured, the same as a self-hosted install. **How:** The change is test-only. Two test files change and no product code does. Behaviour to be aware of: in the merged tree, `setPersonPropertiesForFlags` sits inside #14838's consent gate. So when `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` is `posthog` or `dual`, a signed-in user without analytics consent gets PostHog flags evaluated against a per-page anonymous id, not their user id. With the default `launchdarkly` backend nothing changes. Whether that is the intended behaviour is a separate decision, and this PR does not change it. ### Verified On this branch I ran `vitest` for `src/providers/posthog`, `src/services/feature-flags`, `src/services/consent` and `src/services/analytics` (28 files, 284 passed, `LC_ALL=en_US.UTF-8`), then `pnpm tsc --noEmit` (clean), and eslint and prettier on the two files. I also mutated the product code once per file to check the tests still fail when broken. Dropping the consent gate in `PostHogUserTracker` fails the new no-consent case. Multiplying the LaunchDarkly timeout by 1000 fails "gives the SDK a timeout in seconds". Before the fix, both files failed 7/7 on `dev`'s tip. ### Agents and large language models used - Claude Code — Claude Opus 5.5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 2a2167a)
…ernal-api Resolves three conflicts with #14853, #14878 and #14317: - rest_api.py: take dev's renamed feature-flag import; the exception imports stay out, since the branch moved the handlers to api/utils/exceptions.py. - model.py: dev's usage_chat_session_id stays on CreditTransactionItem alone, now a subclass of the branch's UserCreditTransaction; on the base it would also reach the admin view, which never fills it. - human_review.py: keep the branch's get_reviews-based execution lookup, add dev's legacy chat-id routing and get_pending_reviews_for_chat_session (node ids via the branch's batched enrichment; _resolve_node_id stays gone). Chat reviews now read with graph_exec_id None, which the v2 run-review listing mapped into a required run_id: get_reviews gains graph_runs_only and GET /runs/reviews passes it, with a test over real chat-keyed rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erties (Significant-Gravitas#14835) > [!IMPORTANT] > **Before merging:** this renames live PostHog events. Charts, funnels, cohorts, actions and experiment metrics that use an old name stop getting data from the deploy onwards. > 1. Find every use of the old names in PostHog. > 2. Bridge them with Actions (old OR new name). > 3. Check any running experiment metrics. > > The checklist is in [SECRT-2739](https://linear.app/autogpt/issue/SECRT-2739), and the full old → new list is under "PostHog insights to update" below. Resolves SECRT-2722. Stacked on Significant-Gravitas#14833 (SECRT-2721, the tracking plan and name constants). Review and merge that one first. ### Why / What / How **Why:** On `dev` the same action reaches PostHog several times under different names. One hire could produce up to six records, every chat turn sent two events, and a copilot-started run sent three. Billing, trial and funnel events had no `environment` or `source`. `credit.py` sent through the `posthog` module's globals, which graphiti-core also sets for its own telemetry (SECRT-2710). Copilot events without a user went out under `anonymous_{session_id}`, which creates PostHog people nobody can merge. **What:** This PR does every item the tracking plan marks `merge →`, `remove` or `rename →`. The event names now follow the product analytics plan, *Every Second Counts* (Toran): every live event whose name differed from that plan is renamed once, here (chat events are `chat_*`, the trial lifecycle is `trial_*`, runs are `agent_run_started` / `agent_run_finished`, ...). A few events gain properties so nothing the removed or renamed events measured is lost. Every event now carries the base properties. The constants, the pin tests and `docs/platform/tracking-plan.md` are updated to match. **How:** The backend now has one send path, `backend/util/posthog_client.capture()`. It adds `environment` and `source` after the caller's properties, so a caller can't overwrite them. It drops events that have no user, never raises, and handles the `$insert_id`/uuid dedup key. `product_analytics.track`, `funnel_analytics.emit_funnel_event`, `copilot/tracking.py` and the billing and trial helpers all go through it. The browser registers `source: web` and `environment` as PostHog super properties once after `init`, and again after `reset()` at logout. ### Changes 🏗️ **Merged into the surviving event** - **Expert hire → `expert_hired` (backend).** It is now sent from `experts_db`, not the REST route: once the hire's background setup settles (`_run_hire_setup`, since Significant-Gravitas#14849 made hires return at once), so `failed_preloads_count` is known. That means it covers onboarding, the expert page and copilot hires (copilot hires were missing before), and an idempotent re-hire of an active expert no longer fires it. It gains `failed_preloads_count` and `surface` (`onboarding`, `expert_page`, `copilot`). `surface` comes from a new optional `HireRequest.surface` field, and the copilot passes `"copilot"`. Backend `hire_completed` and browser `hire_flow_completed` / `onboarding_expert_hired` are no longer sent. `hire_failed`, `hire_started` and `hire_flow_abandoned` stay. The Sentry `funnel` breadcrumb now carries `expert_hired`. **The DataFast `hire_completed` goal is untouched (SECRT-2718).** - **`hire_started` on the onboarding hire step** is sent once instead of twice, as `hire_started {template_id, surface: "onboarding"}`. The expert page sends `surface: "expert_page"`. - **Chat turn:** `copilot_message_sent` is no longer sent. `chat_message_sent` (was `run_autopilot` / `run_expert`) carries `message_length`. - **Copilot results become `chat_outcome`:** the analytics plan treats "the copilot ran or scheduled something for the user" as the moment of value in a chat, separate from the run or schedule itself. So `copilot_agent_run_success`, `copilot_agent_scheduled` and `copilot_followup_scheduled` are renamed to `chat_outcome` (with the plan's `outcome_type`) instead of being dropped. `agent_run_started` (`trigger: copilot`) and `schedule_created` still count the run and the schedule. `graph_name` and `schedule_name` are dropped (they are names), and dry runs no longer produce a `chat_outcome`. **Kept gap:** agent schedules created from a chat have no `session_id` on `schedule_created`; `chat_outcome` carries it. - **`expert_run_completed`** is no longer sent. `agent_run_finished` (was `agent_run_completed` / `agent_run_failed`) gains `is_subgraph_run` (from `execution_context.parent_execution_id`), so a top-level expert run is `expert_id` set and `is_subgraph_run = false`. That is the same gate `expert_run_completed` used; `agent_run_*` already skips dry runs. One difference: `expert_run_completed` had a `$insert_id` dedup key and `agent_run_finished` doesn't. I didn't add one because a resumed run can legitimately finish twice. - **Latency events become properties:** `finalize_latency_ms` moves onto `brain_dump_completed` (voice) and `transcription_failed`. `voice_transcribe_latency_ms` becomes `transcribe_latency_ms` on `voice_turn_sent` (and on `voice_turn_dropped` with `reason: filler_or_empty`). `voice_first_sound_latency_ms` becomes `first_sound_latency_ms` on `voice_turn_completed` (null when nothing played). **Consistency** - `data/credit.py` no longer imports `posthog` or sets `posthog.api_key` / `posthog.host` (SECRT-2710). Billing, reconciliation and trial events go through the shared client and now carry `environment` / `source: platform`. - `source` is reserved for the emitter: `hire_started.source` → `surface`, `expert_recommended.source` → `team_source`, `workflow_installed_on_expert.source` → `workflow_source`. - `copilot/tracking.py` no longer falls back to `anonymous_{session_id}`. Events without a user are skipped. **Removed dead code** - The never-sent names `intro_card_dismissed`, `raise_door_clicked`, `intro_start_with_autopilot` and `copilot_trigger_setup`, plus `track_trigger_setup`. - `useLaunchDarklyExperiment` and its tests (no importers). With it goes `experiment_exposed`, and the LaunchDarkly recipe in `activation-metrics.md` is updated to match. - `build/components/legacy-builder/agent-run-draft-view.tsx` (no importers), and `agent-status-chip.tsx`, which only it used. - `services/experts/hire-timing.ts`, which only fed `hire_flow_completed.elapsed_ms`. PostHog's funnel time-to-convert from `hire_started` to `expert_hired` gives the same number. - `POST /api/analytics/log_raw_metric` and `/log_raw_analytics`, their data helpers, tests and snapshots, and the legacy `BackendAPI.logMetric` / `logAnalytic` client methods (no callers). `openapi.json` is regenerated. - **The `AnalyticsDetails` / `AnalyticsMetrics` tables are not touched and there is no migration.** Dropping them (and their data) is a separate decision. **Renamed to the analytics plan's names** (every pair is in the insights table below) - Runs and chat turns: `run_agent` → `agent_run_started`; `run_autopilot` → `chat_message_sent`; `run_expert` splits by the `kind` it already carried: chat turns → `chat_message_sent`, workflow runs → `agent_run_started` (both with `expert_id`); `agent_run_completed` / `agent_run_failed` → `agent_run_finished` with `status` (`completed` / `failed`). - Chat: `copilot_tool_called` → `chat_tool_called`, `copilot_library_check_outcome` → `chat_library_check_outcome`, plus `chat_outcome` above. - Billing: `credit_topup_success` → `topup_completed`, `subscription_upgraded` → `subscription_changed` (with `change_type: upgrade`), `subscription_payment_success` → `payment_succeeded`, `subscription_tier_reconciliation_discrepancy` → `subscription_tier_reconciled`. - Trial: `subscription_trial_{started,ending,canceled,resumed,converted,ended}` → `trial_*`, `subscription_trial_payment_failed` → `payment_failed`, browser `subscription_trial_offer_viewed` → `trial_offer_viewed`. `subscription_trial_checkout_started` keeps its name because Significant-Gravitas#14836 retires it. - The "task" filter is now `event IN ('agent_run_started', 'chat_message_sent') AND via != copilot`. - **Properties renamed to the plan's envelope, in the same release** so filters break once: `session_id` → `chat_session_id` on the chat and schedule events (and `target_session_id` → `target_chat_session_id` on `chat_outcome`), `trigger` / `trigger_ref` → `via` / `via_ref` on `agent_run_started` / `agent_run_finished`, and on `chat_message_sent` the channel becomes the plan's `origin` (`web`, or the bot platform; it was `surface`), while the old `origin`, which was always `interactive` because automation turns aren't sent, is dropped. - Deliberately not changed, and listed in the plan's "Differences from the analytics plan" section: the brain-dump and voice events that the analytics plan folds into `onboarding_step_*` / `chat_message_sent` properties, and `briefing_opened`, which is the briefing shown on home, not the plan's `briefing_opened_in_chat`. **Constants and docs:** the removed and renamed names are gone from `posthog_events.py` / `posthog-events.ts`. Both pin tests now also list them as retired and fail if one comes back. `tracking-plan.md` moves them to new **Removed** and **Renamed** sections with their replacements; the `merge →` / `remove` statuses are gone from the legend, and the base-property and identity sections describe the new behaviour. I kept the edits local so they merge cleanly with SECRT-2723. ### PostHog insights to update Every name below stops arriving once this PR ships. Point insights, funnels and cohorts at the replacement. The right-hand column is the filter that keeps the old meaning. PostHog can't backfill, so a chart that spans the release needs both names (old before, new after). **Renamed to the analytics plan's names** | Old event | New event | Filter to keep the old meaning | | --- | --- | --- | | `run_agent` | `agent_run_started` | `expert_id` is not set | | `run_expert` (`kind = workflow_run`) | `agent_run_started` | `expert_id` is set (`kind = workflow_run`) | | `run_autopilot` | `chat_message_sent` | `expert_id` is not set | | `run_expert` (`kind = chat_turn`) | `chat_message_sent` | `expert_id` is set | | `agent_run_completed` | `agent_run_finished` | `status = completed` | | `agent_run_failed` | `agent_run_finished` | `status = failed` | | `copilot_tool_called` | `chat_tool_called` | — | | `copilot_library_check_outcome` | `chat_library_check_outcome` | — | | `copilot_agent_run_success` | `chat_outcome` | `outcome_type = agent_run_success`. Dry runs no longer count; `graph_name` is gone. | | `copilot_agent_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = agent`. `graph_name` / `schedule_name` are gone. | | `copilot_followup_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = followup`. `session_id` is now the chat that scheduled it; the destination is `target_session_id`. | | `credit_topup_success` | `topup_completed` | — | | `subscription_upgraded` | `subscription_changed` | `change_type = upgrade` (the only value today) | | `subscription_payment_success` | `payment_succeeded` | — | | `subscription_tier_reconciliation_discrepancy` | `subscription_tier_reconciled` | — | | `subscription_trial_started` | `trial_started` | — | | `subscription_trial_ending` | `trial_ending` | — | | `subscription_trial_canceled` | `trial_canceled` | — | | `subscription_trial_resumed` | `trial_resumed` | — | | `subscription_trial_converted` | `trial_converted` | — | | `subscription_trial_ended` | `trial_ended` | — | | `subscription_trial_payment_failed` | `payment_failed` | — (only trial conversion charges send it today) | | `subscription_trial_offer_viewed` (browser) | `trial_offer_viewed` | — | | `feature_flag_mismatch` (browser, from Significant-Gravitas#14317) | `feature_flag_mismatched` | — (added on `dev` by Significant-Gravitas#14317 and renamed here before it reached production) | The "task" insight becomes `agent_run_started` + `chat_message_sent` where `via` is not `copilot`. **Renamed properties** (PostHog's audit on 28 Sep found no insight filtering or breaking down on any of them) | Old property | New property | On | | --- | --- | --- | | `session_id` | `chat_session_id` | `chat_message_sent`, `chat_tool_called`, `chat_outcome`, `chat_library_check_outcome`, `schedule_created`, `schedule_fired` | | `target_session_id` | `target_chat_session_id` | `chat_outcome` (`target = followup`) | | `trigger` | `via` | `agent_run_started`, `agent_run_finished` | | `trigger_ref` | `via_ref` | `agent_run_started` | | `surface` (`chat` or bot platform) | `origin` (`web` or bot platform) | `chat_message_sent` | | `origin` (`interactive`) | removed | `chat_message_sent` | **No longer sent** | Event no longer sent | Replace with | | --- | --- | | `hire_completed` (PostHog, backend) | `expert_hired`. It now also skips idempotent re-hires. The DataFast `hire_completed` goal is unchanged. | | `hire_flow_completed` | `expert_hired` where `surface = expert_page`. For `elapsed_ms`, use funnel time-to-convert from `hire_started`. `voice_picked` is gone. | | `onboarding_expert_hired` | `expert_hired` where `surface = onboarding`. `position` is on `expert_recommendation_clicked`. | | `copilot_message_sent` | `chat_message_sent`. `message_length` is on it. | | `expert_run_completed` | `agent_run_finished` where `expert_id` is set and `is_subgraph_run = false`. Its `status` (`completed` / `failed`) matches the old property. | | `finalize_latency_ms` (event) | The `finalize_latency_ms` property on `brain_dump_completed` / `transcription_failed`. | | `voice_transcribe_latency_ms` | The `transcribe_latency_ms` property on `voice_turn_sent`. | | `voice_first_sound_latency_ms` | The `first_sound_latency_ms` property on `voice_turn_completed`. | | `experiment_exposed` | Nothing. It was never sent (the hook had no caller). | | `copilot_trigger_setup`, `intro_card_dismissed`, `raise_door_clicked`, `intro_start_with_autopilot` | Nothing. They were never sent. | Property changes on live events, which filters also need: - `hire_started`: `source = onboarding_hire_step` → `surface = onboarding`. The expert page now sends `surface = expert_page`. - `expert_recommended`: `source` → `team_source`. - `workflow_installed_on_expert`: `source` → `workflow_source`. - New properties that carry the old event names' meaning: `status` on `agent_run_finished`, `change_type` on `subscription_changed`, `outcome_type` and `target` on `chat_outcome`. - `source` now means the emitter on every event (`platform`, `chat_copilot`, `web`). Every event also carries `environment`, and on browser events a Vercel preview reports `environment = preview`. - `expert_hired` counts go up: copilot hires are now included. **Follow-ups, not in this PR.** The analytics plan also folds several events into one. This pass only renames: - Brain-dump events (`brain_dump_*`, `transcription_failed`, `later_dump_completed`) and the wizard's `intro_path` / `hire_step_continued` become properties of `onboarding_step_viewed` / `_completed` / `_skipped` / `_back`. - Spoken turns (`voice_turn_sent`) become `chat_message_sent` with `input_mode: voice`. - `subscription_changed` also covering downgrades and cancellations (today `subscription_cancellation_scheduled` / `subscription_ended` are separate). - `chat_outcome` emitters for `agent_created`, `trigger_setup`, `artifact_created`, `file_produced`, `answer_only`. ### 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 - [ ] I have tested my changes according to the test plan: - [x] Backend `poetry run format` / `poetry run lint` pass. Ruff is clean; the only 3 pyright errors are the existing unresolved `playwright` imports in `test/fixtures/skills/webapp-testing`. - [x] Backend unit tests that don't need a database, run locally with `--noconftest`, all pass: `posthog_client_test`, `posthog_events_test`, `product_analytics_test`, `funnel_analytics_test`, new `copilot/tracking_test`, the PostHog tests in `credit_subscription_test` (15), `notifications/trial_test`, `copilot/tools/expert_change_test`, `copilot/briefing/generate_test`, `executor/schedule_events_test`, `copilot/turn_queue_test`, and the touched `run_agent_test` cases. - [ ] Left to CI because they need Postgres/Redis: `experts_db_test` (hire / `expert_hired` / `workflow_source` assertions), `experts/routes_test` (the new `surface` pass-through and 422 tests), `test_session_dry_run`, `orgs/regression_test`, `spend_approval_routes_test`. - [x] Analytics-plan rename commit: backend `poetry run format` is clean (only the existing `playwright` pyright errors). These pass locally: `posthog_events_test`, `posthog_client_test`, `product_analytics_test`, `funnel_analytics_test`, `copilot/tracking_test` (new `chat_outcome` test), `notifications/trial_test`, `schedule_followup_test`, `run_agent_schedule_name_test`, `run_agent_timezone_test`, and the 5 renamed billing assertions in `credit_subscription_test`. Frontend `pnpm format` / `lint` / `types` are clean, and Vitest over analytics, billing, experts and home (38 files, 302 tests) passes. `run_agent_test` / `test_session_dry_run` need the DB fixtures and are left to CI. - [x] Frontend `pnpm format`, `pnpm lint` and `pnpm types` are clean. The full `pnpm test:unit` run passes (746 files, 7951 tests), including updated HireStep, useHireFlow, expert page, brain-dump, voice-mode, auth-identity and pin tests and the new base-properties test. - [ ] After deploy to dev, check in PostHog live events: one `expert_hired` per hire with `surface`, a single `hire_started` per onboarding click, `message_length` on `run_autopilot`, `environment` / `source` on billing and browser events, and no events from the list above. #### 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**). None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <noreply@anthropic.com>
Why
Feature-flag evaluation is locked to LaunchDarkly, while PostHog is already installed, configured and running in production on both backend and frontend — its feature flags serve the onboarding pricing experiment today. The platform carries two flag systems that know nothing about each other, and nothing can move between them until the call sites stop naming a vendor.
LaunchDarkly also fits the evaluation contract badly. #14308 added
evaluate_feature_flag() -> (enabled, authoritative)becausevariation()returns your default on failure and gives the caller no way to tell that apart from a real "off" — which matters when "off" deletes a user's schedule. That PR reconstructs the distinction from three signals. PostHog reports it natively:get_flag()returnsNonewhen the flag could not be evaluated andFalsewhen it conclusively matched no release condition. The frontend twin isuseFlagStatus'sanswered: on PostHog it turns true once posthog-js has answered/flagslive this page load, not off a snapshot persisted from an earlier visit.What
A deployment can now answer every flag read from PostHog instead of LaunchDarkly, or from both at once, without a single call site changing.
FEATURE_FLAG_BACKEND/NEXT_PUBLIC_FEATURE_FLAG_BACKENDselectslaunchdarkly,posthog, ordual; indualboth vendors are evaluated, LaunchDarkly's answer is served, and disagreements are recorded.Nothing changes for users. The default stays
launchdarklyand that path is byte-identical to today: no flag is deleted, no LaunchDarkly dependency is removed, and no PostHog flag has to exist yet. This is the code side of the LaunchDarkly → PostHog migration; the flags themselves move in phase 2.In
posthoganddualmode the attributes the ported targeting rules match on (role,email_domain,created_at, and the per-requestcountrythe trial offer passes) reach PostHog for flag evaluation only: the frontend sends them with/flagsviasetPersonPropertiesForFlagsand never stores them on the person profile, and the backend passes them per evaluation.posthog.identifyis unchanged in every mode, and no raw email goes to flag evaluation.How
_evaluate_flag_value(backend) anduseFlagSource(frontend) became the single dispatch point every read passes through, so each call site keeps its signature.FORCE_FLAG_*and theFORCE_ALL_FLAGSmaster switch short-circuit before any vendor, on the evaluator and on both route gates, so they work in all three modes and local dev, CI and Playwright are unaffected throughout.A PostHog flag's payload stands in for LaunchDarkly's JSON variation value, which is why the payload-valued flags and the four
"system"-keyed config reads need no call-site change.Dual mode is what makes the port measurable rather than hopeful. A disagreement — in the value or in whether the answer was authoritative — is logged as one JSON record on the
backend.util.feature_flag.mismatchlogger: flag key, truncated SHA-256 of the user id, both values, both authoritativeness bits. A flag PostHog could not answer at all, which is every flag until phase 2 creates them, is logged once per flag per process rather than on every read. The frontend reports the same shape toconsole.warnand to PostHog as afeature_flag_mismatchevent. PostHog is evaluated off the caller's path, so nothing waits for it, and both vendors share one user-context lookup so they cannot drift. Records for the 19 flags whose LaunchDarkly targeting is segment or individual-user membership carryexpected_until_cohorts_exist, marking the disagreements that exist by construction until phase 2 builds the cohorts. RundualwithPOSTHOG_PERSONAL_API_KEYset or every read pays a remote/flagscall; the backend warns at startup when it is missing. PostHog reads run on a dedicated 8-worker pool rather than the process-wide default executor, so a burst of slow reads queues there instead of starving every other blocking call.Changes 🏗️
Backend
backend/util/feature_flag/posthog.py(new): PostHog flag client with local evaluation, built once under a lock, andevaluate_flag()returning(value, evaluated); an explicit "off" wins over a payload.backend/util/feature_flag/__init__.py:_evaluate_flag_valueis now a dispatcher over_evaluate_launchdarkly/_evaluate_posthog/_evaluate_dual. Public API (Flag,is_feature_enabled,evaluate_feature_flag,get_feature_flag_value,feature_flag,create_feature_flag_dependency) is unchanged.backend/util/feature_flag.pyinto the packagebackend/util/feature_flag/, so the vendor implementations sit beside the dispatcher instead of adding siblingfeature_flag_*.pymodules. The body stays the package's__init__.py, sobackend.util.feature_flagremains one module object: the import path, thebackend.util.feature_flag.mismatchlogger name and every test that patches a module attribute are unchanged.role,email_domain,created_at) are derived from the same cached user-context lookup LaunchDarkly uses, so both vendors see one database read. Per-requestattributespassed toget_feature_flag_value(the visitor'scountry, which the trial offer's targeting rule reads) are layered onto that context once, in the dispatcher, so LaunchDarkly, PostHog and both halves ofdualevaluate the same facts; PostHog receivescountryas a person property.initialize_feature_flags()/shutdown_feature_flags()replace the LaunchDarkly-specific lifecycle hooks inrest_api.pyandscheduler.py; the readiness gates in the FastAPI dependency and the@feature_flagdecorator now ask the configured backend.Frontend
services/feature-flags/flag-source.ts(new): one seam every flag read passes through,useFlagSource(key) -> {value, resolved}, with LaunchDarkly, PostHog and dual implementations underbackends/. The implementation is picked once at module load, so inlaunchdarklymode the PostHog hooks are never called.withFeatureFlag,FeatureFlagPageandFeatureFlagRedirectread through the seam instead of the LaunchDarkly SDK —withFeatureFlagnavigates to/404only on an answer (useFlagStatus(...).answered), never on the 5 s timeout, as on dev — and the onboarding wizard waits onuseFlagStatus(...).readyrather thanuseLDClient().waitForInitialization(). The SDK is now imported by exactly two source files inside the feature-flag service, the provider and the LaunchDarkly backend, and by one outside it:services/experiments/useLaunchDarklyExperiment.ts, which dev added in feat(platform): record A/B experiment arms per user #14287 for the A/B experiment arms and which is not a flag read.dualserves whichever vendor is configured and reports a disagreement only once both have answered — the two never resolve on the same render, so comparing earlier reports the load order rather than a divergence. PostHog counts as answered once/flagshas answered live this page load, so a flag LaunchDarkly has and PostHog has never heard of is reported.useGetFlag/useFlagStatuskeep their names, signatures and fallback behaviour, so every existing call site and every test file that stubs the flag module keeps working as it is. Two onboarding test files gain auseFlagStatusentry, because the wizard now reads readiness through the seam instead of the LaunchDarkly client.useGetFlaganduseFlagStatusresolve a vendor's answer against the shape the flag declares rather than falling back only onnull/undefined: PostHog returns a bare boolean for a flag with no payload, which would otherwise reach a JSON-valued flag's consumer astrueand fail at.map.email_domain,role,created_at) go to PostHog throughsetPersonPropertiesForFlags, only when PostHog answers flag reads;posthog.identifysends exactly what it sends on dev.Configuration
backend/.env.default:FEATURE_FLAG_BACKEND(defaultlaunchdarkly),POSTHOG_PERSONAL_API_KEYfrontend/.env.default:NEXT_PUBLIC_FEATURE_FLAG_BACKEND(defaultlaunchdarkly)Verified
I executed the backend flag suites, the whole
backend/utilpackage and the full frontend unit suite locally.feature_flag/feature_flag_test.pypasses with every assertion unchanged (73 tests) — its only edit is where its source scanner computes the backend root, which the package move forced — and that, plus explicit tests asserting the default mode never calls the PostHog evaluator and never asks it about readiness, is what proves this is a no-op while the default stands. New tests cover each evaluator, the payload mapping for JSON-valued flags, person-property passthrough for exactly the attributes the ported rules target on, dual-mode mismatch logging (including the authoritative-vs-not case, an unserializable value, and the once-per-flag record while PostHog cannot answer), single construction of the flag client under concurrent first reads, andFORCE_FLAG_*in all three modes. The trial's country rule (offered everywhere but India, and not to a visitor with no country) is tested through the real LaunchDarkly evaluator and a real locally-evaluating PostHog client in each of the three modes; dropping the attribute layering turns six of its twelve cases red. On the frontend, new tests cover the three backends through the real hooks, prove the seam is mockable on its own, pin that a timeout never redirects and thatidentifycarries no flag properties, and pin the shape check for array, object and primitive flags — I confirmed that last one can fail by reverting the resolver to the nullish fallback, which turns five of its nine cases red. Counts, the one environmental exclusion, and the nine setting-flip defects found and fixed before merge are in the evidence comment below.Not tested end-to-end against live PostHog: no PostHog flags exist yet, which is phase 2's first task. The PostHog evaluator is exercised against a stubbed SDK snapshot, not the wire.
Phase plan
created_atrules; no percentage rollouts to reproduce), point the four"system"-keyed config reads at Remote Config flags, rundualfor a week and drive the mismatch log to zero. Migrate the 94 frontend test files that mock the flag module onto the seam this PR adds.posthog, then delete LaunchDarkly: thelaunchdarkly-server-sdkandlaunchdarkly-react-client-sdkdependencies, thelaunch_darkly_sdk_keysetting, three env vars, the lifecycle hooks, and the flag keys no code reads any more — on this head five LaunchDarkly flags (agent-briefing,api-keys-feature,autopilot-context-trimming,builder-chat-panel,chat-context-panel) and the backend's declared-but-unreaddream-web-fact-check-enabled.Checklist 📋
For code changes:
feature_flag_test.pypasses unchanged, proving the default evaluation path is untouchedFORCE_FLAG_*andFORCE_ALL_FLAGSanswer without any vendor, in all three modes, through the evaluator and both route gatescountryrule decides the same way in all three modesFor configuration changes:
.env.defaultis updated or already compatible with my changesdocker-compose.ymlis updated or already compatible with my changes🤖 Generated with Claude Code