From dea970969022e0722540651d5117b3310be6d11f Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 21 Sep 2026 15:22:06 +0200 Subject: [PATCH] feat: send $release_id from POSTHOG_RELEASE_ID on every event A Python app has no bundle to inject a release id into, so the SDK reads the id that `posthog-cli release resolve` prints from the POSTHOG_RELEASE_ID env var and sends it as `$release_id` on every event. On `$exception` events the server resolves the release by a direct id lookup. An explicit `$release_id` in the event properties or super_properties wins over the env var. Minimal `$feature_flag_called` events keep their strict cross-SDK property allowlist and do not carry the id. Co-Authored-By: Claude Fable 5.1 --- .sampo/changesets/release-id-env.md | 5 + posthog/async_client.py | 4 + posthog/client.py | 9 ++ posthog/release_id.py | 26 ++++ posthog/test/test_release_id.py | 199 ++++++++++++++++++++++++++++ references/public_api_snapshot.txt | 2 + 6 files changed, 245 insertions(+) create mode 100644 .sampo/changesets/release-id-env.md create mode 100644 posthog/release_id.py create mode 100644 posthog/test/test_release_id.py diff --git a/.sampo/changesets/release-id-env.md b/.sampo/changesets/release-id-env.md new file mode 100644 index 000000000..807452275 --- /dev/null +++ b/.sampo/changesets/release-id-env.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Read the release id from the `POSTHOG_RELEASE_ID` environment variable and send it as `$release_id` on every event. On `$exception` events, error tracking uses it to link the exception to its release by a direct id lookup. Create the release and get its id with `posthog-cli release resolve`. An explicit `$release_id` in the event properties or in `super_properties` wins over the environment variable. Minimal `$feature_flag_called` events keep their strict property allowlist and do not carry it. diff --git a/posthog/async_client.py b/posthog/async_client.py index 908aff17b..3f429e81a 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -75,6 +75,7 @@ _EvaluatedFlagRecord, _FeatureFlagEvaluationsHost, ) +from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host from .types import FlagMetadata, FlagValue, normalize_flags_response from .utils import SizeLimitedDict, _normalize_timestamp, clean, system_context @@ -148,6 +149,7 @@ def __init__( self.is_server = is_server self.historical_migration = historical_migration self.super_properties = super_properties + self._release_id = _resolve_release_id() self.capture_mode = _resolve_capture_mode(capture_mode) self.capture_compression = _resolve_capture_compression( capture_compression, gzip_fallback=gzip @@ -431,6 +433,8 @@ def _prepare_event( properties["$geoip_disable"] = True if self.super_properties: msg["properties"] = {**properties, **self.super_properties} + if self._release_id is not None: + msg["properties"].setdefault("$release_id", self._release_id) if self.is_server: msg["properties"]["$is_server"] = True if property_allowlist is not None: diff --git a/posthog/client.py b/posthog/client.py index 857bd743e..c888e28e5 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -82,6 +82,7 @@ FlagDefinitionCacheProvider, ) from posthog.poller import Poller +from posthog.release_id import _resolve_release_id from posthog.request import ( AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT, @@ -961,6 +962,9 @@ def __init__( capture_compression, gzip_fallback=gzip ) self.super_properties = super_properties + # Release id from POSTHOG_RELEASE_ID, attached to every event. Resolved + # here so the env var is read once per client. + self._release_id = _resolve_release_id() self.enable_exception_autocapture = enable_exception_autocapture self.log_captured_exceptions = log_captured_exceptions self.enable_exception_autocapture_rate_limiting = ( @@ -2355,6 +2359,11 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): if self.super_properties: msg["properties"] = {**msg["properties"], **self.super_properties} + # Set after the super_properties merge so an explicit `$release_id` from + # the caller's properties or the super properties wins over the env var. + if self._release_id is not None: + msg["properties"].setdefault("$release_id", self._release_id) + # Set after the super_properties merge so this SDK's server classification # can't be silently overridden by a user-provided super property. if self.is_server: diff --git a/posthog/release_id.py b/posthog/release_id.py new file mode 100644 index 000000000..a9a58eaf8 --- /dev/null +++ b/posthog/release_id.py @@ -0,0 +1,26 @@ +import os +from typing import Optional + +__all__ = ["RELEASE_ID_ENV_VAR"] + +RELEASE_ID_ENV_VAR = "POSTHOG_RELEASE_ID" + + +def _resolve_release_id() -> Optional[str]: + """Resolve the release id reported as ``$release_id`` on every event. + + This is the deploy-time counterpart to injecting ``$release_id`` into a web + bundle. A Python app has no bundle, so a build tool creates the release with + ``posthog-cli release resolve`` and launches the app with the printed id in + ``POSTHOG_RELEASE_ID``. On ``$exception`` events the server then resolves the + release by a direct id lookup, so no release name or version has to match + anything the app reports. On other events the id is a plain property that + ties the event to the release that produced it. + + The value is trimmed and a blank value is treated as unset, so + ``POSTHOG_RELEASE_ID=`` (or whitespace) never sends an empty ``$release_id``. + """ + raw = os.environ.get(RELEASE_ID_ENV_VAR) + if raw is None: + return None + return raw.strip() or None diff --git a/posthog/test/test_release_id.py b/posthog/test/test_release_id.py new file mode 100644 index 000000000..12b8e7e24 --- /dev/null +++ b/posthog/test/test_release_id.py @@ -0,0 +1,199 @@ +import contextlib +import os +import unittest +from unittest import mock + +import pytest +from parameterized import parameterized + +from posthog import AsyncPosthog +from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client +from posthog.release_id import RELEASE_ID_ENV_VAR, _resolve_release_id +from posthog.test.test_utils import FAKE_TEST_API_KEY + +# (name, call, expected event): one row per public event-producing method, shared +# by the sync and async clients. Each call builds its own arguments, because a +# captured exception is marked and a second capture of the same object is skipped. +EVENT_CALLS = [ + ( + "capture", + lambda client: client.capture("python test event", distinct_id="user-1"), + "python test event", + ), + ( + "capture_exception", + lambda client: client.capture_exception(Exception("boom")), + "$exception", + ), + ( + "set", + lambda client: client.set(distinct_id="user-1", properties={"plan": "pro"}), + "$set", + ), + ( + "set_once", + lambda client: client.set_once( + distinct_id="user-1", properties={"first_seen": True} + ), + "$set_once", + ), + ( + "alias", + lambda client: client.alias(previous_id="anon-1", distinct_id="user-1"), + "$create_alias", + ), + ( + "group_identify", + lambda client: client.group_identify( + group_type="company", group_key="company-1" + ), + "$groupidentify", + ), +] + + +@contextlib.contextmanager +def _release_id_env(value): + """Set POSTHOG_RELEASE_ID to `value` (unset when None) for the block.""" + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop(RELEASE_ID_ENV_VAR, None) + if value is not None: + os.environ[RELEASE_ID_ENV_VAR] = value + yield + + +class TestResolveReleaseId(unittest.TestCase): + @parameterized.expand( + [ + ("unset", None, None), + ("set", "0198c1a2-release", "0198c1a2-release"), + ("padded", " 0198c1a2-release\n", "0198c1a2-release"), + ("empty", "", None), + ("whitespace", " ", None), + ] + ) + def test_env_var_resolution(self, _name, env_value, expected) -> None: + with _release_id_env(env_value): + self.assertEqual(_resolve_release_id(), expected) + + +class TestClientReleaseId(unittest.TestCase): + def _client(self, env_value, **kwargs): + """Build a client under `env_value` and collect the events it would send.""" + events = [] + + def before_send(msg): + events.append(msg) + return msg + + with _release_id_env(env_value): + client = Client( + FAKE_TEST_API_KEY, send=False, before_send=before_send, **kwargs + ) + return client, events + + @parameterized.expand(EVENT_CALLS) + def test_release_id_is_attached_to_every_event( + self, _name, call, expected_event + ) -> None: + client, events = self._client("0198c1a2-release") + call(client) + + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["event"], expected_event) + self.assertEqual(events[0]["properties"]["$release_id"], "0198c1a2-release") + + @parameterized.expand([("unset", None), ("blank", " ")]) + def test_no_release_id_is_sent_without_a_value(self, _name, env_value) -> None: + client, events = self._client(env_value) + client.capture("python test event", distinct_id="user-1") + client.capture_exception(Exception("boom")) + + self.assertEqual(len(events), 2) + for event in events: + self.assertNotIn("$release_id", event["properties"]) + + def test_explicit_release_id_property_wins_over_the_env_var(self) -> None: + client, events = self._client("from-env") + client.capture( + "python test event", + distinct_id="user-1", + properties={"$release_id": "from-caller"}, + ) + self.assertEqual(events[0]["properties"]["$release_id"], "from-caller") + + def test_super_property_release_id_wins_over_the_env_var(self) -> None: + client, events = self._client( + "from-env", super_properties={"$release_id": "from-super"} + ) + client.capture("python test event", distinct_id="user-1") + self.assertEqual(events[0]["properties"]["$release_id"], "from-super") + + def test_release_id_is_read_once_at_client_init(self) -> None: + client, events = self._client("at-init") + with _release_id_env("changed-later"): + 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") + client._enqueue( + { + "event": "$feature_flag_called", + "distinct_id": "user-1", + "timestamp": None, + "properties": {"$feature_flag": "my-flag"}, + }, + None, + property_allowlist=_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, + ) + self.assertEqual(events[0]["properties"]["$feature_flag"], "my-flag") + self.assertNotIn("$release_id", events[0]["properties"]) + + +async def _async_events(env_value, send_events): + """Build an async client under `env_value`, run `send_events`, return the batch.""" + batches = [] + + async def batch_post(*args, **kwargs): + batches.append(kwargs["batch"]) + + with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with _release_id_env(env_value): + client = AsyncPosthog("test-key", flush_interval=30) + async with client: + send_events(client) + await client.flush(timeout_seconds=1) + return [event for batch in batches for event in batch] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("call", "expected_event"), + [pytest.param(call, event, id=name) for name, call, event in EVENT_CALLS], +) +async def test_async_client_attaches_release_id_to_every_event(call, expected_event): + events = await _async_events("0198c1a2-release", call) + + assert len(events) == 1 + assert events[0]["event"] == expected_event + assert events[0]["properties"]["$release_id"] == "0198c1a2-release" + + +@pytest.mark.asyncio +async def test_async_client_sends_no_release_id_without_a_value(): + events = await _async_events( + None, lambda client: client.capture("event", distinct_id="user-1") + ) + assert "$release_id" not in events[0]["properties"] + + +@pytest.mark.asyncio +async def test_async_client_explicit_release_id_property_wins_over_the_env_var(): + events = await _async_events( + "from-env", + lambda client: client.capture( + "event", distinct_id="user-1", properties={"$release_id": "from-caller"} + ), + ) + assert events[0]["properties"]["$release_id"] == "from-caller" diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 3eb20c41f..6f1beae41 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -869,6 +869,7 @@ attribute posthog.poller.Poller.stopped = threading.Event() attribute posthog.privacy_mode = False attribute posthog.project_api_key = None attribute posthog.project_root = None +attribute posthog.release_id.RELEASE_ID_ENV_VAR = 'POSTHOG_RELEASE_ID' attribute posthog.request.AI_EVENTS_ENDPOINT = '/i/v0/ai/batch/' attribute posthog.request.APIError.message = message attribute posthog.request.APIError.retry_after = retry_after @@ -1580,6 +1581,7 @@ module posthog.mcp.types module posthog.mcp.version module posthog.metrics_capture module posthog.poller +module posthog.release_id module posthog.request module posthog.tracing module posthog.tracing.span