Skip to content

docs(flags): honor the feature flag event opt-out on snapshot reads - #111

Merged
andehen merged 4 commits into
mainfrom
docs/snapshot-flag-event-opt-out
Oct 28, 2026
Merged

andehen merged 4 commits into
mainfrom
docs/snapshot-flag-event-opt-out

Conversation

@andehen

@andehen andehen commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Why

evaluateFlags() is the preferred server API, and the single-flag getters it supersedes (getFeatureFlag, isFeatureEnabled, getFeatureFlagResult, …) now log deprecation warnings that tell users to migrate to it. Those getters let the caller control $feature_flag_called: every server SDK takes a per-call option, and some SDKs also have a client-level default. The evaluate-flags contract mentions neither control, so snapshot reads honor neither.

  • Customers turn these events off on purpose. They do it for flags they don't use with flag analytics or experiments, to cut event volume and noise in their project.
  • Following our deprecation guidance should not undo that setting. Today a Python caller that passed send_feature_flag_events=False gets every event back after migrating. A posthog-node client created with sendFeatureFlagEvent: false gets them back from snapshot reads.
  • Customers who need experiment exposures must keep them. A client with the default off that opts specific flags back in, typically experiment flags via getFeatureFlag(key, id, { sendFeatureFlagEvents: true }), has no snapshot equivalent. Once snapshot reads honor the client default, those experiments would see no exposures.
  • The control belongs on each read, not on the snapshot. One request typically reads experiment flags and operational flags (kill switches, config) from the same snapshot, so a per-snapshot setting would force a second evaluateFlags(), and a second /flags request, to mix them.
  • Dedupe and access stay separate. A suppressed read must not mark the dedupe tracker, or a later opted-in read of the same key would be swallowed. It still counts as access for onlyAccessed(), because the code did branch on that flag.

What changed

New requirement Snapshot feature flag event control in evaluate-flags:

  • The enablement and value accessors (including a value accessor that returns a rich result) accept an optional per-read sendFeatureFlagEvents option, in platform casing (send_feature_flag_events), with the same meaning as the per-call option on the server getters. Payload, key enumeration and filters take no option.
  • The SDK decides each read in this order: the per-read value, then the SDK's client-level default where one exists, then true. This is the order the legacy getters already use.
  • When the decision is false, the read emits no $feature_flag_called, including flag_missing. It doesn't consult or update the dedupe tracker, so a later opted-in read of the same key still emits. It still marks the key as accessed for onlyAccessed().
  • evaluateFlags() takes no per-snapshot option.

Amended requirements:

  • Server-side snapshot evaluation API. The supersession table gains a row: getFeatureFlag(key, id, { sendFeatureFlagEvents: x }) → evaluateFlags(id).getFlag(key, { sendFeatureFlagEvents: x }). The "not side-effect-preserving" note now says exposure-event control is preserved. The other documented differences (bulk maps, payload silence, Go's legacy payload exposure) are unchanged.
  • Lazy feature-flag access tracking. Event routing and flag_missing reporting now defer to the new control. Access marking is unchanged.

Unchanged: creating a snapshot emits nothing, payload reads stay silent, and a read with no option anywhere still emits.

acceptance/public/evaluate-flags.feature adds six scenarios that mirror the spec scenarios. They use the request-local snapshot read steps from #107: the per-read option goes in a read's options as send_feature_flag_events, and the client-level default goes in the setup configuration as send_feature_flag_event. They are tagged @server, not @sdk:server, because no SDK has run them end to end through the shared harness yet. The two client-default scenarios also carry @requires:flag_event_default, because only some SDKs have a client-level default.

Proposed, applied and archived on this branch under openspec/changes/archive/2026-10-08-honor-snapshot-flag-event-opt-out/. The design doc records the SDK survey and a migration note: an SDK whose snapshot reads ignored its client-level default until now should say in its changelog that callers who relied on snapshot reads for exposures while that default was off must now opt those reads in.

Validation

  • openspec validate --specs --strict (CLI 1.4.1, as in CI): 64 passed, 0 failed.
  • The change validated strictly before archiving.
  • The acceptance feature parses with @cucumber/gherkin: 58 scenario definitions, up from 52 on main.
  • git diff --check is clean.

These are specification checks. No SDK conformance was executed.

Evidence

Server SDK survey of default branches on 2026-10-08:

SDK Client-level default Per-call option Snapshot reads
posthog-node sendFeatureFlagEvent sendFeatureFlagEvents ignore both (draft #5252 adds the client default and the per-read option)
JVM server PostHogConfig.sendFeatureFlagEvent sendFeatureFlagEvent on getFeatureFlagResult honor the client default; no per-read option
posthog-python none send_feature_flag_events no control
posthog-ruby none send_feature_flag_events: no control
posthog-php none $sendFeatureFlagEvents no control
posthog-go none FeatureFlagPayload.SendFeatureFlagEvents no control
posthog-dotnet none FeatureFlagOptions.SendFeatureFlagEvents no control
posthog-elixir none send_event: on get_feature_flag_result no control

Considered alternatives

  • A per-snapshot option on evaluateFlags(). Rejected. Mixing experiment and operational flags in one request would need two evaluations and two /flags requests, which defeats the single-evaluation purpose of the API. This is the same reasoning that put the runtime filter on the snapshot.
  • Only honor the client-level default (the first version of #5252). Necessary but not enough. There is no way to opt experiment flags back in, a default-on client cannot silence one read (health checks, bulk scans), and six of the eight surveyed SDKs have no client-level default at all.
  • Mark the dedupe tracker on suppressed reads. Rejected, because it swallows a later opted-in exposure for the same identity, key and value.
  • Skip access marking on suppressed reads. Rejected. onlyAccessed() would drop flags the code branched on.

Related

Follow-ups

  • posthog-node: land #5252. It adds the client default and the per-read sendFeatureFlagEvents option on isEnabled() / getFlag(), and its changeset tells users who relied on snapshot reads for exposures while sendFeatureFlagEvent was false to opt those reads in.
  • posthog-python: add a send_feature_flag_events keyword to is_enabled() / get_flag().
  • Ruby, PHP, Go, .NET, JVM server, Elixir: add the per-read option.
  • OpenFeature providers: no spec here governs them. packages/openfeature-node-provider in posthog-js defaults its sendFeatureFlagEvents to true (provider.ts:78) and passes it on every call (provider.ts:154). That overrides a client created with sendFeatureFlagEvent: false. The provider should leave the option unset unless its caller sets it. #5252 makes that change in a separate commit.
  • Acceptance harness: forward send_feature_flag_events on is_enabled and get_flag reads, and send_feature_flag_event in the setup configuration, to the SDK's public options. Declare flag_event_default for SDKs with a client-level default. Once the six scenarios pass end to end against an SDK with the option, change their tag to @sdk:server.

🤖 Generated with Claude Code

Snapshot isEnabled()/getFlag() reads now take an optional per-read
sendFeatureFlagEvents option, resolved as per-read value, then the SDK's
client-level default where one exists, then send. A read that does not
send emits no $feature_flag_called (including flag_missing), leaves the
dedupe tracker untouched, and still counts for onlyAccessed().

evaluateFlags() takes no per-snapshot option, and the supersession map
now carries the per-call option across to the snapshot read.
@andehen
andehen force-pushed the docs/snapshot-flag-event-opt-out branch from 3b1864e to 114246c Compare October 13, 2026 07:39
@andehen
andehen marked this pull request as ready for review October 13, 2026 07:41
@andehen
andehen requested a review from a team as a code owner October 13, 2026 07:41
…ent-opt-out

# Conflicts:
#	acceptance/public/evaluate-flags.feature
#	openspec/specs/evaluate-flags/spec.md
The per-call option requirement said value and result APIs default to
sending the event. Read literally, an SDK could declare the option as
`true` and override a disabled client-level default. An omitted option
now falls back to the client-level default where the SDK has one.

Add scenarios for an enabled client default with a per-read `false`, and
for a suppressed snapshot read followed by a single-flag getter.
SDKs without a client-level default can default the per-call option to true. #5252 is still closed, so the migration plan now says to reopen it or implement an equivalent.
@andehen
andehen merged commit e5b4ff6 into main Oct 28, 2026
10 checks passed
@andehen
andehen deleted the docs/snapshot-flag-event-opt-out branch October 28, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants