From 5969c3976d2fb3882fbf91cd50894abb39590b89 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Fri, 23 Oct 2026 14:20:36 -0700 Subject: [PATCH] fix: fill feature flag properties below context tags and super properties Flag properties from flags or send_feature_flags are values the SDK adds, so they now fill after the call's properties, context tags and super_properties, like $is_server and system properties. 7.x already let context tags and super_properties win over them. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 3 +- posthog/async_client.py | 6 +++- posthog/capture_event.py | 8 +++-- posthog/client.py | 7 ++--- posthog/test/test_evaluate_flags.py | 4 +-- posthog/test/test_event_options.py | 48 +++++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 5f795469f..6577dd6dc 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -190,7 +190,7 @@ Values apply in this order. Steps 2 to 4 fill only the options and properties th 1. the `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable` 2. context options and tags 3. `super_options` and `super_properties` -4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID` +4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID` 5. `before_send`, which sees the result of steps 1 to 4 and can change or remove any of it 6. legacy properties, which fill unset options and are then removed @@ -203,6 +203,7 @@ These changes follow from this order: - Properties passed to a call now override `super_properties`. `super_properties` can no longer change `$lib` or `$lib_version`. - A `$is_server`, `$geoip_disable` or system property such as `$os` that you set in a call or a context tag now wins over the SDK's value. In 7.x the SDK overwrote it. `super_properties` win over the SDK's value too, so `super_properties={"$geoip_disable": False}` turns GeoIP lookup on for events, even with `disable_geoip=True`. +- Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag or `super_properties`, as in 7.x. - A `disable_geoip` argument to a call belongs to that event, so it wins over context tags and `super_properties`. A `$geoip_disable` in the call's own `properties` still wins over it. `disable_geoip=False` now sends `$geoip_disable: false`. In 7.x it sent no `$geoip_disable` property. - The `groups` argument merges into a `$groups` property of the call, and wins key by key. In 7.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way. - A `$set`, `$set_once`, `$groups` or `$group_set` in `super_properties` no longer replaces the whole value of the call. The two merge, and the call wins key by key. diff --git a/posthog/async_client.py b/posthog/async_client.py index 87237a1b6..5e8834ce1 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -546,6 +546,7 @@ def _event_defaults( property_allowlist=None, disable_geoip: Optional[bool] = None, system_properties: Optional[dict[str, Any]] = None, + flag_properties: Optional[dict[str, Any]] = None, ) -> _EventDefaults: return _build_event_defaults( super_properties=self.super_properties, @@ -559,6 +560,7 @@ def _event_defaults( disable_geoip=self.disable_geoip, call_disable_geoip=disable_geoip, system_properties=system_properties, + flag_properties=flag_properties, ) def _build_capture_event( @@ -576,8 +578,9 @@ def _build_capture_event( flags_snapshot = kwargs.get("flags") send_feature_flags = kwargs.get("send_feature_flags") + flag_properties = None if flags_snapshot is not None: - properties = {**flags_snapshot._get_event_properties(), **properties} + flag_properties = flags_snapshot._get_event_properties() elif send_feature_flags: warnings.warn( "AsyncClient does not support deprecated send_feature_flags. Pass a " @@ -603,6 +606,7 @@ def _build_capture_event( property_allowlist=kwargs.get("_property_allowlist"), disable_geoip=kwargs.get("disable_geoip"), system_properties=system_context(), + flag_properties=flag_properties, ), ) diff --git a/posthog/capture_event.py b/posthog/capture_event.py index a5c37f284..b99061e89 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -148,19 +148,23 @@ def _build_event_defaults( disable_geoip: bool = False, call_disable_geoip: Optional[bool] = None, system_properties: Optional[Mapping[str, Any]] = None, + flag_properties: Optional[Mapping[str, Any]] = None, ) -> _EventDefaults: """Order the layers: per-call arguments, context, global, then SDK values. ``disable_geoip`` is the client setting, an SDK value. ``call_disable_geoip`` is the argument of one call: it is a value of that event, so only the - event's own ``$geoip_disable`` property beats it. + event's own ``$geoip_disable`` property beats it. ``flag_properties`` (the + ``$feature/*`` and ``$active_feature_flags`` values from ``flags`` or + ``send_feature_flags``) are SDK values, so context and super properties + beat them. """ call_properties = ( {} if call_disable_geoip is None else {"$geoip_disable": call_disable_geoip} ) # A value in the event, the context or super properties wins over every # value the SDK adds, including `$is_server` and `$geoip_disable`. - sdk_properties = dict(system_properties or {}) + sdk_properties = {**(flag_properties or {}), **(system_properties or {})} if release_id is not None: sdk_properties["$release_id"] = release_id if is_server: diff --git a/posthog/client.py b/posthog/client.py index 34db581d9..f70847108 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1911,10 +1911,6 @@ def _capture( if active_feature_flags: extra_properties["$active_feature_flags"] = active_feature_flags - if extra_properties: - properties = {**extra_properties, **properties} - msg["properties"] = properties - return self._enqueue( msg, disable_geoip, @@ -1924,6 +1920,7 @@ def _capture( context_options=_context_get_context_options(), derived_options=_personless_options(personless), system_properties=system_context(), + flag_properties=extra_properties, ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -2538,6 +2535,7 @@ def _enqueue( context_options=None, derived_options=None, system_properties=None, + flag_properties=None, ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2585,6 +2583,7 @@ def _enqueue( disable_geoip=self.disable_geoip, call_disable_geoip=disable_geoip, system_properties=system_properties, + flag_properties=flag_properties, ), ) diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 32a63ab27..ac93d0dc0 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -629,7 +629,7 @@ def test_capture_with_flags_attaches_feature_properties( None, ) self.assertIsNotNone(page_viewed) - properties = page_viewed[0][0]["properties"] + properties = page_viewed.kwargs["flag_properties"] self.assertEqual(properties["$feature/variant-flag"], "variant-value") self.assertEqual(properties["$feature/boolean-flag"], True) self.assertEqual(properties["$feature/disabled-flag"], False) @@ -659,7 +659,7 @@ def test_capture_with_only_accessed_attaches_only_those_flags( ), None, ) - properties = page_viewed[0][0]["properties"] + properties = page_viewed.kwargs["flag_properties"] self.assertEqual(properties["$feature/boolean-flag"], True) self.assertNotIn("$feature/variant-flag", properties) self.assertNotIn("$feature/disabled-flag", properties) diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index f3fb1237c..ab28f3788 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -427,6 +427,54 @@ async def test_async_caller_values_beat_sdk_values(source): _assert_caller_values(events, CALLER_SDK_VALUES) +class _FlagSnapshot: + def _get_event_properties(self): + return {"$feature/beta": "snapshot", "$active_feature_flags": ["beta"]} + + +def _capture_with_flags_and_caller_value(source): + def call(client): + with new_context(fresh=True): + if source == "context": + tag("$feature/beta", "caller") + properties = {"$feature/beta": "caller"} if source == "event" else None + return client.capture( + "e", distinct_id="u", properties=properties, flags=_FlagSnapshot() + ) + + return call + + +def _flag_layer_config(source): + return ( + {"super_properties": {"$feature/beta": "caller"}} if source == "super" else {} + ) + + +def _assert_flag_layers(events, source): + properties = events[0]["properties"] + expected = "snapshot" if source is None else "caller" + assert properties["$feature/beta"] == expected + assert properties["$active_feature_flags"] == ["beta"] + + +@pytest.mark.parametrize("source", ["event", "context", "super", None]) +def test_sync_caller_layers_beat_flag_properties(source): + events = _sync_wire_events( + _capture_with_flags_and_caller_value(source), **_flag_layer_config(source) + ) + _assert_flag_layers(events, source) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["event", "context", "super", None]) +async def test_async_caller_layers_beat_flag_properties(source): + events = await _async_wire_events( + _capture_with_flags_and_caller_value(source), **_flag_layer_config(source) + ) + _assert_flag_layers(events, source) + + GEOIP_CALL_CASES = [ pytest.param(True, "context", False, True, id="call_beats_context"), pytest.param(True, "super", False, True, id="call_beats_super"),