Skip to content

feat(platform): add a PostHog feature-flag backend behind the existing API - #14317

Merged
Bentlybro merged 22 commits into
devfrom
pwuts/feature-flags-posthog-phase1
Sep 23, 2026
Merged

Bentlybro merged 22 commits into
devfrom
pwuts/feature-flags-posthog-phase1

Conversation

@Pwuts

@Pwuts Pwuts commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

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) because variation() 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() returns None when the flag could not be evaluated and False when it conclusively matched no release condition. The frontend twin is useFlagStatus's answered: on PostHog it turns true once posthog-js has answered /flags live 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_BACKEND selects launchdarkly, posthog, or dual; in dual both vendors are evaluated, LaunchDarkly's answer is served, and disagreements are recorded.

Nothing changes for users. The default stays launchdarkly and 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 posthog and dual mode the attributes the ported targeting rules match on (role, email_domain, created_at, and the per-request country the trial offer passes) reach PostHog for flag evaluation only: the frontend sends them with /flags via setPersonPropertiesForFlags and never stores them on the person profile, and the backend passes them per evaluation. posthog.identify is unchanged in every mode, and no raw email goes to flag evaluation.

How

_evaluate_flag_value (backend) and useFlagSource (frontend) became the single dispatch point every read passes through, so each call site keeps its signature. FORCE_FLAG_* and the FORCE_ALL_FLAGS master 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.mismatch logger: 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 to console.warn and to PostHog as a feature_flag_mismatch event. 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 carry expected_until_cohorts_exist, marking the disagreements that exist by construction until phase 2 builds the cohorts. Run dual with POSTHOG_PERSONAL_API_KEY set or every read pays a remote /flags call; 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, and evaluate_flag() returning (value, evaluated); an explicit "off" wins over a payload.
  • backend/util/feature_flag/__init__.py: _evaluate_flag_value is 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.
  • The flag code moves from a flat backend/util/feature_flag.py into the package backend/util/feature_flag/, so the vendor implementations sit beside the dispatcher instead of adding sibling feature_flag_*.py modules. The body stays the package's __init__.py, so backend.util.feature_flag remains one module object: the import path, the backend.util.feature_flag.mismatch logger name and every test that patches a module attribute are unchanged.
  • Person properties for PostHog targeting (role, email_domain, created_at) are derived from the same cached user-context lookup LaunchDarkly uses, so both vendors see one database read. Per-request attributes passed to get_feature_flag_value (the visitor's country, which the trial offer's targeting rule reads) are layered onto that context once, in the dispatcher, so LaunchDarkly, PostHog and both halves of dual evaluate the same facts; PostHog receives country as a person property.
  • initialize_feature_flags() / shutdown_feature_flags() replace the LaunchDarkly-specific lifecycle hooks in rest_api.py and scheduler.py; the readiness gates in the FastAPI dependency and the @feature_flag decorator 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 under backends/. The implementation is picked once at module load, so in launchdarkly mode the PostHog hooks are never called.
  • withFeatureFlag, FeatureFlagPage and FeatureFlagRedirect read through the seam instead of the LaunchDarkly SDK — withFeatureFlag navigates to /404 only on an answer (useFlagStatus(...).answered), never on the 5 s timeout, as on dev — and the onboarding wizard waits on useFlagStatus(...).ready rather than useLDClient().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.
  • dual serves 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 /flags has answered live this page load, so a flag LaunchDarkly has and PostHog has never heard of is reported.
  • useGetFlag / useFlagStatus keep 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 a useFlagStatus entry, because the wizard now reads readiness through the seam instead of the LaunchDarkly client.
  • useGetFlag and useFlagStatus resolve a vendor's answer against the shape the flag declares rather than falling back only on null/undefined: PostHog returns a bare boolean for a flag with no payload, which would otherwise reach a JSON-valued flag's consumer as true and fail at .map.
  • The flag-targeting attributes (email_domain, role, created_at) go to PostHog through setPersonPropertiesForFlags, only when PostHog answers flag reads; posthog.identify sends exactly what it sends on dev.

Configuration

  • backend/.env.default: FEATURE_FLAG_BACKEND (default launchdarkly), POSTHOG_PERSONAL_API_KEY
  • frontend/.env.default: NEXT_PUBLIC_FEATURE_FLAG_BACKEND (default launchdarkly)

Verified

I executed the backend flag suites, the whole backend/util package and the full frontend unit suite locally. feature_flag/feature_flag_test.py passes 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, and FORCE_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 that identify carries 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

  • Phase 1 (this PR) — both implementations, the setting, the dual-run diff, no behaviour change.
  • Phase 2 — re-create the 19 flags carrying non-trivial targeting in PostHog from the LaunchDarkly export (individual users, six segments, email-domain/role/created_at rules; no percentage rollouts to reproduce), point the four "system"-keyed config reads at Remote Config flags, run dual for 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.
  • Phase 3 — switch the default to posthog, then delete LaunchDarkly: the launchdarkly-server-sdk and launchdarkly-react-client-sdk dependencies, the launch_darkly_sdk_key setting, 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-unread dream-web-fact-check-enabled.

Checklist 📋

For code changes:

  • I have clearly listed my changes in the PR description
  • I have made a test plan
  • I have tested my changes according to the test plan:
    • feature_flag_test.py passes unchanged, proving the default evaluation path is untouched
    • Each evaluator answers correctly, including PostHog's native "could not evaluate"
    • Dual mode returns LaunchDarkly's answer and logs a structured record on disagreement
    • FORCE_FLAG_* and FORCE_ALL_FLAGS answer without any vendor, in all three modes, through the evaluator and both route gates
    • Frontend hooks keep their contract on each backend, and the new seam is mockable alone
    • The trial offer's country rule decides the same way in all three modes

For configuration changes:

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

🤖 Generated with Claude Code

Pwuts and others added 2 commits September 3, 2026 21:06
…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
@Pwuts

Pwuts commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

🤖

Summary — no code context needed

Test 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 — .claude/bin/pytest.sh

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.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The 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.

Changes

Feature-flag backend support

