Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/snapshot-flag-event-option.md
Original file line number Diff line number Diff line change
@@ -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()`.
29 changes: 24 additions & 5 deletions posthog/feature_flag_evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
120 changes: 120 additions & 0 deletions posthog/test/test_evaluate_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]]]
Expand Down
Loading