From bc749e79804ab62a964c38d126261c5ad4d14012 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 16:06:37 +0100 Subject: [PATCH] feat: add a per-read send_feature_flag_events option to snapshot reads --- .../changesets/snapshot-flag-event-option.md | 5 + posthog/feature_flag_evaluations.py | 29 ++++- posthog/test/test_evaluate_flags.py | 120 ++++++++++++++++++ references/public_api_snapshot.txt | 4 +- 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 .sampo/changesets/snapshot-flag-event-option.md diff --git a/.sampo/changesets/snapshot-flag-event-option.md b/.sampo/changesets/snapshot-flag-event-option.md new file mode 100644 index 00000000..9668d431 --- /dev/null +++ b/.sampo/changesets/snapshot-flag-event-option.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a per-read `send_feature_flag_events` option to `FeatureFlagEvaluations.is_enabled()` and `get_flag()`. Pass `send_feature_flag_events=False` to read a flag from an `evaluate_flags()` snapshot without sending `$feature_flag_called`. The read leaves the dedupe cache alone, so a later read that sends events still reports the flag, and the key still counts as accessed for `only_accessed()`. The option defaults to `True`, as on `get_feature_flag()`. diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 13d8c7df..ef4859b4 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -95,7 +95,13 @@ def __init__( unresolved_flags or {} ) - def is_enabled(self, key: str, default_value: bool = False) -> bool: + def is_enabled( + self, + key: str, + default_value: bool = False, + *, + send_feature_flag_events: bool = True, + ) -> bool: """Return whether the flag is enabled. Fires ``$feature_flag_called`` on the first access per (distinct_id, flag, value) tuple, deduped via the SDK's cache. @@ -105,23 +111,31 @@ def is_enabled(self, key: str, default_value: bool = False) -> bool: the key was not part of the evaluated set, or the evaluation itself came back empty (failed ``/flags`` request, quota limit, or no resolvable ``distinct_id``). Defaults to ``False``. + send_feature_flag_events: Whether this read sends ``$feature_flag_called``. + When ``False``, no event is sent and the dedupe cache is left alone, + but the key still counts as accessed for :meth:`only_accessed`. A flag that has a value always wins over ``default_value``, including a disabled flag, which returns ``False`` even when ``default_value=True``. """ flag = self._flags.get(key) - self._record_access(key) + self._record_access(key, send_feature_flag_events) return bool(flag.enabled) if flag else default_value - def get_flag(self, key: str) -> Optional[FlagValue]: + def get_flag( + self, key: str, *, send_feature_flag_events: bool = True + ) -> Optional[FlagValue]: """Return the flag value. Fires ``$feature_flag_called`` on first access. Returns the variant string for multivariate flags, ``True`` for enabled flags without a variant, ``False`` for disabled flags, and ``None`` for flags that were not returned by the evaluation. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. """ flag = self._flags.get(key) - self._record_access(key) + self._record_access(key, send_feature_flag_events) if not flag: return None if not flag.enabled: @@ -236,9 +250,14 @@ def _clone_with( accessed=set(self._accessed), ) - def _record_access(self, key: str) -> None: + def _record_access(self, key: str, send_feature_flag_events: bool) -> None: self._accessed.add(key) + # A suppressed read skips the dedupe cache too, so a later read that sends + # events still reports this flag. + if not send_feature_flag_events: + return + # Empty snapshots (no resolvable distinct_id) are returned by ``evaluate_flags()`` # as a safety fallback. Firing $feature_flag_called for them would emit events # with an empty distinct_id, polluting analytics — short-circuit here. diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 25ca18e5..dec98a76 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1301,6 +1301,126 @@ def test_filtered_snapshots_do_not_back_propagate_access(self, patch_flags): self.assertEqual(flags.only_accessed().keys, ["boolean-flag"]) +class TestEvaluateFlagsPerReadEventOption(unittest.TestCase): + """Per-read ``send_feature_flag_events`` on snapshot reads (sdk-specs + evaluate-flags "Snapshot feature flag event control").""" + + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @staticmethod + def _flag_called(patch_capture): + return [ + (c[1]["properties"]["$feature_flag"], c[1]["properties"]) + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + ] + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_option_false_silences_one_read(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("boolean-flag", send_feature_flag_events=False) + value = flags.get_flag("variant-flag") + + self.assertEqual((enabled, value), (True, "variant-value")) + self.assertEqual(patch_flags.call_count, 1) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["variant-flag"]) + self.assertEqual(events[0][1]["$feature_flag_response"], "variant-value") + self.assertEqual(patch_capture.call_args[1]["distinct_id"], "user-1") + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_option_true_sends_events(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + flags.is_enabled("boolean-flag", send_feature_flag_events=True) + flags.get_flag("variant-flag", send_feature_flag_events=True) + + self.assertEqual( + [key for key, _ in self._flag_called(patch_capture)], + ["boolean-flag", "variant-flag"], + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_read_leaves_dedupe_untouched(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + results = [ + flags.is_enabled("boolean-flag", send_feature_flag_events=False), + flags.get_flag("boolean-flag"), + flags.is_enabled("boolean-flag"), + ] + + self.assertEqual(results, [True, True, True]) + self.assertEqual(patch_flags.call_count, 1) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["boolean-flag"]) + self.assertIs(events[0][1]["$feature_flag_response"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_read_leaves_single_flag_getter_exposure_intact( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + snapshot_value = flags.is_enabled( + "boolean-flag", send_feature_flag_events=False + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + getter_value = self.client.get_feature_flag("boolean-flag", "user-1") + + self.assertEqual((snapshot_value, getter_value), (True, True)) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["boolean-flag"]) + self.assertIs(events[0][1]["$feature_flag_response"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_reads_still_count_for_only_accessed( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("boolean-flag", send_feature_flag_events=False) + value = flags.get_flag("disabled-flag", send_feature_flag_events=False) + + self.assertEqual((enabled, value), (True, False)) + self.assertEqual( + sorted(flags.only_accessed().keys), ["boolean-flag", "disabled-flag"] + ) + self.assertEqual(patch_flags.call_count, 1) + self.assertEqual(self._flag_called(patch_capture), []) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_missing_key_reports_no_missing_flag( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("missing-flag", send_feature_flag_events=False) + value = flags.get_flag("missing-flag", send_feature_flag_events=False) + + self.assertEqual((enabled, value), (False, None)) + self.assertEqual(patch_flags.call_count, 1) + self.assertEqual(self._flag_called(patch_capture), []) + + class TestCaptureWithFlagsSnapshot(unittest.TestCase): def setUp(self): self.client = Client(FAKE_TEST_API_KEY) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 63ce6a50..fda69c51 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1804,9 +1804,9 @@ method posthog.exception_utils.AnnotatedValue.substituted_because_contains_sensi method posthog.exception_utils.VariableSizeLimiter.add(size) method posthog.exception_utils.VariableSizeLimiter.can_add(size) method posthog.exception_utils.VariableSizeLimiter.get_remaining_space() -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str) -> Optional[FlagValue] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str, *, send_feature_flag_events: bool = True) -> Optional[FlagValue] method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag_payload(key: str) -> Optional[Any] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False) -> bool +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False, *, send_feature_flag_events: bool = True) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only(keys: List[str]) -> FeatureFlagEvaluations method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only_accessed() -> FeatureFlagEvaluations method posthog.flag_definition_cache.FlagDefinitionCacheProvider.get_flag_definitions() -> Union[Optional[FlagDefinitionCacheData], Awaitable[Optional[FlagDefinitionCacheData]]]