Layer / File(s) Summary
Backend selection and evaluation
autogpt_platform/backend/backend/util/feature_flag/*, autogpt_platform/backend/backend/util/settings.py, autogpt_platform/backend/.env.default, autogpt_platform/backend/backend/util/metrics.py
Adds LaunchDarkly, PostHog, and dual backend selection. Adds PostHog evaluation, dual shadow reads, mismatch logging, targeting properties, lifecycle management, overrides, and backend-aware readiness checks.
Backend lifecycle wiring and validation
autogpt_platform/backend/backend/api/rest_api.py, autogpt_platform/backend/backend/executor/*, autogpt_platform/backend/backend/util/feature_flag/feature_flag_test.py
Renames initialization and shutdown paths to use vendor-neutral feature-flag functions. Updates REST API, scheduler, and related tests.
Frontend backend selection and flag source
autogpt_platform/frontend/.env.default, autogpt_platform/frontend/src/services/feature-flags/{flag-backend.ts,flag-source.ts,helpers.ts,use-get-flag.ts}, autogpt_platform/frontend/src/services/feature-flags/backends/*, autogpt_platform/frontend/src/services/feature-flags/feature-flag-provider.tsx
Adds frontend backend selection and shared flag-source results. LaunchDarkly, PostHog, and dual hooks expose values and resolution state.
Frontend consumers and validation
autogpt_platform/frontend/src/services/feature-flags/{FeatureFlagPage.tsx,FeatureFlagRedirect.tsx,with-feature-flag.tsx}, autogpt_platform/frontend/src/app/*, autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx, autogpt_platform/frontend/src/services/feature-flags/__tests__/*
Updates gated pages, onboarding, typed flag usage, PostHog person properties, value-shape handling, and tests to use vendor-neutral readiness and flag status.

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
Loading

Merge Risk: 🟡 Moderate · up to 42ae7

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 146 functions across 32 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding a PostHog feature-flag backend while preserving the existing API.
Description check ✅ Passed The description is directly related to the changeset and provides detailed context, implementation details, testing evidence, and rollout phases.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added cla: pending CLA not yet signed by all contributors platform/frontend AutoGPT Platform - Front end platform/backend AutoGPT Platform - Back end labels Sep 3, 2026
@github-actions github-actions Bot added size/xl cla: signed CLA signed by all contributors and removed cla: pending CLA not yet signed by all contributors labels Sep 3, 2026
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

codecov Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.51685% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.02%. Comparing base (da73bf7) to head (c66a462).

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     
Flag Coverage Δ
platform-backend 88.25% <97.61%> (+0.03%) ⬆️
platform-frontend 65.38% <86.51%> (+0.11%) ⬆️
platform-frontend-e2e 27.61% <37.50%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
Platform Backend 88.27% <97.61%> (+0.03%) ⬆️
Platform Frontend 67.22% <87.09%> (+0.14%) ⬆️
AutoGPT Libs ∅ <ø> (∅)
Classic AutoGPT 28.43% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…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
@Pwuts

Pwuts commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

🤖

Summary — no code context needed

What 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 FEATURE_FLAG_BACKEND off its default, which is what the PR exists to make possible. The default path was already confirmed byte-identical and is unchanged by this round.

Findings and how each was closed

# Fix Verified by
F1 withFeatureFlag, FeatureFlagPage, FeatureFlagRedirect and the onboarding wizard read through useFlagSource instead of the LaunchDarkly SDK. withFeatureFlag now takes a Flag rather than a raw string. New with-feature-flag.test.tsx drives it through the seam with no vendor SDK mocked at all: renders on true, redirects to /404 on false, and waits without redirecting while the answer is outstanding. grep launchdarkly-react-client-sdk src/ now returns exactly two files — the provider and the LaunchDarkly backend.
F2 The frontend dual mismatch fires only once both vendors have resolved. Two tests: "stays quiet while only one vendor has answered" (rerender during the race, zero captures) and "reports once LaunchDarkly catches up and still disagrees" (exactly one capture).
F3 dual shares one user-context lookup and evaluates PostHog off the caller's path, under a bounded backlog. Startup warns when POSTHOG_PERSONAL_API_KEY is missing. The reviewer's own probe, re-run: PROBE1 degraded-path DB lookups per evaluation: launchdarkly=1 dual=1 (was dual=2) and PROBE2 per-evaluation latency with a 400ms PostHog call: launchdarkly=0.1ms dual=0.1ms (was dual=400.9ms). Three tests cover it, including one that resolves a dual read inside 5s while PostHog sleeps 30s, and one that caps the backlog.
F4 dual serves whichever vendor is configured; isPostHogFlagsEnabled gains the cloud check that gates PostHogProvider. "serves PostHog when LaunchDarkly is not configured" asserts {enabled: true, ready: true} where the probe measured {enabled: false, ready: false}; a second test asserts no mismatch is reported when only one vendor is configured; a third asserts posthog mode falls back to defaults outside cloud.
F5 shutdown_posthog_flags clears _init_attempted. "the client can be rebuilt after shutdown" asserts a second client is constructed.
F6 Unknown FEATURE_FLAG_BACKEND falls back to launchdarkly with a warning, case-insensitively. Two tests on Config() directly — Settings.config is a class-level default built once at import, so a test that constructs Settings() cannot see a monkeypatched env var. Manual check: FEATURE_FLAG_BACKEND=posthogg logs the warning and yields LAUNCHDARKLY; PostHog yields POSTHOG.
F7 Kept, with the intent written down in the code and in the PR body: role/email_domain/created_at are what the ported targeting rules match on. Still a no-op in launchdarkly mode. The existing buildFlagPersonProperties tests.
F8 Mismatch records carry expected_until_cohorts_exist, from a 19-key set built from ld-targeting-summary.md (segment membership or individual-user targets; attribute rules are excluded because _person_properties already carries them). "segment-targeted flags are marked as expected" asserts the marker is true for graphiti-memory, and the pre-existing mismatch test asserts false for hire-experts.
F9 sentry_init gates the LaunchDarkly integration on serves_launchdarkly(). Import check; no behaviour change in the default or dual mode.

One defect the probe found that the report did not list

PROBE4 in the reviewer's backend probe showed OFF flag carrying a payload -> evaluate_feature_flag = (False, False). A flag PostHog had conclusively answered "off" for was being read as "could not evaluate", because the payload was preferred over the value and a payload is not a boolean. That is precisely the distinction this evaluator exists to preserve, so a conclusive False now wins over a payload. The probe reads (False, True) after the fix, and a test pins it.

Suites executed

Suite Result
feature_flag_test.py, feature_flag_backends_test.py, feature_flag_posthog_test.py 94 passed (was 86; 8 new)
backend/util whole package 859 passed (was 851)
frontend src/services/feature-flags 51 passed (6 files)
frontend onboarding + settings/memory + feature-flags 324 passed (24 files)
whole frontend unit suite 6110 passed / 6110, no failures
pnpm types clean

backend/util/service_test.py stays excluded: it binds fixed ports and another worktree on this machine holds 8001–8009. It is untouched by this PR.

The onboarding tests needed useFlagStatus added to their existing use-get-flag mock — that is a consequence of the wizard no longer reaching for the LaunchDarkly SDK, not the phase-2 mock migration, which is still deliberately out of scope. The brain-dump test's launchdarkly-react-client-sdk mock became dead and was removed.

…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
@Pwuts

Pwuts commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

🤖

Summary — no code context needed

Two 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 1d57fe96b4.

N1 — a shadow flag read racing teardown resurrected the client

In dual mode the backend evaluates PostHog off the caller's path as a background task. One still in flight at shutdown resumed afterwards, called get_flag_client(), and — because shutdown clears the "did we try" gate so an in-process restart works (F5) — built a fresh PostHog client with a poller thread nothing closes.

shutdown_feature_flags() now cancels the in-flight reads and refuses new ones until the next initialize_feature_flags(), which keeps the restart F5 exists for. That is the same hazard shutdown_launchdarkly documents for its own gate, handled the same way.

I cancelled and gated rather than cancelled and awaited: shutdown_feature_flags is synchronous — rest_api.feature_flag_context and the scheduler both call it from a sync frame — so there is no point at which it can await. Refusing new probes is what actually closes the resurrection, since the rebuild happens in get_flag_client() at the start of a probe that had not started yet.

Re-running the reviewer's probe shape:

PROBE5  in-flight shadow tasks: before shutdown=1 after=0 -> shutdown drains them: True
PROBE10 after a late read: _client=None threads=2 (was 2)

Previously after=1, _client=REBUILT, threads=3.

Three tests pin it: shutdown cancels the in-flight set, a read arriving during teardown never reaches get_flag_client(), and a restart re-enables shadow evaluation.

N2 — the dual docs claimed an invariant the F4 fix retired

dual was sold as "serve LaunchDarkly", and after F4 the frontend serves PostHog where LaunchDarkly is not configured — serving LaunchDarkly's never-arriving answer was the bug. The backend still serves LaunchDarkly unconditionally, so the two sides genuinely differ and the docs now say so rather than implying one rule:

  • frontend/.env.default — "serve LaunchDarkly when it is configured — otherwise PostHog, since LaunchDarkly's answer never arrives without its provider"
  • backend/.env.default — "always serve LaunchDarkly here"
  • the FeatureFlagBackend docstring — states both halves explicitly, and no longer claims dual changes nothing any user sees

Suites executed

Suite Result
feature_flag_test.py, feature_flag_backends_test.py, feature_flag_posthog_test.py 97 passed (was 94; 3 new)
the reviewer's original backend probe, re-run PROBE1 dual=1 lookup, PROBE2 dual=0.1ms, PROBE3 LaunchDarkly's answer preserved, PROBE4 (False, True)

@Pwuts

Pwuts commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

🤖 Evidence and review history

Review round

A 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 /settings/memory and the onboarding wizard in posthog mode; dual reported a mismatch per flag per page load from the vendor load race, waited for the discarded PostHog answer on every backend read, and doubled the degraded user-context lookup; dual with LaunchDarkly disabled served defaults and stalled five seconds; the PostHog client could not be rebuilt after shutdown; an invalid FEATURE_FLAG_BACKEND crashed at boot instead of falling back; sentry_init built a LaunchDarkly client in posthog mode. A probe also caught a defect of its own: a conclusive "off" carrying a payload read as "could not evaluate", which is the one distinction this evaluator exists to preserve.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This pull request has conflicts with the base branch, please resolve those so we can evaluate the pull request.

@github-actions github-actions Bot added the conflicts Automatically applied to PRs with merge conflicts label Sep 9, 2026
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>
@github-actions github-actions Bot removed the conflicts Automatically applied to PRs with merge conflicts label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Conflicts have been resolved! 🎉 A maintainer will review the pull request shortly.

@github-actions github-actions Bot added the conflicts Automatically applied to PRs with merge conflicts label Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has conflicts with the base branch, please resolve those so we can evaluate the pull request.

@autogpt-pr-reviewer

autogpt-pr-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🤖 Review of de63bd3 posted: #14317 (review)

autogpt-pr-reviewer[bot]
autogpt-pr-reviewer Bot previously approved these changes Sep 23, 2026

@autogpt-pr-reviewer autogpt-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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:53 De-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:66 Batch 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:1915 Make 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.

kcze
kcze previously approved these changes Sep 23, 2026
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>
@Pwuts
Pwuts dismissed stale reviews from kcze and autogpt-pr-reviewer[bot] via c66a462 September 23, 2026 20:25
@github-actions github-actions Bot added cla: signed CLA signed by all contributors and removed cla: pending CLA not yet signed by all contributors labels Sep 23, 2026
@Pwuts

Pwuts commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

🤖 Merged dev (#14799) into this branch at c66a462. Evidence below.

Resolution, mutations, suites

Conflict. One conflict, in feature_flag/__init__.py _evaluate_launchdarkly. #14799 layered attributes onto the context inside the LaunchDarkly evaluator, but the dispatcher here never passed them on, so a naive resolution drops country in every mode, LaunchDarkly included. The resolution layers the attributes once in _evaluate_flag_value and hands that one context to all three evaluators. _person_properties now forwards country.

Added-lines proof. The sorted set of lines added against origin/dev after the merge is the pre-merge set, plus the resolution and the new test, and nothing else. The six pre-merge lines no longer present are exactly the ones the resolution replaced.

Country rule, all three modes. TestTheCountryRuleInEveryBackend runs the real LaunchDarkly TestData evaluator and a real locally-evaluating PostHog client. The rule: offered to US and BR, not to IN, and not when no country is sent. Result: 12 passed.

  • Removing the layering: 6 failed (US and BR in every mode).
  • Dropping country from _person_properties: 2 failed (PostHog mode).

Suites, local.

  • backend/util/feature_flag/: 147 passed
  • subscription_trial_{config,capacity,checkout}_test, subscription_trial_routes_test, country_test, scripts/feature_flag_sync_test, architecture_test: 191 passed
  • blocks/test/test_block.py: 1755 passed, 84 skipped
  • pyright over the flag package, settings.py and subscription_trial_config.py: 0 errors

Not run. The TRIAL_TEST_DATABASE-gated integration suites. Also, the trial config tests mock get_feature_flag_value, so the only proof that the country crosses the flag layer is the new test.

@Pwuts

Pwuts commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

/review

@autogpt-pr-reviewer

autogpt-pr-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🤖 Review of c66a462 posted: #14317 (review)

@autogpt-pr-reviewer autogpt-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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:1916 Generalize 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:31 Serialize 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:273 Add 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.

@Bentlybro
Bentlybro added this pull request to the merge queue Sep 23, 2026
Merged via the queue into dev with commit 56a1c09 Sep 23, 2026
60 of 62 checks passed
@Bentlybro
Bentlybro deleted the pwuts/feature-flags-posthog-phase1 branch September 23, 2026 21:59
@github-project-automation github-project-automation Bot moved this to Done in Frontend Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from 👍🏼 Mergeable to ✅ Done in AutoGPT development kanban Sep 23, 2026
Pwuts added a commit that referenced this pull request Sep 23, 2026
…ry-context

Takes #14767 as rebased onto dev after #14317 merged; the tree is
unchanged, only the ancestry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zsanz3 pushed a commit to Zsanz3/AutoGPT that referenced this pull request Sep 23, 2026
…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>
kcze added a commit that referenced this pull request Sep 24, 2026
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>
kcze added a commit that referenced this pull request Sep 24, 2026
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>
Zsanz3 pushed a commit to Zsanz3/AutoGPT that referenced this pull request Sep 24, 2026
…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>
Pwuts added a commit that referenced this pull request Sep 24, 2026
…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)
Pwuts added a commit that referenced this pull request Sep 24, 2026
…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>
Zsanz3 pushed a commit to Zsanz3/AutoGPT that referenced this pull request Oct 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed CLA signed by all contributors platform/backend AutoGPT Platform - Back end platform/frontend AutoGPT Platform - Front end size/xl

Projects

Status: ✅ Done
Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants