From 86c1781a8f6c0f045730d78a0d1937a4203c2792 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:27 +0000 Subject: [PATCH] fix(capture): keep $release_id on minimized flag-called events The capture contract classifies $release_id as a static, low-cardinality runtime identity property in the sense of the feature-flag-called-tracker minimal-event allowlist, so a minimized $feature_flag_called event has to retain it when the SDK stamped it from POSTHOG_RELEASE_ID. The allowlist filter runs after the release-id stamp, so the property was being stripped. Co-Authored-By: Claude Opus 5 Generated-By: PostHog Desktop Task-Id: 3344ee8a-c997-4953-ac4c-01cddb3ebb96 --- .../release-id-in-minimal-flag-called.md | 5 +++ posthog/client.py | 4 ++ .../test_feature_flag_called_minimization.py | 37 +++++++++++++++---- posthog/test/test_release_id.py | 27 ++++++++++++-- 4 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 .sampo/changesets/release-id-in-minimal-flag-called.md diff --git a/.sampo/changesets/release-id-in-minimal-flag-called.md b/.sampo/changesets/release-id-in-minimal-flag-called.md new file mode 100644 index 000000000..6b23984ac --- /dev/null +++ b/.sampo/changesets/release-id-in-minimal-flag-called.md @@ -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`. diff --git a/posthog/client.py b/posthog/client.py index d74ea56e3..098d5c870 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -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", } ) diff --git a/posthog/test/test_feature_flag_called_minimization.py b/posthog/test/test_feature_flag_called_minimization.py index c32b4ea27..d092f8543 100644 --- a/posthog/test/test_feature_flag_called_minimization.py +++ b/posthog/test/test_feature_flag_called_minimization.py @@ -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 @@ -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): @@ -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( diff --git a/posthog/test/test_release_id.py b/posthog/test/test_release_id.py index 12b8e7e24..ddaddda40 100644 --- a/posthog/test/test_release_id.py +++ b/posthog/test/test_release_id.py @@ -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", @@ -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"])