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/feature-flag-has-experiment-property.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

`$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.
32 changes: 32 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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")
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,
Expand All @@ -2372,6 +2385,7 @@ def _get_feature_flag_result(
evaluated_at,
flag_details,
feature_flag_error,
has_experiment,
)

return flag_result
Expand Down Expand Up @@ -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: Optional[bool] = None,
):
properties: dict[str, Any] = {
"$feature_flag": key,
Expand Down Expand Up @@ -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(
Expand All @@ -2696,13 +2712,18 @@ 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: 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
included so that group-scoped flags fire a separate event for each group a
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; 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 ()
Expand All @@ -2717,6 +2738,11 @@ def _capture_feature_flag_called_if_needed(
if feature_flag_reported_key in reported_flags:
return

# 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",
distinct_id=distinct_id,
Expand Down Expand Up @@ -3034,6 +3060,7 @@ def evaluate_flags(
version=None,
reason="Evaluated locally",
locally_evaluated=True,
has_experiment=flag_def.get("has_experiment"),
)
locally_evaluated_keys.add(key)

Expand Down Expand Up @@ -3096,6 +3123,11 @@ def evaluate_flags(
else None
),
locally_evaluated=False,
has_experiment=(
detail.metadata.has_experiment
if isinstance(detail.metadata, FlagMetadata)
else None
),
)
except QuotaLimitError as e:
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
Expand Down
4 changes: 4 additions & 0 deletions posthog/feature_flag_evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ``None`` when the server did not report it (older deployments).
has_experiment: Optional[bool] = None


@dataclass
Expand Down Expand Up @@ -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 None,
)
215 changes: 215 additions & 0 deletions posthog/test/test_feature_flag_has_experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""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. 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
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_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.assertNotIn("$feature_flag_has_experiment", properties)


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_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.assertNotIn("$feature_flag_has_experiment", properties)


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_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.assertNotIn("$feature_flag_has_experiment", properties)

@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)
4 changes: 4 additions & 0 deletions posthog/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. ``None`` when the
server does not report the field (older deployments).
"""

id: int
payload: Optional[str]
version: int
description: str
has_experiment: Optional[bool] = None

@classmethod
def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]:
Expand All @@ -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"),
)


Expand Down
3 changes: 2 additions & 1 deletion references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: Optional[bool] = None
attribute posthog.types.FlagMetadata.id: int
attribute posthog.types.FlagMetadata.payload: Optional[str]
attribute posthog.types.FlagMetadata.version: int
Expand Down Expand Up @@ -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: Optional[bool] = None)
class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str)
class posthog.types.FlagsAndPayloads
class posthog.types.FlagsResponse
Expand Down
Loading