Repository navigation
docs(flags): specify SDK-sent $experiment_exposure for config version 2 flags - #162
Merged
Merged
Conversation
… 2 flags Add the OpenSpec change add-experiment-exposure. A read that emits $feature_flag_called also emits $experiment_exposure when the record that supplied its value is a complete config version 2 experiment split. It adds rule attribution to $feature_flag_called, extends its dedupe key for config version 2, and gives the exposure its own dedupe namespace.
…ag-called key The config version 2 $feature_flag_called dedupe key now carries the whole attribution the event sends: config version, reason code, rule ID, rule type and variant key. Two variant-less outcomes of one rule with the same value, such as a holdout and then a paused experiment, no longer collapse. The exposure key is unchanged. Name posthog-python#1154 as the first implementation.
This was referenced Oct 30, 2026
…, skip wrong-type reads - A typed read of the wrong type sends neither $feature_flag_called nor $experiment_exposure and touches neither dedupe namespace. A read that returned its default because the value is null or false still reports the assignment. - $experiment_exposure carries $feature_flag, $feature_flag_response, $feature_flag_variant, $feature_flag_rule_id and locally_evaluated, plus the flag id, version, request id, evaluated time and groups. It drops the config version, reason code, rule type and source marker, which are constants for an experiment split; the rule id marks an SDK exposure. - Config version 2 $feature_flag_called events drop $feature_flag_reason; $feature_flag_reason_code replaces it. Config version 1 events keep it. - Replace the open question on the exposure's response, and mark task 2.4 done now that add-typed-flag-values carries the dedupe rewording. Add a task for the harness exposure projection.
andehen
added a commit
to PostHog/posthog-sdk-test-harness
that referenced
this pull request
Oct 30, 2026
Follow PostHog/sdk-specs#162. An SDK sends $experiment_exposure only for a config version 2 experiment split, so the config version, rule type, reason code and source marker were constants on it. Require exactly $feature_flag, $feature_flag_response, $feature_flag_variant, $feature_flag_rule_id and locally_evaluated, and drop the other four properties so the closed schema rejects them. The rule ID identifies the experiment rule and marks the exposure as SDK-sent: ingestion no longer copies a $feature_flag_called that carries config version 2 (PostHog/posthog#125449), and a copy never carries a rule ID. A config version 2 $feature_flag_called carries $feature_flag_reason_code in place of $feature_flag_reason. Declare $feature_flag_reason in the called context and reject it in the version 2 branch; version 1 calls keep it with any value. The event schemas have no fixture sets, so meta-tests build the projections: each exposure property missing, each forbidden exposure property present, and $feature_flag_reason on every presence-matrix row's version 2 call. Contract package 8.0.0.
Contract 8.0.0 trims the exposure projection and rejects $feature_flag_reason on config version 2 calls, so task 2.5 is done.
- Send the rule ID and rule type on $feature_flag_called as a pair or not at all, and only with a rule type the reason code allows; cover a targeting match that names an experiment rule. - Scope the wider minimal-event allowlist to config version 2 events, with a scenario for a minimized config version 1 event. - Say the exposure dedupe key uses the distinct ID as the string the event carries. - State which retained snapshot details the exposure needs, and point to the existing evaluation-metadata retention for the rest. - Align design decisions 2 and 14 and the proposal with the spec, add the reason for forbidding $feature_flag_forced_variant, name the /flags API version the minimal-event gate comes from, and fix the harness version in task 1.1.
andehen
added a commit
to PostHog/posthog-python
that referenced
this pull request
Nov 4, 2026
Follow PostHog/sdk-specs#162. On a config version 2 $feature_flag_called, $feature_flag_rule_id and $feature_flag_rule_type are sent together, and only with a rule type the reason code allows: targeted_release or percentage_rollout for targeting_match, percentage_rollout or experiment for rollout_miss, and experiment for experiment_paused, holdout and experiment_split. A record with no rule ID, no rule type or another rule type, including one this SDK does not know, sends neither. The event keeps the config version and reason code, and the value is unchanged. A targeting match that names an experiment rule gets no attribution and no exposure; the exposure already required a complete experiment split. Contract 8.0.0 rejects these calls without their rule pair, so the new cases are not validated against the vendored schema until a harness release that accepts them is copied.
andehen
marked this pull request as ready for review
November 4, 2026 08:54
…its to readable records - A tracked read checks the exposure key even when the flag-called tracker suppresses a repeat, and the reverse. A shared cache keeps the two namespaces distinct; the atomic check-and-mark alternative is gone, since it contradicted the separate exposure namespace. - A malformed split is a readable config version 2 record. A known field of the wrong JSON type is a per-record parse error, as the wire reader contract says. - Building the events makes no flags or definitions request; delivery follows the normal capture pipeline, which may send immediately. - The happy-path scenario omits `failed`, which producers send only as true.
…e IDs, payload reads and reloads - A config version 2 record with a missing or unknown reason code keeps config version 2 and the raw code, if any, with no rule attribution and no exposure. Contract 9.0.0 (harness #101) accepts these events. - Each event is attempted independently, and its dedupe key is added only after capture accepts it. A failed or dropped capture adds no key. Config version 1 flag-called events keep the tracker's existing rules. - A rule ID that is not a hyphenated UUID string counts as missing: no exposure, no rule pair. - Payload reads stay silent by default. An opted-in payload read that evaluates the flag follows the trigger; a caller-supplied match value never sends an exposure or reserves its key. - A flags reload never clears the exposure namespace.
andehen
added a commit
to PostHog/posthog-python
that referenced
this pull request
Nov 4, 2026
Follow the updated PostHog/sdk-specs#162. - A config version 2 $feature_flag_called and every $experiment_exposure keep their dedupe key only when capture() returns an event UUID without raising. The key is reserved while capture runs, so concurrent reads do not send the event twice, and released when capture drops the event (before_send, opt-out, a full or closed queue) or raises. A raising capture is logged, the read returns its value, and the other event is still attempted. Config version 1 calls keep their existing dedupe. - A rule ID counts as present only in the hyphenated 8-4-4-4-12 UUID form. Any other string drops the rule pair and sends no exposure. - A variant key is present when it is a string, so an empty variant key completes a split and sends the exposure. - Super properties are merged before the attribution of both config version 2 events is set again from the record. A super property can no longer change the rule ID or variant, or add the reason description, a forced variant, an experiment ID, or exposure-forbidden properties. Config version 1 events still take super properties as before. Tests cover unknown and missing reason codes, non-UUID rule IDs, empty variant keys, a dropped and a raising capture of each event followed by a second read, unchanged config version 1 dedupe, a flags reload, and super properties on both clients. The scenario and parity mocks of _enqueue now return an event UUID, as the real queue does.
Capture handles an event when it accepts it or the application's before-send hook discards it, and only then is the key added. A throw, a full or closed queue, opt-out or a disabled SDK still add no key. The discard is the application's choice, a later read would usually be discarded again, and async SDKs run the hook after capture returns. The queue-rejection scenario replaces the before-send one for a dropped flag-called event, and a new scenario shows a discarded exposure keeping its key.
andehen
added a commit
to PostHog/posthog-python
that referenced
this pull request
Nov 4, 2026
The owner settled this in PostHog/sdk-specs#162: an event that before_send drops uses up its dedupe key on both clients. The application chose to drop it, and every later read would be dropped the same way. A raising capture, a full or closed queue, opt-out and a disabled client still release the key, so a later read tries again. The async client already behaved this way, because its before_send runs after capture() has queued the event. On the sync client, _capture_once now passes a private _before_send_drops list through capture() to _enqueue, which records a before_send drop there. A before_send that raises still counts as a failed capture. Tests: a before_send drop of each event on the sync client, and a drop of the exposure on the async client with its worker running, are not sent again. A raising capture and an unqueued capture of each event are still sent by the second read. The config version 1 test now uses an unqueued capture.
dustinbyrne
reviewed
Nov 4, 2026
dustinbyrne
left a comment
Contributor
There was a problem hiding this comment.
can you ask your agent to apply and archive this change and commit?
it should produce paths like openspec/specs/experiment-exposure/spec.md and move the changes into openspec/changes/archive/YYYY-MM-DD-add-experiment-exposure
dustinbyrne
approved these changes
Nov 4, 2026
Sync the deltas into openspec/specs/experiment-exposure (new), feature-flag-called-tracker and evaluate-flags, and move the change to openspec/changes/archive/2026-11-05-add-experiment-exposure. - Add acceptance/private/experiment-exposure.feature and the config version 2 attribution, dedupe-key and minimal-event scenarios in feature-flag-called-tracker.feature. - Write the Purpose and Applicability of the new spec, and point the tracker's Behavior and Interactions prose at the config version 2 key and experiment-exposure. - Add Experiment Exposure to the README capability index. Tasks 2.3, 2.6 and 3.3 stay open: they are outside this repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
andehen
added a commit
to PostHog/posthog-sdk-test-harness
that referenced
this pull request
Nov 5, 2026
…ct 9.0.0) (#101) * feat(flags): accept a flag call that drops a broken rule pair Follow PostHog/sdk-specs#162. On a config version 2 $feature_flag_called, $feature_flag_rule_id and $feature_flag_rule_type are sent together or not at all, and only with a rule type the reason code allows. A record with a missing rule ID, a missing rule type or a disallowed rule type loses the whole pair; the call keeps the config version and reason code. The called-context schema only tolerated this for a malformed experiment_split. The presence-schema generator now emits one dropped-attribution branch per reason that names a rule in the matrix (targeting_match, experiment_split, rollout_miss, experiment_paused, holdout). The malformed-split branch is generated the same way, with the same shape. A half pair and a disallowed rule type are still rejected. The meta-tests check a call without attribution for each of those reasons, the complete call for every matrix row, and each half pair and disallowed rule type. The set of accepted calls grows, so this is contract package 9.0.0. * feat(flags): a failed record yields no legacy payload A failed record reads as disabled, so the legacy payload accessor yields no payload for it, even when the record still carries metadata.payload. readers.v1_failed is such a record: a producer-valid failed config version 1 record with payload {"columns":2}. The contract defined its error code but said nothing about the payload. Reader expectations gain an optional legacy_payload: null for no payload, otherwise the JSON-encoded payload string. readers.v1_failed pins null. A meta-test checks every declared legacy_payload against the record, and requires one on any failed reader case that carries a payload. Part of contract package 9.0.0. * fix(flags): pin legacy_payload as the decoded value and test stray attribution - `legacy_payload` is the decoded JSON value the payload accessor returns, not the encoded wire string, as the reader section already says accessors decode. The meta-test decodes the wire payload and treats invalid JSON as no payload. `readers.v1_at_v4` declares its decoded payload so the encoding is pinned. - The called-context meta-tests add each attribution field to a call without attribution, for every reason that names a rule and for `flag_disabled`, and check that a `flag_disabled` call without attribution is valid. Before this, a generator that stopped forbidding a field on a dropped call still passed. * feat(flags): accept a flag call with a missing or unregistered reason code A config version 2 `$feature_flag_called` whose `$feature_flag_reason_code` is missing, or is not a registered reason code, is now valid if it carries no rule, variant or experiment attribution. It keeps config version 2 and the raw code, if any. - The generator adds one branch for these calls. It excludes every reason that has a branch of its own, and every other branch now requires its reason code, so the branches do not overlap. A registered code still goes through its own branches: a `targeting_match` call with a half pair is still rejected. - The `then` clause no longer requires a reason code on every version 2 call. - Meta-tests: an unknown and a missing code without attribution are valid; each attribution field, and each complete rule pair, on such a call is invalid; the catch-all excludes exactly the registry's reason codes.
posthog Bot
added a commit
that referenced
this pull request
Nov 9, 2026
Deep re-audit of posthog-php, posthog-ruby and posthog-go against the 69 cross-SDK contracts, at sdk-specs 5160f2f. Selection: php and ruby were the two stalest pointers (6 weeks, outside the ~4-week window) and had the largest backfill; go is the remaining matrix SDK named in the mcp-analytics applicability table, which five of this cycle's 19 spec commits touch. All three are server SDKs, where the Emails, Exception Event Metadata, Tracing Headers and config-version-2 flag deltas land. Contract surface grew to 69 with Experiment Exposure (#162). Main findings: - Emails went from one implementation to four. All three SDKs ship it and all three pass, including #163's flattened send body, Cloud app-host resolution, and a once-per-call idempotency key. - #129's span-context bridge and #123's trace context on exception events landed in all three. posthog-go is alone in getting the detached-work scenario right, and alone in the trace-context path being reachable only through an opt-in helper in a separate module. - None of the three reads /flags?v=4, so Experiment Exposure is ➖ on all three by the spec's own applicability sentence — raised as a scope question. - posthog-ruby ships a 3,798-line MCP package the spec's applicability table records as "not yet"; scored 🟡, with the table flagged for update. - The #132 jitter requirement is now unmet on five of seven SDKs checked; posthog-go passes jitter: 0 at every ingestion call site while its own Emails client jitters. Scope: compliance/ only. Generated-By: PostHog Desktop Task-Id: 86b4f98b-a338-40c2-912e-9e3a9dc366fd
This was referenced Nov 9, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This adds the OpenSpec change
add-experiment-exposurefor config version 2 flags ("flag rules v2"), which are built from ordered rules. For these flags, an SDK must:$experiment_exposureitself when a read lands on an experiment split;$feature_flag_called;Applied and archived in this PR.
The problem
Today ingestion copies some multivariate
$feature_flag_calledevents into$experiment_exposure, and reads the variant from the string in$feature_flag_response. That cannot:A
/flags?v=4record for a config version 2 flag names the reason code, rule ID, rule type and variant key, so the SDK knows when a read assigned a variant. Ingestion is ready: it keeps a directly sent exposure, and it does not copy a$feature_flag_calledthat has$feature_flag_config_version2 (PostHog/posthog#125449).The wire contract is
contracts/feature_flag_rules_v2in posthog-sdk-test-harness 1.27.0 (contract 8.0.0). One rule needs posthog-sdk-test-harness#101 (ready for review, contract 9.0.0): it lets a$feature_flag_calledthat drops a broken rule pair, or that has a missing or unknown reason code, pass validation.When SDKs send
$experiment_exposureThe rule
A read that reports
$feature_flag_called(its feature flag event decision istrue) also sends one$experiment_exposure, deduped on its own key, when the record that supplied the value has all of these:experiment_split;experiment;No other outcome sends it: not a targeted or percentage match, rollout exclusion, holdout, paused experiment, default, missing or failed flag, or config version 1 flag. A linked experiment is not required.
The SDK decides from the record, not the returned value. A variant whose value is
false, or a read that returned its caller default fornullorfalse, still counts.Edge cases
experiment, or the rule ID or variant key is missing, there is no exposure.$feature_flag_calledkeeps config version 2 and the reason code, and drops rule ID, rule type and variant. The SDK never guesses the missing parts. A rule ID that is not a UUID string counts as missing. A known field of the wrong JSON type is a per-record parse error, not a malformed split.$feature_flag_calledwith config version 2 and the raw code (none if missing), no rule attribution, and no exposure. The SDK does not map the code to another one.true) governs both events. When it isfalse, neither event is sent and neither dedupe store is touched. Payload reads send nothing by default. A payload read that opted into events and evaluates the flag follows the normal trigger. A caller-supplied match value never sends an exposure or reserves its key.locally_evaluatedtrue. Config version 1 flags keep the ingestion path.before_sendhook (or equivalent) discards it. A failed capture (a throw, a full or closed queue, opt-out or a disabled SDK) uses up no key and is tried again on the next read. An OpenFeature hook can reuse it.Properties
$experiment_exposure, required:$feature_flag$feature_flag_response$feature_flag_variantmetadata.variant_key$feature_flag_rule_idmetadata.rule_idlocally_evaluatedtruefor local evaluation, elsefalseIt omits the config version, rule type, reason code and
$experiment_exposure_source, because they never change for a split. The rule ID identifies the experiment, and only SDK-sent exposures have one.When available, it adds the flag ID and version, request ID, evaluation time, definitions-loaded time,
$lib,$lib_versionand$groups. It shares the paired call's identity and session but has its own UUID. It never carries an experiment ID, holdout ID, forced-variant marker or assignment seed. Minimal-event mode does not apply to it.$feature_flag_called, new properties for config version 2 only:$feature_flag_config_versionis not$feature_flag_version(the row version).$feature_flag_reason_codereplaces$feature_flag_reasonon config version 2 events only.$feature_flag_responsekeeps today's format on both events:truefor numbers and objects,falsefornull.Dedupe
$feature_flag_called: for config version 2, the existing key adds config version, reason code, rule ID, rule type and variant key. Each new outcome sends a new event: another A/A variant, a new rule, a holdout after a variant, a holdout then a pause, shipping a winner, a move to config version 2.$experiment_exposure: its own size-limited store, keyed on distinct ID, groups, flag key, config version, rule ID and variant key. It leaves out the value, request ID, evaluation time and source, so a remote result after the same local assignment is one exposure.$feature_flag_calledthat the tracker suppresses does not suppress a new exposure, and the reverse.Worth looking at first
$feature_flag_calledtable and the rule-pair rule.Relation to #104
#104 (typed flag values) leaves the exposure, attribution and dedupe to this PR. Either can land first. The two agree on caller defaults, type mismatches,
$feature_flag_response, bootstrap and overrides.Implementation
posthog-python goes first: draft PostHog/posthog-python#1154, based on main. It builds on PostHog/posthog-python#1031, released in posthog 7.83.0.
Out of scope
$feature_flag_response.Files and process
The change is applied and archived:
openspec/specs/experiment-exposure/spec.md(new), and new requirements inopenspec/specs/feature-flag-called-tracker/spec.mdandopenspec/specs/evaluate-flags/spec.md.openspec/changes/archive/2026-11-05-add-experiment-exposure/(proposal, design, tasks, deltas).acceptance/private/experiment-exposure.feature(new), and attribution, dedupe-key and minimal-event scenarios inacceptance/private/feature-flag-called-tracker.feature. They keep the legacy@both,@clientand@servertags, so the integration suite does not select them yet.Three tasks stay open, because they are outside this repository: the harness step bindings for these scenarios, the release of contract 9.0.0 (posthog-sdk-test-harness#101), and the check that experiment analysis reads
$feature_flag_variant.openspec validate --specs --strictpasses.🤖 Generated with Claude Code