From ce4ae776a5050e18faa69c96f9035ea0d8a54925 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 15 Jul 2026 09:22:07 -0700 Subject: [PATCH 1/2] Add $feature_flag_has_experiment to $feature_flag_called events --- .../feature-flag-has-experiment-property.md | 5 + posthog/client.py | 31 +++ posthog/feature_flag_evaluations.py | 4 + .../test/test_feature_flag_has_experiment.py | 214 ++++++++++++++++++ posthog/test/test_feature_flag_result.py | 18 ++ posthog/test/test_feature_flags.py | 7 + posthog/types.py | 4 + references/public_api_snapshot.txt | 3 +- 8 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 .sampo/changesets/feature-flag-has-experiment-property.md create mode 100644 posthog/test/test_feature_flag_has_experiment.py diff --git a/.sampo/changesets/feature-flag-has-experiment-property.md b/.sampo/changesets/feature-flag-has-experiment-property.md new file mode 100644 index 00000000..36706ab2 --- /dev/null +++ b/.sampo/changesets/feature-flag-has-experiment-property.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Every `$feature_flag_called` event now carries a `$feature_flag_has_experiment` boolean property reflecting the server-reported `has_experiment` signal for the flag. When the server does not report the field (older deployments), it defaults to `false`. diff --git a/posthog/client.py b/posthog/client.py index 2458af21..49892857 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2360,6 +2360,19 @@ def _get_feature_flag_result( flag_result = self._get_stale_flag_fallback(distinct_id, key) if send_feature_flag_events: + # Locally-evaluated flags carry has_experiment in the stored definition; + # remotely-evaluated flags carry it in the response metadata. Defaults to + # False when the server (older deployment) does not report it. + has_experiment = False + if flag_was_locally_evaluated: + local_def = (self.feature_flags_by_key or {}).get(key) + if isinstance(local_def, dict): + has_experiment = local_def.get("has_experiment", False) + elif isinstance(flag_details, FeatureFlag) and isinstance( + flag_details.metadata, FlagMetadata + ): + has_experiment = flag_details.metadata.has_experiment + self._capture_feature_flag_called( distinct_id, key, @@ -2372,6 +2385,7 @@ def _get_feature_flag_result( evaluated_at, flag_details, feature_flag_error, + has_experiment, ) return flag_result @@ -2651,6 +2665,7 @@ def _capture_feature_flag_called( evaluated_at: Optional[int], flag_details: Optional[FeatureFlag], feature_flag_error: Optional[str] = None, + has_experiment: bool = False, ): properties: dict[str, Any] = { "$feature_flag": key, @@ -2685,6 +2700,7 @@ def _capture_feature_flag_called( properties=properties, groups=groups, disable_geoip=disable_geoip, + has_experiment=has_experiment, ) def _capture_feature_flag_called_if_needed( @@ -2696,6 +2712,7 @@ def _capture_feature_flag_called_if_needed( properties: dict[str, Any], groups: Optional[Mapping[str, Union[str, int]]] = None, disable_geoip: Optional[bool] = None, + has_experiment: bool = False, ) -> None: """Fire a ``$feature_flag_called`` event if the (distinct_id, flag, response, groups) tuple hasn't already been reported on this client. Group context is @@ -2703,6 +2720,10 @@ def _capture_feature_flag_called_if_needed( user is evaluated under. Shared by the single-flag evaluation path and ``FeatureFlagEvaluations.is_enabled() / get_flag()`` so both paths dedupe identically. + + ``has_experiment`` is the server-reported signal for whether the flag is linked + to an experiment; it is recorded on the event as + ``$feature_flag_has_experiment``. """ groups_key = ( tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () @@ -2717,6 +2738,10 @@ def _capture_feature_flag_called_if_needed( if feature_flag_reported_key in reported_flags: return + # Record the server's experiment signal on every event so the server can + # optimize ingestion. + properties["$feature_flag_has_experiment"] = has_experiment + self.capture( "$feature_flag_called", distinct_id=distinct_id, @@ -3034,6 +3059,7 @@ def evaluate_flags( version=None, reason="Evaluated locally", locally_evaluated=True, + has_experiment=flag_def.get("has_experiment", False), ) locally_evaluated_keys.add(key) @@ -3096,6 +3122,11 @@ def evaluate_flags( else None ), locally_evaluated=False, + has_experiment=( + detail.metadata.has_experiment + if isinstance(detail.metadata, FlagMetadata) + else False + ), ) except QuotaLimitError as e: self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index c2b81537..e1358b63 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -23,6 +23,9 @@ class _EvaluatedFlagRecord: version: Optional[int] reason: Optional[str] locally_evaluated: bool + # Server-reported signal for whether the flag is linked to an experiment. + # Defaults to ``False`` when the server did not report it (older deployments). + has_experiment: bool = False @dataclass @@ -258,4 +261,5 @@ def _record_access(self, key: str) -> None: groups=self._groups, disable_geoip=self._disable_geoip, properties=properties, + has_experiment=flag.has_experiment if flag else False, ) diff --git a/posthog/test/test_feature_flag_has_experiment.py b/posthog/test/test_feature_flag_has_experiment.py new file mode 100644 index 00000000..22212774 --- /dev/null +++ b/posthog/test/test_feature_flag_has_experiment.py @@ -0,0 +1,214 @@ +"""Tests for the ``$feature_flag_has_experiment`` property on ``$feature_flag_called``. + +The server reports ``has_experiment`` in the flag definition and in the ``/flags`` +response metadata. Every ``$feature_flag_called`` event carries the signal as a +``$feature_flag_has_experiment`` boolean property. When the server does not report the +field (older deployments), it defaults to ``False``. +""" + +import unittest +from unittest import mock + +from posthog.client import Client +from posthog.test.test_utils import FAKE_TEST_API_KEY + + +def _flags_response(has_experiment): + """Build a ``/flags`` response for ``person-flag`` with the given ``has_experiment``. + + ``has_experiment=None`` omits the field entirely, simulating an older server. + """ + metadata = {"id": 23, "version": 42, "payload": "300"} + if has_experiment is not None: + metadata["has_experiment"] = has_experiment + return { + "flags": { + "person-flag": { + "key": "person-flag", + "enabled": True, + "variant": None, + "reason": {"description": "Matched condition set 1"}, + "metadata": metadata, + }, + }, + } + + +class TestFeatureFlagHasExperimentRemoteEval(unittest.TestCase): + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def _captured_properties(self, patch_capture): + _, kwargs = patch_capture.call_args + return kwargs["properties"] + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_experiment_flag_sends_true(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response(has_experiment=True) + + self.client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_non_experiment_flag_sends_false(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response(has_experiment=False) + + self.client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_missing_has_experiment_sends_false(self, patch_capture, patch_flags): + # A server that does not report the field (older deployment) defaults to False. + patch_flags.return_value = _flags_response(has_experiment=None) + + self.client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + +class TestFeatureFlagHasExperimentLocalEval(unittest.TestCase): + """The local-evaluation poller stores ``has_experiment`` verbatim in the flag + definition, so locally-evaluated flags report the same signal.""" + + def _local_flag(self, has_experiment): + flag = { + "id": 1, + "name": "Beta Feature", + "key": "person-flag", + "active": True, + "filters": { + "groups": [{"properties": [], "rollout_percentage": 100}], + "payloads": {"true": "300"}, + }, + } + if has_experiment is not None: + flag["has_experiment"] = has_experiment + return flag + + def _captured_properties(self, patch_capture): + _, kwargs = patch_capture.call_args + return kwargs["properties"] + + @mock.patch.object(Client, "capture") + def test_local_experiment_flag_sends_true(self, patch_capture): + client = Client(FAKE_TEST_API_KEY) + client.feature_flags = [self._local_flag(has_experiment=True)] + + client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], True) + + @mock.patch.object(Client, "capture") + def test_local_non_experiment_flag_sends_false(self, patch_capture): + client = Client(FAKE_TEST_API_KEY) + client.feature_flags = [self._local_flag(has_experiment=False)] + + client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + @mock.patch.object(Client, "capture") + def test_local_missing_has_experiment_sends_false(self, patch_capture): + client = Client(FAKE_TEST_API_KEY) + client.feature_flags = [self._local_flag(has_experiment=None)] + + client.get_feature_flag_result("person-flag", "some-distinct-id") + + properties = self._captured_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + +class TestFeatureFlagHasExperimentEvaluateFlags(unittest.TestCase): + """The ``evaluate_flags()`` snapshot path fires ``$feature_flag_called`` on access + and must carry the same signal as the single-flag path.""" + + def _flags_response(self, has_experiment): + metadata = {"id": 2, "version": 23, "payload": '{"key": "value"}'} + if has_experiment is not None: + metadata["has_experiment"] = has_experiment + return { + "flags": { + "variant-flag": { + "key": "variant-flag", + "enabled": True, + "variant": "variant-value", + "reason": {"code": "variant", "description": "Matched set 3"}, + "metadata": metadata, + }, + }, + "requestId": "request-id-1", + "evaluatedAt": 1640995200000, + } + + def _called_properties(self, patch_capture): + for call in patch_capture.call_args_list: + if call[0] and call[0][0] == "$feature_flag_called": + return call[1]["properties"] + raise AssertionError("no $feature_flag_called event captured") + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_experiment_flag_sends_true(self, patch_capture, patch_flags): + patch_flags.return_value = self._flags_response(has_experiment=True) + flags = Client(FAKE_TEST_API_KEY).evaluate_flags("user-1") + + flags.get_flag("variant-flag") + + properties = self._called_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_non_experiment_flag_sends_false(self, patch_capture, patch_flags): + patch_flags.return_value = self._flags_response(has_experiment=False) + flags = Client(FAKE_TEST_API_KEY).evaluate_flags("user-1") + + flags.get_flag("variant-flag") + + properties = self._called_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_missing_has_experiment_sends_false(self, patch_capture, patch_flags): + patch_flags.return_value = self._flags_response(has_experiment=None) + flags = Client(FAKE_TEST_API_KEY).evaluate_flags("user-1") + + flags.get_flag("variant-flag") + + properties = self._called_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], False) + + @mock.patch.object(Client, "capture") + def test_local_evaluation_snapshot_sends_signal(self, patch_capture): + # Exercises the snapshot's local-eval branch, where has_experiment is sourced + # from the stored flag definition rather than a /flags response. + client = Client(FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "local-flag", + "active": True, + "has_experiment": True, + "filters": { + "groups": [{"properties": [], "rollout_percentage": 100}], + "payloads": {"true": "300"}, + }, + } + ] + + flags = client.evaluate_flags("user-1") + flags.get_flag("local-flag") + + properties = self._called_properties(patch_capture) + self.assertIs(properties["$feature_flag_has_experiment"], True) diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 5eebc4f6..f797acda 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -238,6 +238,7 @@ def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": True, "$feature_flag_payload": 300, @@ -295,6 +296,7 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-1", + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": "variant-1", "$feature_flag_payload": {"some": "value"}, @@ -320,6 +322,7 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-2", + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": "variant-2", }, @@ -360,6 +363,7 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags properties={ "$feature_flag": "person-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/person-flag": True, "$feature_flag_reason": "Matched condition set 1", @@ -406,6 +410,7 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-1", + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/person-flag": "variant-1", "$feature_flag_reason": "Matched condition set 1", @@ -452,6 +457,7 @@ def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags): properties={ "$feature_flag": "no-person-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/no-person-flag": None, "$feature_flag_error": FeatureFlagError.FLAG_MISSING, @@ -494,6 +500,7 @@ def test_get_feature_flag_result_with_errors_while_computing_flags( properties={ "$feature_flag": "my-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": True, "$feature_flag_request_id": "test-request-id-789", @@ -539,6 +546,7 @@ def test_get_feature_flag_result_flag_not_in_response( properties={ "$feature_flag": "missing-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/missing-flag": None, "$feature_flag_request_id": "test-request-id-456", @@ -575,6 +583,7 @@ def test_get_feature_flag_result_errors_computing_and_flag_missing( properties={ "$feature_flag": "missing-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/missing-flag": None, "$feature_flag_request_id": "test-request-id-999", @@ -599,6 +608,7 @@ def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR, @@ -624,6 +634,7 @@ def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.TIMEOUT, @@ -649,6 +660,7 @@ def test_get_feature_flag_result_connection_error(self, patch_capture, patch_fla properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR, @@ -674,6 +686,7 @@ def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.api_error(500), @@ -699,6 +712,7 @@ def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.QUOTA_LIMITED, @@ -769,6 +783,7 @@ def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_fla properties={ "$feature_flag": "my-flag", "$feature_flag_response": "cached-variant", + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": "cached-variant", "$feature_flag_payload": {"from": "cache"}, @@ -807,6 +822,7 @@ def test_connection_error_returns_stale_cached_value( properties={ "$feature_flag": "my-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": True, "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR, @@ -843,6 +859,7 @@ def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": "control", + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": "control", "$feature_flag_error": FeatureFlagError.api_error(503), @@ -873,6 +890,7 @@ def test_error_without_cache_returns_none(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.TIMEOUT, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 47eba37b..9d232736 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5605,6 +5605,7 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -5639,6 +5640,7 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -5677,6 +5679,7 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "flags-flag", "$feature_flag_response": "flags-value", + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag": "flags-value", }, @@ -5731,6 +5734,7 @@ def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture): properties={ "$feature_flag": "flags-flag", "$feature_flag_response": "flags-variant", + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag": "flags-variant", "$feature_flag_reason": "Matched condition set 1", @@ -5785,6 +5789,7 @@ def test_capture_is_called_with_flag_details_and_payload( properties={ "$feature_flag": "flags-flag-with-payload", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag-with-payload": True, "$feature_flag_reason": "Matched condition set 1", @@ -5979,6 +5984,7 @@ def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -6100,6 +6106,7 @@ def test_capture_multiple_users_doesnt_out_of_memory( properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, + "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, diff --git a/posthog/types.py b/posthog/types.py index 1906fe9b..f08b627e 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -79,12 +79,15 @@ class FlagMetadata: payload: Payload configured for the matched flag value, if any. version: Feature flag version. description: Feature flag description. + has_experiment: Whether the flag has a linked experiment. Defaults to ``False`` + when the server does not report the field (older deployments). """ id: int payload: Optional[str] version: int description: str + has_experiment: bool = False @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: @@ -95,6 +98,7 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: payload=resp.get("payload"), version=resp.get("version", 0), description=resp.get("description", ""), + has_experiment=resp.get("has_experiment", False), ) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 05256028..ed183a89 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -762,6 +762,7 @@ attribute posthog.types.FeatureFlagResult.payload: Optional[Any] attribute posthog.types.FeatureFlagResult.reason: Optional[str] attribute posthog.types.FeatureFlagResult.variant: Optional[str] attribute posthog.types.FlagMetadata.description: str +attribute posthog.types.FlagMetadata.has_experiment: bool = False attribute posthog.types.FlagMetadata.id: int attribute posthog.types.FlagMetadata.payload: Optional[str] attribute posthog.types.FlagMetadata.version: int @@ -895,7 +896,7 @@ class posthog.request.QuotaLimitError class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata]) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str) +class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: bool = False) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse From ec560a41edbb975a151638c874c0c129b08e34bb Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 15 Jul 2026 16:22:08 -0700 Subject: [PATCH 2/2] Omit $feature_flag_has_experiment when the server does not report it --- .../feature-flag-has-experiment-property.md | 2 +- posthog/client.py | 27 ++++++++++--------- posthog/feature_flag_evaluations.py | 6 ++--- .../test/test_feature_flag_has_experiment.py | 21 ++++++++------- posthog/test/test_feature_flag_result.py | 18 ------------- posthog/test/test_feature_flags.py | 7 ----- posthog/types.py | 8 +++--- references/public_api_snapshot.txt | 4 +-- 8 files changed, 35 insertions(+), 58 deletions(-) diff --git a/.sampo/changesets/feature-flag-has-experiment-property.md b/.sampo/changesets/feature-flag-has-experiment-property.md index 36706ab2..ec5d9ecc 100644 --- a/.sampo/changesets/feature-flag-has-experiment-property.md +++ b/.sampo/changesets/feature-flag-has-experiment-property.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Every `$feature_flag_called` event now carries a `$feature_flag_has_experiment` boolean property reflecting the server-reported `has_experiment` signal for the flag. When the server does not report the field (older deployments), it defaults to `false`. +`$feature_flag_called` events now carry a `$feature_flag_has_experiment` boolean property when the server reports whether the flag is linked to an experiment. When the server does not report the signal (older deployments), the property is omitted. diff --git a/posthog/client.py b/posthog/client.py index 49892857..e87a4787 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2361,13 +2361,13 @@ def _get_feature_flag_result( if send_feature_flag_events: # Locally-evaluated flags carry has_experiment in the stored definition; - # remotely-evaluated flags carry it in the response metadata. Defaults to - # False when the server (older deployment) does not report it. - has_experiment = False + # remotely-evaluated flags carry it in the response metadata. None when + # the server (older deployment) does not report it. + has_experiment: Optional[bool] = None if flag_was_locally_evaluated: local_def = (self.feature_flags_by_key or {}).get(key) if isinstance(local_def, dict): - has_experiment = local_def.get("has_experiment", False) + has_experiment = local_def.get("has_experiment") elif isinstance(flag_details, FeatureFlag) and isinstance( flag_details.metadata, FlagMetadata ): @@ -2665,7 +2665,7 @@ def _capture_feature_flag_called( evaluated_at: Optional[int], flag_details: Optional[FeatureFlag], feature_flag_error: Optional[str] = None, - has_experiment: bool = False, + has_experiment: Optional[bool] = None, ): properties: dict[str, Any] = { "$feature_flag": key, @@ -2712,7 +2712,7 @@ def _capture_feature_flag_called_if_needed( properties: dict[str, Any], groups: Optional[Mapping[str, Union[str, int]]] = None, disable_geoip: Optional[bool] = None, - has_experiment: bool = False, + has_experiment: Optional[bool] = None, ) -> None: """Fire a ``$feature_flag_called`` event if the (distinct_id, flag, response, groups) tuple hasn't already been reported on this client. Group context is @@ -2722,8 +2722,8 @@ def _capture_feature_flag_called_if_needed( identically. ``has_experiment`` is the server-reported signal for whether the flag is linked - to an experiment; it is recorded on the event as - ``$feature_flag_has_experiment``. + to an experiment; when the server reported it, it is recorded on the event as + ``$feature_flag_has_experiment``. ``None`` (unknown) omits the property. """ groups_key = ( tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () @@ -2738,9 +2738,10 @@ def _capture_feature_flag_called_if_needed( if feature_flag_reported_key in reported_flags: return - # Record the server's experiment signal on every event so the server can - # optimize ingestion. - properties["$feature_flag_has_experiment"] = has_experiment + # Record the server's experiment signal so the server can optimize ingestion. + # Omitted when unknown (older servers that don't report has_experiment). + if has_experiment is not None: + properties["$feature_flag_has_experiment"] = has_experiment self.capture( "$feature_flag_called", @@ -3059,7 +3060,7 @@ def evaluate_flags( version=None, reason="Evaluated locally", locally_evaluated=True, - has_experiment=flag_def.get("has_experiment", False), + has_experiment=flag_def.get("has_experiment"), ) locally_evaluated_keys.add(key) @@ -3125,7 +3126,7 @@ def evaluate_flags( has_experiment=( detail.metadata.has_experiment if isinstance(detail.metadata, FlagMetadata) - else False + else None ), ) except QuotaLimitError as e: diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index e1358b63..5f65299e 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -24,8 +24,8 @@ class _EvaluatedFlagRecord: reason: Optional[str] locally_evaluated: bool # Server-reported signal for whether the flag is linked to an experiment. - # Defaults to ``False`` when the server did not report it (older deployments). - has_experiment: bool = False + # ``None`` when the server did not report it (older deployments). + has_experiment: Optional[bool] = None @dataclass @@ -261,5 +261,5 @@ def _record_access(self, key: str) -> None: groups=self._groups, disable_geoip=self._disable_geoip, properties=properties, - has_experiment=flag.has_experiment if flag else False, + has_experiment=flag.has_experiment if flag else None, ) diff --git a/posthog/test/test_feature_flag_has_experiment.py b/posthog/test/test_feature_flag_has_experiment.py index 22212774..61e28371 100644 --- a/posthog/test/test_feature_flag_has_experiment.py +++ b/posthog/test/test_feature_flag_has_experiment.py @@ -1,9 +1,10 @@ """Tests for the ``$feature_flag_has_experiment`` property on ``$feature_flag_called``. The server reports ``has_experiment`` in the flag definition and in the ``/flags`` -response metadata. Every ``$feature_flag_called`` event carries the signal as a -``$feature_flag_has_experiment`` boolean property. When the server does not report the -field (older deployments), it defaults to ``False``. +response metadata. When the server explicitly reported the signal, the +``$feature_flag_called`` event carries it as a ``$feature_flag_has_experiment`` boolean +property. When the server does not report the field (older deployments), the property +is omitted. """ import unittest @@ -64,14 +65,14 @@ def test_non_experiment_flag_sends_false(self, patch_capture, patch_flags): @mock.patch("posthog.client.flags") @mock.patch.object(Client, "capture") - def test_missing_has_experiment_sends_false(self, patch_capture, patch_flags): - # A server that does not report the field (older deployment) defaults to False. + def test_missing_has_experiment_omits_property(self, patch_capture, patch_flags): + # A server that does not report the field (older deployment) omits the property. patch_flags.return_value = _flags_response(has_experiment=None) self.client.get_feature_flag_result("person-flag", "some-distinct-id") properties = self._captured_properties(patch_capture) - self.assertIs(properties["$feature_flag_has_experiment"], False) + self.assertNotIn("$feature_flag_has_experiment", properties) class TestFeatureFlagHasExperimentLocalEval(unittest.TestCase): @@ -118,14 +119,14 @@ def test_local_non_experiment_flag_sends_false(self, patch_capture): self.assertIs(properties["$feature_flag_has_experiment"], False) @mock.patch.object(Client, "capture") - def test_local_missing_has_experiment_sends_false(self, patch_capture): + def test_local_missing_has_experiment_omits_property(self, patch_capture): client = Client(FAKE_TEST_API_KEY) client.feature_flags = [self._local_flag(has_experiment=None)] client.get_feature_flag_result("person-flag", "some-distinct-id") properties = self._captured_properties(patch_capture) - self.assertIs(properties["$feature_flag_has_experiment"], False) + self.assertNotIn("$feature_flag_has_experiment", properties) class TestFeatureFlagHasExperimentEvaluateFlags(unittest.TestCase): @@ -180,14 +181,14 @@ def test_non_experiment_flag_sends_false(self, patch_capture, patch_flags): @mock.patch("posthog.client.flags") @mock.patch.object(Client, "capture") - def test_missing_has_experiment_sends_false(self, patch_capture, patch_flags): + def test_missing_has_experiment_omits_property(self, patch_capture, patch_flags): patch_flags.return_value = self._flags_response(has_experiment=None) flags = Client(FAKE_TEST_API_KEY).evaluate_flags("user-1") flags.get_flag("variant-flag") properties = self._called_properties(patch_capture) - self.assertIs(properties["$feature_flag_has_experiment"], False) + self.assertNotIn("$feature_flag_has_experiment", properties) @mock.patch.object(Client, "capture") def test_local_evaluation_snapshot_sends_signal(self, patch_capture): diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index f797acda..5eebc4f6 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -238,7 +238,6 @@ def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": True, "$feature_flag_payload": 300, @@ -296,7 +295,6 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-1", - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": "variant-1", "$feature_flag_payload": {"some": "value"}, @@ -322,7 +320,6 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-2", - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/person-flag": "variant-2", }, @@ -363,7 +360,6 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags properties={ "$feature_flag": "person-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/person-flag": True, "$feature_flag_reason": "Matched condition set 1", @@ -410,7 +406,6 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags properties={ "$feature_flag": "person-flag", "$feature_flag_response": "variant-1", - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/person-flag": "variant-1", "$feature_flag_reason": "Matched condition set 1", @@ -457,7 +452,6 @@ def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags): properties={ "$feature_flag": "no-person-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/no-person-flag": None, "$feature_flag_error": FeatureFlagError.FLAG_MISSING, @@ -500,7 +494,6 @@ def test_get_feature_flag_result_with_errors_while_computing_flags( properties={ "$feature_flag": "my-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": True, "$feature_flag_request_id": "test-request-id-789", @@ -546,7 +539,6 @@ def test_get_feature_flag_result_flag_not_in_response( properties={ "$feature_flag": "missing-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/missing-flag": None, "$feature_flag_request_id": "test-request-id-456", @@ -583,7 +575,6 @@ def test_get_feature_flag_result_errors_computing_and_flag_missing( properties={ "$feature_flag": "missing-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/missing-flag": None, "$feature_flag_request_id": "test-request-id-999", @@ -608,7 +599,6 @@ def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR, @@ -634,7 +624,6 @@ def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.TIMEOUT, @@ -660,7 +649,6 @@ def test_get_feature_flag_result_connection_error(self, patch_capture, patch_fla properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR, @@ -686,7 +674,6 @@ def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.api_error(500), @@ -712,7 +699,6 @@ def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags) properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.QUOTA_LIMITED, @@ -783,7 +769,6 @@ def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_fla properties={ "$feature_flag": "my-flag", "$feature_flag_response": "cached-variant", - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": "cached-variant", "$feature_flag_payload": {"from": "cache"}, @@ -822,7 +807,6 @@ def test_connection_error_returns_stale_cached_value( properties={ "$feature_flag": "my-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": True, "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR, @@ -859,7 +843,6 @@ def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": "control", - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": "control", "$feature_flag_error": FeatureFlagError.api_error(503), @@ -890,7 +873,6 @@ def test_error_without_cache_returns_none(self, patch_capture, patch_flags): properties={ "$feature_flag": "my-flag", "$feature_flag_response": None, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/my-flag": None, "$feature_flag_error": FeatureFlagError.TIMEOUT, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 9d232736..47eba37b 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5605,7 +5605,6 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -5640,7 +5639,6 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -5679,7 +5677,6 @@ def test_capture_is_called(self, patch_flags, patch_capture): properties={ "$feature_flag": "flags-flag", "$feature_flag_response": "flags-value", - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag": "flags-value", }, @@ -5734,7 +5731,6 @@ def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture): properties={ "$feature_flag": "flags-flag", "$feature_flag_response": "flags-variant", - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag": "flags-variant", "$feature_flag_reason": "Matched condition set 1", @@ -5789,7 +5785,6 @@ def test_capture_is_called_with_flag_details_and_payload( properties={ "$feature_flag": "flags-flag-with-payload", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": False, "$feature/flags-flag-with-payload": True, "$feature_flag_reason": "Matched condition set 1", @@ -5984,7 +5979,6 @@ def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture): properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, @@ -6106,7 +6100,6 @@ def test_capture_multiple_users_doesnt_out_of_memory( properties={ "$feature_flag": "complex-flag", "$feature_flag_response": True, - "$feature_flag_has_experiment": False, "locally_evaluated": True, "$feature/complex-flag": True, }, diff --git a/posthog/types.py b/posthog/types.py index f08b627e..8724e4e3 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -79,15 +79,15 @@ class FlagMetadata: payload: Payload configured for the matched flag value, if any. version: Feature flag version. description: Feature flag description. - has_experiment: Whether the flag has a linked experiment. Defaults to ``False`` - when the server does not report the field (older deployments). + has_experiment: Whether the flag has a linked experiment. ``None`` when the + server does not report the field (older deployments). """ id: int payload: Optional[str] version: int description: str - has_experiment: bool = False + has_experiment: Optional[bool] = None @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: @@ -98,7 +98,7 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: payload=resp.get("payload"), version=resp.get("version", 0), description=resp.get("description", ""), - has_experiment=resp.get("has_experiment", False), + has_experiment=resp.get("has_experiment"), ) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index ed183a89..f7468453 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -762,7 +762,7 @@ attribute posthog.types.FeatureFlagResult.payload: Optional[Any] attribute posthog.types.FeatureFlagResult.reason: Optional[str] attribute posthog.types.FeatureFlagResult.variant: Optional[str] attribute posthog.types.FlagMetadata.description: str -attribute posthog.types.FlagMetadata.has_experiment: bool = False +attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None attribute posthog.types.FlagMetadata.id: int attribute posthog.types.FlagMetadata.payload: Optional[str] attribute posthog.types.FlagMetadata.version: int @@ -896,7 +896,7 @@ class posthog.request.QuotaLimitError class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata]) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: bool = False) +class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse