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/release-id-env.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions posthog/release_id.py
Original file line number Diff line number Diff line change
@@ -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
199 changes: 199 additions & 0 deletions posthog/test/test_release_id.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading