Skip to content
Closed
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/release-id-in-minimal-flag-called.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Keep `$release_id` on minimized `$feature_flag_called` events. The property is a static, low-cardinality runtime identity dimension, so the capture contract requires the minimal-event allowlist to retain it when the SDK stamped it from `POSTHOG_RELEASE_ID`.
4 changes: 4 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ def wrapper(self, *args, **kwargs):
"$os_distro",
"$python_runtime",
"$python_version",
# Deploy-time runtime identity, stamped from POSTHOG_RELEASE_ID. Static and
# low-cardinality like the platform keys above, and the capture contract
# requires a minimized event to retain it when the SDK stamped it.
"$release_id",
}
)

Expand Down
37 changes: 30 additions & 7 deletions posthog/test/test_feature_flag_called_minimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
to the full legacy shape.
"""

import os
import unittest
from unittest import mock

from parameterized import parameterized

from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client
from posthog.release_id import RELEASE_ID_ENV_VAR
from posthog.request import GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.utils import system_context
Expand Down Expand Up @@ -68,20 +70,28 @@ class _CapturedEventsMixin:
``before_send``, so tests assert the exact wire shape after every enrichment
step (system context, super properties, $lib, ...)."""

def _make_client(self, **kwargs):
def _make_client(self, release_id=None, **kwargs):
captured = []

def before_send(msg):
captured.append(msg)
return msg

client = Client(
FAKE_TEST_API_KEY,
send=False,
before_send=before_send,
super_properties={"app_version": "1.2.3"},
**kwargs,
# The release id is read once at construction, so pin the environment here
# rather than inheriting whatever the test runner happens to export.
env = (
{RELEASE_ID_ENV_VAR: release_id}
if release_id is not None
else {RELEASE_ID_ENV_VAR: ""}
)
with mock.patch.dict(os.environ, env, clear=False):
client = Client(
FAKE_TEST_API_KEY,
send=False,
before_send=before_send,
super_properties={"app_version": "1.2.3"},
**kwargs,
)
return client, captured

def _flag_called_properties(self, captured, index=0):
Expand Down Expand Up @@ -134,6 +144,19 @@ def test_gated_non_experiment_flag_sends_exactly_the_allowlist(self, patch_flags
self.assertEqual(properties["$feature_flag_reason"], "Matched condition set 1")
self.assertEqual(properties["$feature_flag_request_id"], "req-1")
self.assertEqual(properties["$feature_flag_evaluated_at"], 1640995200000)
self.assertNotIn("$release_id", properties)

@mock.patch("posthog.client.flags")
def test_gated_non_experiment_flag_keeps_the_release_id(self, patch_flags):
patch_flags.return_value = _flags_response(has_experiment=False, gate=True)
client, captured = self._make_client(release_id="rel-abc123")

client.get_feature_flag_result("person-flag", "some-distinct-id")

properties = self._flag_called_properties(captured)
self.assertEqual(properties["$release_id"], "rel-abc123")
self.assertLessEqual(set(properties), _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES)
self.assertNotIn("app_version", properties)

@mock.patch("posthog.client.flags")
def test_gated_non_experiment_flag_with_groups_keeps_group_context(
Expand Down
27 changes: 24 additions & 3 deletions posthog/test/test_release_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,30 @@ def test_release_id_is_read_once_at_client_init(self) -> None:
client.capture("python test event", distinct_id="user-1")
self.assertEqual(events[0]["properties"]["$release_id"], "at-init")

def test_minimal_flag_called_events_keep_their_strict_allowlist(self) -> None:
client, events = self._client("0198c1a2-release")
def test_minimal_flag_called_events_keep_the_release_id(self) -> None:
client, events = self._client(
"0198c1a2-release", super_properties={"app_version": "1.2.3"}
)
client._enqueue(
{
"event": "$feature_flag_called",
"distinct_id": "user-1",
"timestamp": None,
"properties": {"$feature_flag": "my-flag", "plan": "pro"},
},
None,
property_allowlist=_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES,
)
properties = events[0]["properties"]
self.assertEqual(properties["$feature_flag"], "my-flag")
self.assertEqual(properties["$release_id"], "0198c1a2-release")
# Still a strict allowlist: nothing outside it survives minimization.
self.assertLessEqual(set(properties), _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES)
self.assertNotIn("plan", properties)
self.assertNotIn("app_version", properties)

def test_minimal_flag_called_events_omit_an_unset_release_id(self) -> None:
client, events = self._client(None)
client._enqueue(
{
"event": "$feature_flag_called",
Expand All @@ -147,7 +169,6 @@ def test_minimal_flag_called_events_keep_their_strict_allowlist(self) -> None:
None,
property_allowlist=_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES,
)
self.assertEqual(events[0]["properties"]["$feature_flag"], "my-flag")
self.assertNotIn("$release_id", events[0]["properties"])


Expand Down
Loading