From 64453ac3a0416eb2d980cda73267fe3d2ef6253d Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 6 Oct 2026 18:39:27 -0700 Subject: [PATCH 1/5] feat!: super_options, context options and personless as the lowest option layer --- posthog/__init__.py | 51 ++++++++++- posthog/async_client.py | 41 ++++++--- posthog/client.py | 83 ++++++++++++++--- posthog/contexts.py | 55 ++++++++++++ posthog/test/test_client.py | 4 +- posthog/test/test_contexts.py | 22 +++++ posthog/test/test_event_options.py | 137 +++++++++++++++++++++++++++-- posthog/test/test_module.py | 14 ++- references/public_api_snapshot.txt | 18 +++- 9 files changed, 390 insertions(+), 35 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index f7ed8e522..a0a697551 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -52,6 +52,12 @@ from posthog.contexts import ( get_tags as inner_get_tags, ) +from posthog.contexts import ( + set_context_option as inner_set_context_option, +) +from posthog.contexts import ( + get_context_options as inner_get_context_options, +) from posthog.exception_utils import ( DEFAULT_CODE_VARIABLES_DETECT_SECRETS, DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS, @@ -299,6 +305,43 @@ def get_tags() -> Dict[str, Any]: return inner_get_tags() +def set_context_option(key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Context options override ``super_options``, and an event's own ``options`` + override context options. + + Args: + key: The option name, such as ``"process_person_profile"`` + value: The option value, sent as given + + Examples: + ```python + from posthog import new_context, set_context_option + with new_context(): + set_context_option("process_person_profile", False) + ``` + + Category: + Contexts + """ + return inner_set_context_option(key, value) + + +def get_context_options() -> Dict[str, Any]: + """ + Get all capture options from the current context. + + Returns: + Dict of all capture options in the current context + + Category: + Contexts + """ + return inner_get_context_options() + + """Settings. These module-level settings configure the legacy global PostHog client used by @@ -340,7 +383,11 @@ def get_tags() -> Dict[str, Any]: feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties merged into every captured event. + super_properties: Properties for every captured event. Context tags and + an event's own properties override them. + super_options: Capture options for every captured event, such as + ``{"cookieless_mode": True}``. Context options and an event's own + ``options`` override them. metrics: Config dict for the ``client.metrics`` API (``service_name``, ``service_version``, ``environment``, ``flush_interval``, ...). Applied when ``setup()`` builds the global client, or on a later ``setup()`` @@ -414,6 +461,7 @@ def get_tags() -> Dict[str, Any]: feature_flags_request_timeout_seconds = 3 # type: int feature_flags_request_max_retries = 1 # type: int super_properties = None # type: Optional[Dict] +super_options = None # type: Optional[Dict] metrics = None # type: Optional[Dict] traces = None # type: Optional[Dict] enable_exception_autocapture = False # type: bool @@ -1406,6 +1454,7 @@ def setup() -> Client: feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds, feature_flags_request_max_retries=feature_flags_request_max_retries, super_properties=super_properties, + super_options=super_options, metrics=metrics, traces=traces, # TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK) diff --git a/posthog/async_client.py b/posthog/async_client.py index 5597d79c2..4d5945241 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -43,6 +43,7 @@ Client as _SyncClient, _metadata_has_experiment, _parse_flag_payload, + _personless_options, add_context_tags as _add_context_tags, get_identity_state as _get_identity_state, stringify_id as _stringify_id, @@ -55,6 +56,7 @@ get_code_variables_mask_url_credentials_context, get_context_device_id as _get_context_device_id, get_context_distinct_id as _get_context_distinct_id, + get_context_options as _get_context_options, get_context_session_id as _get_context_session_id, ) from .exception_utils import ( @@ -120,6 +122,7 @@ def __init__( is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, + super_options: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, @@ -154,6 +157,7 @@ def __init__( self.is_server = is_server self.historical_migration = historical_migration self.super_properties = super_properties + self.super_options = super_options self._release_id = _resolve_release_id() self.capture_compression = _resolve_capture_compression(capture_compression) self.capture_trace_context = capture_trace_context @@ -397,6 +401,7 @@ def _prepare_event( msg: dict[str, Any], disable_geoip: Optional[bool], property_allowlist=None, + derived_options: Optional[dict[str, Any]] = None, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: if self.disabled or not self._accepting: return None, None @@ -423,7 +428,14 @@ def _prepare_event( if disable_geoip: properties["$geoip_disable"] = True if self.super_properties: - msg["properties"] = {**properties, **self.super_properties} + msg["properties"] = {**self.super_properties, **properties} + # Each layer overrides the one before it: values the SDK derives, such + # as personless, then super options, then context and event options. + msg["options"] = { + **(derived_options or {}), + **_event_options(self.super_options), + **(msg.get("options") or {}), + } if self._release_id is not None: msg["properties"].setdefault("$release_id", self._release_id) if self.is_server: @@ -460,7 +472,7 @@ async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: def _build_capture_event( self, event: str, kwargs: OptionalCaptureArgs - ) -> tuple[dict[str, Any], Optional[bool], Any]: + ) -> tuple[dict[str, Any], Optional[bool], Any, dict[str, Any]]: properties = {**(kwargs.get("properties") or {}), **system_context()} if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} @@ -468,8 +480,6 @@ def _build_capture_event( assert properties is not None distinct_id, personless = _get_identity_state(kwargs.get("distinct_id")) - if personless and "$process_person_profile" not in properties: - properties["$process_person_profile"] = False groups = kwargs.get("groups") if groups: properties["$groups"] = groups @@ -493,10 +503,14 @@ def _build_capture_event( "distinct_id": distinct_id, "event": event, "uuid": kwargs.get("uuid"), - "options": _event_options(kwargs.get("options")), + "options": { + **_get_context_options(), + **_event_options(kwargs.get("options")), + }, }, kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), + _personless_options(personless), ) def capture( @@ -504,11 +518,11 @@ def capture( ) -> Optional[str]: """Queue an event without blocking for network delivery.""" try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs + msg, disable_geoip, property_allowlist, derived_options = ( + self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist + msg, disable_geoip, property_allowlist, derived_options ) if prepared is None or sent_uuid is None: return None @@ -551,11 +565,11 @@ async def capture_immediate( self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1 error_batch: list[dict[str, Any]] = [] try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs + msg, disable_geoip, property_allowlist, derived_options = ( + self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist + msg, disable_geoip, property_allowlist, derived_options ) if prepared is None or sent_uuid is None: return None @@ -612,7 +626,10 @@ def _build_person_properties_event( property_key: properties, "event": event, "uuid": kwargs.get("uuid"), - "options": _event_options(kwargs.get("options")), + "options": { + **_get_context_options(), + **_event_options(kwargs.get("options")), + }, } def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: diff --git a/posthog/client.py b/posthog/client.py index 2825f4bfe..63685d949 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -52,11 +52,13 @@ get_context_device_id, get_context_distinct_id, get_context_session_id, + get_context_options as _context_get_context_options, get_tags as _context_get_tags, identify_context as _context_identify_context, _scoped as _context_scoped, new_context, set_context_device_id as _context_set_context_device_id, + set_context_option as _context_set_context_option, set_context_session as _context_set_context_session, tag as _context_tag, ) @@ -276,6 +278,11 @@ def _stringify_event_uuid(value) -> str: return canonical +def _personless_options(personless: bool) -> dict[str, Any]: + """The lowest option layer: no person profile for a generated distinct ID.""" + return {"process_person_profile": False} if personless else {} + + def add_context_tags(properties): properties = properties or {} current_context = _get_current_context() @@ -712,6 +719,7 @@ def __init__( feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, + super_options=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, @@ -797,7 +805,11 @@ def __init__( feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties merged into every captured event. + super_properties: Properties for every captured event. Context + tags and an event's own properties override them. + super_options: Capture options for every captured event, such as + ``{"cookieless_mode": True}``. Context options and an event's + own ``options`` override them. enable_exception_autocapture: Automatically capture uncaught exceptions. log_captured_exceptions: Also log exceptions captured by error @@ -1005,6 +1017,7 @@ def __init__( maximum=AI_MAX_MSG_SIZE, ) self.super_properties = super_properties + self.super_options = super_options # 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() @@ -1315,6 +1328,31 @@ def get_tags(self) -> Dict[str, Any]: """ return _context_get_tags() + def set_context_option(self, key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Args: + key: The option name, such as ``"process_person_profile"``. + value: The option value, sent as given. + + Category: + Contexts + """ + _context_set_context_option(key, value) + + def get_context_options(self) -> Dict[str, Any]: + """ + Get all capture options from the current context. + + Returns: + Dict of all capture options in the current context. + + Category: + Contexts + """ + return _context_get_context_options() + def identify_context(self, distinct_id: str) -> None: """ Identify the current context with a distinct ID. @@ -1711,7 +1749,10 @@ def _capture( flags_snapshot = kwargs.get("flags", None) send_feature_flags = kwargs.get("send_feature_flags", False) disable_geoip = kwargs.get("disable_geoip", None) - options = _event_options(kwargs.get("options", None)) + options = { + **_context_get_context_options(), + **_event_options(kwargs.get("options", None)), + } # Internal, set for minimal $feature_flag_called events: a strict allowlist # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) @@ -1726,9 +1767,6 @@ def _capture( (distinct_id, personless) = get_identity_state(distinct_id) - if personless and "$process_person_profile" not in properties: - properties["$process_person_profile"] = False - msg = { "properties": properties, "timestamp": timestamp, @@ -1833,7 +1871,11 @@ def _capture( msg["properties"] = properties return self._enqueue( - msg, disable_geoip, lane, property_allowlist=property_allowlist + msg, + disable_geoip, + lane, + property_allowlist=property_allowlist, + derived_options=_personless_options(personless), ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -1921,7 +1963,10 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set": properties, "event": "$set", "uuid": uuid, - "options": _event_options(kwargs.get("options", None)), + "options": { + **_context_get_context_options(), + **_event_options(kwargs.get("options", None)), + }, } return self._enqueue(msg, disable_geoip) @@ -1971,7 +2016,10 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set_once": properties, "event": "$set_once", "uuid": uuid, - "options": _event_options(kwargs.get("options", None)), + "options": { + **_context_get_context_options(), + **_event_options(kwargs.get("options", None)), + }, } return self._enqueue(msg, disable_geoip) @@ -2412,7 +2460,14 @@ def _report_capture_failure( finally: _on_error_state.active = False - def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): + def _enqueue( + self, + msg, + disable_geoip, + lane=None, + property_allowlist=None, + derived_options=None, + ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2449,7 +2504,15 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): msg["properties"]["$geoip_disable"] = True if self.super_properties: - msg["properties"] = {**msg["properties"], **self.super_properties} + msg["properties"] = {**self.super_properties, **msg["properties"]} + + # Each layer overrides the one before it: values the SDK derives, such + # as personless, then super options, then context and event options. + msg["options"] = { + **(derived_options or {}), + **_event_options(self.super_options), + **(msg.get("options") or {}), + } # 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. diff --git a/posthog/contexts.py b/posthog/contexts.py index f897d1d96..59c4b2bf5 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -28,6 +28,7 @@ def __init__( self.distinct_id: Optional[str] = None self.device_id: Optional[str] = None self.tags: Dict[str, Any] = {} + self.options: Dict[str, Any] = {} self.capture_exception_code_variables: Optional[bool] = None self.code_variables_mask_patterns: Optional[list] = None self.code_variables_ignore_patterns: Optional[list] = None @@ -46,6 +47,9 @@ def set_device_id(self, device_id: str): def add_tag(self, key: str, value: Any): self.tags[key] = value + def add_option(self, key: str, value: Any): + self.options[key] = value + def set_capture_exception_code_variables(self, enabled: bool): self.capture_exception_code_variables = enabled @@ -94,6 +98,13 @@ def collect_tags(self) -> Dict[str, Any]: return tags return self.tags.copy() + def collect_options(self) -> Dict[str, Any]: + if self.parent and not self.fresh: + options = self.parent.collect_options() + options.update(self.options) + return options + return self.options.copy() + def get_capture_exception_code_variables(self) -> Optional[bool]: if self.capture_exception_code_variables is not None: return self.capture_exception_code_variables @@ -288,6 +299,50 @@ def get_tags() -> Dict[str, Any]: return {} +def set_context_option(key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Context options override the client's ``super_options``. An event's own + ``options`` override context options. Child contexts inherit them unless + they are fresh. + + Args: + key: The option name, such as ``"process_person_profile"`` + value: The option value, sent as given + + Example: + ```python + with posthog.new_context(): + posthog.set_context_option("process_person_profile", False) + posthog.capture("health_check") + ``` + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + current_context.add_option(key, value) + + +def get_context_options() -> Dict[str, Any]: + """ + Get all capture options from the current context. Note, modifying + the returned dictionary will not affect the current context. + + Returns: + Dict of all capture options in the current context + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + return current_context.collect_options() + return {} + + def identify_context(distinct_id: str) -> None: """ Identify the current context with a distinct ID, associating all events captured in this or diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 37d862b5c..3f6052b81 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -2390,10 +2390,10 @@ def test_session_id_with_different_event_types( [ # test_name, super_properties, event_session_id, expected_session_id, expected_super_props ( - "super_properties_override_session_id", + "event_session_id_overrides_super_properties", {"$session_id": "super-session", "source": "test"}, "event-session-808", - "super-session", + "event-session-808", {"source": "test"}, ), ( diff --git a/posthog/test/test_contexts.py b/posthog/test/test_contexts.py index e75a6610d..73a1ab466 100644 --- a/posthog/test/test_contexts.py +++ b/posthog/test/test_contexts.py @@ -7,7 +7,9 @@ import posthog from posthog.client import Client from posthog.contexts import ( + get_context_options, get_tags, + set_context_option, new_context, scoped, tag, @@ -384,6 +386,26 @@ def context_state(): ), ) + def test_context_options_inherit_like_tags(self): + with new_context(fresh=True): + set_context_option("cookieless_mode", True) + set_context_option("process_person_profile", False) + + with new_context(fresh=False): + set_context_option("process_person_profile", True) + assert get_context_options() == { + "cookieless_mode": True, + "process_person_profile": True, + } + + with new_context(fresh=True): + assert get_context_options() == {} + + assert get_context_options() == { + "cookieless_mode": True, + "process_person_profile": False, + } + def test_child_tags_override_parent_tags_in_non_fresh_context(self): with new_context(fresh=True): tag("shared_key", "parent_value") diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index 33e6c3a50..cdc3b8ab2 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -2,7 +2,7 @@ import pytest -from posthog import AsyncPosthog +from posthog import AsyncPosthog, new_context, set_context_option, tag from posthog.capture_event import _to_v1_event from posthog.client import Client from posthog.test.capture_helpers import ( @@ -34,21 +34,25 @@ ASYNC_CAPTURE_CALLS = {k: v for k, v in CAPTURE_CALLS.items() if k != "capture_ai"} -def _sync_wire_events(call, before_send=None) -> list[dict]: +def _sync_wire_events(call, before_send=None, **config) -> list[dict]: with patch_capture_send("client") as send: - client = Client(FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send) + client = Client( + FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send, **config + ) assert call(client) is not None return [_to_v1_event(msg) for msg in sent_events(send)] -async def _async_wire_events(call, before_send=None) -> list[dict]: +async def _async_wire_events(call, before_send=None, **config) -> list[dict]: batches: list[list[dict]] = [] async def send_batch(api_key, host, batch, **kwargs): batches.append(batch) with patch_async_capture_send(side_effect=send_batch): - async with AsyncPosthog("test-key", before_send=before_send) as client: + async with AsyncPosthog( + "test-key", before_send=before_send, **config + ) as client: assert call(client) is not None await client.flush(timeout_seconds=1) return [_to_v1_event(msg) for batch in batches for msg in batch] @@ -109,3 +113,126 @@ async def test_async_non_dict_options_are_logged_and_event_is_sent(caplog): ) assert events[0]["options"] == {} assert "options must be a dict" in caplog.text + + +PP = "process_person_profile" + +# Layers, lowest first: personless (no distinct_id), super, context, event. +LAYER_CASES = { + "personless_alone": ({}, {}, {}, None, {PP: False}), + "identified_sends_no_option": ({}, {}, {}, "u", {}), + "super_beats_personless": ({PP: True}, {}, {}, None, {PP: True}), + "context_beats_super": ({PP: True}, {PP: False}, {}, "u", {PP: False}), + "event_beats_context": ({}, {PP: False}, {PP: True}, "u", {PP: True}), + "event_beats_all": ({PP: False}, {PP: False}, {PP: True}, None, {PP: True}), + "layers_merge_by_key": ( + {"cookieless_mode": True}, + {"product_tour_id": "t"}, + {}, + "u", + {"cookieless_mode": True, "product_tour_id": "t"}, + ), +} + + +def _layered_capture(context_options, event_options, distinct_id): + def call(client): + with new_context(fresh=True): + for key, value in context_options.items(): + set_context_option(key, value) + return client.capture("e", distinct_id=distinct_id, options=event_options) + + return call + + +@pytest.mark.parametrize("case", list(LAYER_CASES)) +def test_sync_option_layers(case): + super_options, context, event, distinct_id, expected = LAYER_CASES[case] + events = _sync_wire_events( + _layered_capture(context, event, distinct_id), super_options=super_options + ) + assert events[0]["options"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", list(LAYER_CASES)) +async def test_async_option_layers(case): + super_options, context, event, distinct_id, expected = LAYER_CASES[case] + events = await _async_wire_events( + _layered_capture(context, event, distinct_id), super_options=super_options + ) + assert events[0]["options"] == expected + + +@pytest.mark.parametrize( + "method", ["capture", "set", "set_once", "group_identify", "alias"] +) +def test_sync_super_options_reach_every_path(method): + events = _sync_wire_events( + lambda c: CAPTURE_CALLS[method](c, None), super_options=OPTIONS + ) + assert [e["options"] for e in events] == [OPTIONS] + + +@pytest.mark.parametrize("method", ["capture", "set", "set_once"]) +def test_sync_context_options_reach_user_paths(method): + def call(client): + with new_context(fresh=True): + set_context_option("cookieless_mode", True) + return CAPTURE_CALLS[method](client, None) + + events = _sync_wire_events(call) + assert [e["options"] for e in events] == [{"cookieless_mode": True}] + + +def test_personless_option_beats_legacy_property(): + events = _sync_wire_events( + lambda c: c.capture("e", properties={"$process_person_profile": True}) + ) + assert events[0]["options"] == {PP: False} + assert "$process_person_profile" not in events[0]["properties"] + + +def test_legacy_super_property_fills_option_when_unset(): + events = _sync_wire_events( + lambda c: c.capture("e", distinct_id="u"), + super_properties={"$process_person_profile": False}, + ) + assert events[0]["options"] == {PP: False} + + +def _layered_properties(client): + with new_context(fresh=True): + tag("from_context", "context") + tag("shared", "context") + return client.capture( + "e", distinct_id="u", properties={"shared": "event", "only_event": 1} + ) + + +SUPER_PROPERTIES = {"shared": "super", "from_context": "super", "only_super": 1} + + +def test_sync_event_and_context_properties_beat_super_properties(): + events = _sync_wire_events(_layered_properties, super_properties=SUPER_PROPERTIES) + properties = events[0]["properties"] + assert ( + properties["shared"], + properties["from_context"], + properties["only_super"], + properties["only_event"], + ) == ("event", "context", 1, 1) + + +@pytest.mark.asyncio +async def test_async_event_and_context_properties_beat_super_properties(): + events = await _async_wire_events( + _layered_properties, super_properties=SUPER_PROPERTIES + ) + properties = events[0]["properties"] + assert ( + properties["shared"], + properties["from_context"], + properties["only_super"], + properties["only_event"], + ) == ("event", "context", 1, 1) diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index 655b1d2cc..09d6ea82c 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -65,6 +65,7 @@ def setUp(self): self._original_api_key = posthog.api_key self._original_project_api_key = posthog.project_api_key self._original_project_root = posthog.project_root + self._original_super_options = posthog.super_options self._original_privacy_mode = posthog.privacy_mode self._original_disabled = posthog.disabled self._original_send = posthog.send @@ -81,6 +82,7 @@ def tearDown(self): posthog.api_key = self._original_api_key posthog.project_api_key = self._original_project_api_key posthog.project_root = self._original_project_root + posthog.super_options = self._original_super_options posthog.privacy_mode = self._original_privacy_mode posthog.disabled = self._original_disabled posthog.send = self._original_send @@ -129,13 +131,19 @@ def test_setup_uses_api_key_when_project_api_key_is_blank(self): self.assertEqual(client.api_key, "phc_api_key") self.assertFalse(client.disabled) - def test_setup_propagates_project_root(self): + @parameterized.expand( + [ + ("project_root", "/path/to/project"), + ("super_options", {"cookieless_mode": True}), + ] + ) + def test_setup_propagates_config(self, setting, value): posthog.api_key = "phc_test" - posthog.project_root = "/path/to/project" + setattr(posthog, setting, value) client = posthog.setup() - self.assertEqual(client.project_root, "/path/to/project") + self.assertEqual(getattr(client, setting), value) def test_setup_propagates_and_updates_privacy_mode(self): posthog.api_key = "phc_test" diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 07db58cf9..a0214f6f5 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -314,6 +314,7 @@ alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue alias posthog.feature_flags.convert_to_datetime_aware -> posthog.utils.convert_to_datetime_aware alias posthog.feature_flags.is_valid_regex -> posthog.utils.is_valid_regex alias posthog.feature_flags.utils -> posthog.utils +alias posthog.inner_get_context_options -> posthog.contexts.get_context_options alias posthog.inner_get_tags -> posthog.contexts.get_tags alias posthog.inner_identify_context -> posthog.contexts.identify_context alias posthog.inner_new_context -> posthog.contexts.new_context @@ -324,6 +325,7 @@ alias posthog.inner_set_code_variables_ignore_patterns_context -> posthog.contex alias posthog.inner_set_code_variables_mask_patterns_context -> posthog.contexts.set_code_variables_mask_patterns_context alias posthog.inner_set_code_variables_mask_url_credentials_context -> posthog.contexts.set_code_variables_mask_url_credentials_context alias posthog.inner_set_context_device_id -> posthog.contexts.set_context_device_id +alias posthog.inner_set_context_option -> posthog.contexts.set_context_option alias posthog.inner_set_context_session -> posthog.contexts.set_context_session alias posthog.inner_tag -> posthog.contexts.tag alias posthog.integrations.django.Client -> posthog.client.Client @@ -680,6 +682,7 @@ attribute posthog.async_client.AsyncClient.project_root = os.getcwd() attribute posthog.async_client.AsyncClient.raw_host = normalize_host(host) attribute posthog.async_client.AsyncClient.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.async_client.AsyncClient.send = send +attribute posthog.async_client.AsyncClient.super_options = super_options attribute posthog.async_client.AsyncClient.super_properties = super_properties attribute posthog.async_client.AsyncClient.timeout = timeout attribute posthog.before_send = None @@ -751,6 +754,7 @@ attribute posthog.client.Client.queue: Queue attribute posthog.client.Client.raw_host = normalize_host(host) attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send +attribute posthog.client.Client.super_options = super_options attribute posthog.client.Client.super_properties = super_properties attribute posthog.client.Client.sync_mode = sync_mode attribute posthog.client.Client.timeout = timeout @@ -789,6 +793,7 @@ attribute posthog.contexts.ContextScope.code_variables_mask_url_credentials: Opt attribute posthog.contexts.ContextScope.device_id: Optional[str] = None attribute posthog.contexts.ContextScope.distinct_id: Optional[str] = None attribute posthog.contexts.ContextScope.fresh = fresh +attribute posthog.contexts.ContextScope.options: Dict[str, Any] = {} attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} @@ -1018,6 +1023,7 @@ attribute posthog.request.USER_AGENT = 'posthog-python/' + VERSION attribute posthog.request.US_INGESTION_ENDPOINT = 'https://us.i.posthog.com' attribute posthog.secret_key = None attribute posthog.send = True +attribute posthog.super_options = None attribute posthog.super_properties = None attribute posthog.sync_mode = False attribute posthog.traces = None @@ -1170,13 +1176,13 @@ class posthog.ai.types.TokenUsage class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs -class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) +class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, super_options: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) class posthog.async_client.AsyncPosthog class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) class posthog.capture_compression.CaptureCompression class posthog.capture_send.CaptureError(status: int | str, message: str, *, endpoint: str, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None, event_results: Optional[dict[str, CaptureEventResult]] = None) class posthog.capture_send.CaptureEventResult(result: Optional[str], details: Optional[str] = None) -class posthog.client.Client(project_api_key: str, host=None, *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_max_queue_size: int = 1000, capture_ai_timeout: float = 30, capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) +class posthog.client.Client(project_api_key: str, host=None, *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, super_options=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_max_queue_size: int = 1000, capture_ai_timeout: float = 30, capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, retries=3, timeout=15, historical_migration=False, endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) @@ -1319,6 +1325,7 @@ function posthog.contexts.get_code_variables_mask_patterns_context() -> Optional function posthog.contexts.get_code_variables_mask_url_credentials_context() -> Optional[bool] function posthog.contexts.get_context_device_id() -> Optional[str] function posthog.contexts.get_context_distinct_id() -> Optional[str] +function posthog.contexts.get_context_options() -> Dict[str, Any] function posthog.contexts.get_context_session_id() -> Optional[str] function posthog.contexts.get_tags() -> Dict[str, Any] function posthog.contexts.identify_context(distinct_id: str) -> None @@ -1330,6 +1337,7 @@ function posthog.contexts.set_code_variables_ignore_patterns_context(ignore_patt function posthog.contexts.set_code_variables_mask_patterns_context(mask_patterns: list) -> None function posthog.contexts.set_code_variables_mask_url_credentials_context(enabled: bool) -> None function posthog.contexts.set_context_device_id(device_id: str) -> None +function posthog.contexts.set_context_option(key: str, value: Any) -> None function posthog.contexts.set_context_session(session_id: str) -> None function posthog.contexts.tag(key: str, value: Any) -> None function posthog.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys: Optional[list[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations @@ -1384,6 +1392,7 @@ function posthog.flush(timeout_seconds: Optional[float] = 10) -> None function posthog.get_active_span() -> Optional[Span] function posthog.get_all_flags(distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None, flag_keys_to_evaluate: Optional[list[str]] = None) -> Optional[dict[str, FlagValue]] function posthog.get_all_flags_and_payloads(distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None, flag_keys_to_evaluate: Optional[list[str]] = None) -> FlagsAndPayloads +function posthog.get_context_options() -> Dict[str, Any] function posthog.get_feature_flag(key: str, distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FlagValue] function posthog.get_feature_flag_evaluation_runtime(key: str) -> Optional[FeatureFlagEvaluationRuntime] function posthog.get_feature_flag_keys_by_evaluation_runtime(evaluation_runtime: Union[FeatureFlagEvaluationRuntime, str]) -> list[str] @@ -1428,6 +1437,7 @@ function posthog.set_code_variables_ignore_patterns_context(ignore_patterns: lis function posthog.set_code_variables_mask_patterns_context(mask_patterns: list[str]) function posthog.set_code_variables_mask_url_credentials_context(enabled: bool) function posthog.set_context_device_id(device_id: str) +function posthog.set_context_option(key: str, value: Any) -> None function posthog.set_context_session(session_id: str) function posthog.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] function posthog.setup() -> Client @@ -1582,6 +1592,7 @@ method posthog.client.Client.flush(timeout_seconds: Optional[float] = 10) -> Non method posthog.client.Client.get_active_span() -> Optional[Span] method posthog.client.Client.get_all_flags(distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> Optional[dict[str, Union[bool, str]]] method posthog.client.Client.get_all_flags_and_payloads(distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> FlagsAndPayloads +method posthog.client.Client.get_context_options() -> Dict[str, Any] method posthog.client.Client.get_feature_flag(key: str, distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FlagValue] method posthog.client.Client.get_feature_flag_evaluation_runtime(key: str) -> Optional[FeatureFlagEvaluationRuntime] method posthog.client.Client.get_feature_flag_keys_by_evaluation_runtime(evaluation_runtime: Union[FeatureFlagEvaluationRuntime, str]) -> list[str] @@ -1601,6 +1612,7 @@ method posthog.client.Client.new_context(fresh=False, capture_exceptions: Option method posthog.client.Client.scoped(fresh=False, capture_exceptions: Optional[bool] = None) method posthog.client.Client.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.client.Client.set_context_device_id(device_id: str) -> None +method posthog.client.Client.set_context_option(key: str, value: Any) -> None method posthog.client.Client.set_context_session(session_id: str) -> None method posthog.client.Client.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.client.Client.shutdown() -> None @@ -1611,7 +1623,9 @@ method posthog.consumer.Consumer.pause() method posthog.consumer.Consumer.request(batch) method posthog.consumer.Consumer.run() method posthog.consumer.Consumer.upload() +method posthog.contexts.ContextScope.add_option(key: str, value: Any) method posthog.contexts.ContextScope.add_tag(key: str, value: Any) +method posthog.contexts.ContextScope.collect_options() -> Dict[str, Any] method posthog.contexts.ContextScope.collect_tags() -> Dict[str, Any] method posthog.contexts.ContextScope.get_capture_exception_code_variables() -> Optional[bool] method posthog.contexts.ContextScope.get_code_variables_detect_secrets() -> Optional[bool] From 2088b6ba4ab721ecce16b23f41e8847b81740450 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 7 Oct 2026 10:41:39 -0700 Subject: [PATCH 2/5] docs: state the option layer rules in capture docstrings --- posthog/__init__.py | 4 +++- posthog/client.py | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index a0a697551..7f2e6af73 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -387,7 +387,9 @@ def get_context_options() -> Dict[str, Any]: an event's own properties override them. super_options: Capture options for every captured event, such as ``{"cookieless_mode": True}``. Context options and an event's own - ``options`` override them. + ``options`` override them. They also win over an event's legacy + property for the same key, such as ``$cookieless_mode``, so pass + per-event overrides of that key as ``options``. metrics: Config dict for the ``client.metrics`` API (``service_name``, ``service_version``, ``environment``, ``flush_interval``, ...). Applied when ``setup()`` builds the global client, or on a later ``setup()`` diff --git a/posthog/client.py b/posthog/client.py index 63685d949..b94e080ba 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -809,7 +809,9 @@ def __init__( tags and an event's own properties override them. super_options: Capture options for every captured event, such as ``{"cookieless_mode": True}``. Context options and an event's - own ``options`` override them. + own ``options`` override them. They also win over an event's + legacy property for the same key, such as ``$cookieless_mode``, + so pass per-event overrides of that key as ``options``. enable_exception_autocapture: Automatically capture uncaught exceptions. log_captured_exceptions: Also log exceptions captured by error @@ -1332,6 +1334,9 @@ def set_context_option(self, key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. + Context options override ``super_options``. An event's own ``options`` + override context options. + Args: key: The option name, such as ``"process_person_profile"``. value: The option value, sent as given. @@ -1673,8 +1678,10 @@ def capture( disable_geoip: Whether to disable GeoIP for this event. options: Capture options for this event, such as ``{"process_person_profile": False}``. Sent as given, for - PostHog to validate. An option wins over its legacy ``$`` - property, such as ``$process_person_profile``. + PostHog to validate. They override context options and + ``super_options``. An option set at any layer wins over its + legacy ``$`` property, such as ``$process_person_profile``, + set at any layer. A ``None`` option counts as unset. Examples: ```python From ee9369c52e5cbe96963a40c30c2ed47e49cdfd2a Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 8 Oct 2026 11:05:41 -0700 Subject: [PATCH 3/5] feat!: fill context and super values after before_send Context tags, context options, super_properties, super_options, the derived personless option and the environment $release_id now fill only keys an event leaves unset, after before_send runs. before_send sees only the event's own values and SDK enrichment, and its changes beat every default. A None option counts as unset and is filled. Legacy properties still fill only unset options and are always removed. --- posthog/__init__.py | 24 ++-- posthog/_async_consumer.py | 13 +- posthog/async_client.py | 106 ++++++++++------ posthog/capture_event.py | 69 ++++++++++ posthog/client.py | 119 +++++++++++------- posthog/contexts.py | 7 +- posthog/test/capture_helpers.py | 14 +++ posthog/test/test_async_consumer.py | 2 +- posthog/test/test_event_options.py | 100 ++++++++++++++- .../test_feature_flag_called_minimization.py | 17 +-- posthog/test/test_release_id.py | 18 ++- 11 files changed, 363 insertions(+), 126 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index 7f2e6af73..dc729902c 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -309,8 +309,9 @@ def set_context_option(key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options override ``super_options``, and an event's own ``options`` - override context options. + Context options fill options an event leaves unset, after ``before_send`` + runs. They override ``super_options``. An event's own ``options`` and + ``before_send`` changes override them. Args: key: The option name, such as ``"process_person_profile"`` @@ -383,13 +384,16 @@ def get_context_options() -> Dict[str, Any]: feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties for every captured event. Context tags and - an event's own properties override them. + super_properties: Properties for every captured event. They fill only + keys the event leaves unset, after ``before_send`` runs. An event's own + properties, ``before_send`` changes and context tags override them. super_options: Capture options for every captured event, such as - ``{"cookieless_mode": True}``. Context options and an event's own - ``options`` override them. They also win over an event's legacy - property for the same key, such as ``$cookieless_mode``, so pass - per-event overrides of that key as ``options``. + ``{"cookieless_mode": True}``. They fill only options the event leaves + unset, after ``before_send`` runs. An event's own ``options``, + ``before_send`` changes and context options override them. They also + win over an event's legacy property for the same key, such as + ``$cookieless_mode``, so pass per-event overrides of that key as + ``options``. metrics: Config dict for the ``client.metrics`` API (``service_name``, ``service_version``, ``environment``, ``flush_interval``, ...). Applied when ``setup()`` builds the global client, or on a later ``setup()`` @@ -413,7 +417,9 @@ def get_context_options() -> Dict[str, Any]: project_root: Root path used to determine in-app exception stack frames. privacy_mode: Capture AI usage metadata without prompt inputs or outputs. before_send: Optional callback that can modify or drop events before upload. - Return ``None`` to drop an event. + Return ``None`` to drop an event. It does not see context tags, context + options, ``super_properties`` or ``super_options``. They fill in after + it runs. enable_local_evaluation: Whether to poll feature flag definitions for local evaluation when a personal API key is configured. flag_definition_cache_provider: Optional external cache provider for sharing diff --git a/posthog/_async_consumer.py b/posthog/_async_consumer.py index 0ae74b995..ce0b209c2 100644 --- a/posthog/_async_consumer.py +++ b/posthog/_async_consumer.py @@ -11,6 +11,7 @@ from ._async_request import async_send_v1_batch from .capture_compression import CaptureCompression +from .capture_event import _EventDefaults from .capture_send import _CAPTURE_V1_PATH, _capture_loss_message from .consumer import BATCH_SIZE_LIMIT, MAX_MSG_SIZE from .request import DatetimeSerializer @@ -37,6 +38,7 @@ async def _run_outside_processing_event(awaitable): class _QueuedEvent: event: dict[str, Any] context: contextvars.Context + defaults: Optional[_EventDefaults] = None async def _invoke_callback(callback, *args): @@ -92,7 +94,10 @@ def __init__( *, host: Optional[str], on_error: Optional[Callable[[Exception, list[dict[str, Any]]], Any]], - process_event: Callable[[dict[str, Any]], Awaitable[Optional[dict[str, Any]]]], + process_event: Callable[ + [dict[str, Any], Optional[_EventDefaults]], + Awaitable[Optional[dict[str, Any]]], + ], flush_at: int, flush_interval: float, retries: int, @@ -158,11 +163,11 @@ async def _get_or_flush(self, timeout: float) -> tuple[Any, bool]: return None, False async def _process_queued_event( - self, event: dict[str, Any] + self, queued: _QueuedEvent ) -> Optional[dict[str, Any]]: token = _PROCESSING_EVENT.set(True) try: - return await self.process_event(event) + return await self.process_event(queued.event, queued.defaults) finally: _PROCESSING_EVENT.reset(token) @@ -207,7 +212,7 @@ async def next(self) -> tuple[list[dict[str, Any]], bool]: try: process_task = queued.context.run( - asyncio.create_task, self._process_queued_event(queued.event) + asyncio.create_task, self._process_queued_event(queued) ) item = await process_task except Exception as error: diff --git a/posthog/async_client.py b/posthog/async_client.py index 4d5945241..1c3c79ed3 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -35,7 +35,13 @@ CaptureCompression, _resolve_capture_compression, ) -from .capture_event import _canonical_event_uuid, _event_options +from .capture_event import ( + _build_event_defaults, + _canonical_event_uuid, + _event_options, + _EventDefaults, + _fill_event_defaults, +) from .capture_send import _CAPTURE_V1_PATH from .client import ( MAX_DICT_SIZE as _MAX_DICT_SIZE, @@ -43,6 +49,8 @@ Client as _SyncClient, _metadata_has_experiment, _parse_flag_payload, + _add_context_session_id, + _context_tag_defaults, _personless_options, add_context_tags as _add_context_tags, get_identity_state as _get_identity_state, @@ -339,10 +347,12 @@ def _ensure_workers_started(self) -> None: self._consumers.append(consumer) self._worker_tasks.append(asyncio.create_task(consumer.run())) - def _enqueue_prepared_event(self, prepared: dict[str, Any]) -> bool: + def _enqueue_prepared_event( + self, prepared: dict[str, Any], defaults: _EventDefaults + ) -> bool: if not self._accepting or self._closed: return False - queued_event = _QueuedEvent(prepared, contextvars.copy_context()) + queued_event = _QueuedEvent(prepared, contextvars.copy_context(), defaults) try: running_loop = asyncio.get_running_loop() except RuntimeError: @@ -401,7 +411,6 @@ def _prepare_event( msg: dict[str, Any], disable_geoip: Optional[bool], property_allowlist=None, - derived_options: Optional[dict[str, Any]] = None, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: if self.disabled or not self._accepting: return None, None @@ -427,17 +436,7 @@ def _prepare_event( disable_geoip = self.disable_geoip if disable_geoip: properties["$geoip_disable"] = True - if self.super_properties: - msg["properties"] = {**self.super_properties, **properties} - # Each layer overrides the one before it: values the SDK derives, such - # as personless, then super options, then context and event options. - msg["options"] = { - **(derived_options or {}), - **_event_options(self.super_options), - **(msg.get("options") or {}), - } - if self._release_id is not None: - msg["properties"].setdefault("$release_id", self._release_id) + msg["options"] = msg.get("options") or {} if self.is_server: msg["properties"]["$is_server"] = True if property_allowlist is not None: @@ -451,7 +450,15 @@ def _prepare_event( cleaned = clean(msg) return cleaned, sent_uuid - async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: + async def _process_event( + self, msg: dict[str, Any], defaults: Optional[_EventDefaults] + ) -> Optional[dict[str, Any]]: + processed = await self._run_before_send(msg) + if processed is not None: + _fill_event_defaults(processed, defaults) + return processed + + async def _run_before_send(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: if self.before_send is None: return msg @@ -470,14 +477,30 @@ async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: self.log.error("Error in before_send callback (%s)", type(error).__name__) return None + def _event_defaults( + self, + context_properties: Optional[dict[str, Any]] = None, + context_options: Optional[dict[str, Any]] = None, + derived_options: Optional[dict[str, Any]] = None, + property_allowlist=None, + ) -> _EventDefaults: + return _build_event_defaults( + super_properties=self.super_properties, + super_options=self.super_options, + release_id=self._release_id, + context_properties=context_properties, + context_options=context_options, + derived_options=derived_options, + property_allowlist=property_allowlist, + ) + def _build_capture_event( self, event: str, kwargs: OptionalCaptureArgs - ) -> tuple[dict[str, Any], Optional[bool], Any, dict[str, Any]]: + ) -> tuple[dict[str, Any], Optional[bool], Any, _EventDefaults]: properties = {**(kwargs.get("properties") or {}), **system_context()} if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} - properties = _add_context_tags(properties) - assert properties is not None + properties = _add_context_session_id(properties) distinct_id, personless = _get_identity_state(kwargs.get("distinct_id")) groups = kwargs.get("groups") @@ -503,14 +526,16 @@ def _build_capture_event( "distinct_id": distinct_id, "event": event, "uuid": kwargs.get("uuid"), - "options": { - **_get_context_options(), - **_event_options(kwargs.get("options")), - }, + "options": _event_options(kwargs.get("options")), }, kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), - _personless_options(personless), + self._event_defaults( + context_properties=_context_tag_defaults(), + context_options=_get_context_options(), + derived_options=_personless_options(personless), + property_allowlist=kwargs.get("_property_allowlist"), + ), ) def capture( @@ -518,18 +543,18 @@ def capture( ) -> Optional[str]: """Queue an event without blocking for network delivery.""" try: - msg, disable_geoip, property_allowlist, derived_options = ( + msg, disable_geoip, property_allowlist, defaults = ( self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist, derived_options + msg, disable_geoip, property_allowlist ) if prepared is None or sent_uuid is None: return None if not self.send: return sent_uuid - if not self._enqueue_prepared_event(prepared): + if not self._enqueue_prepared_event(prepared, defaults): return None self.log.debug("queued async event %s", event) return sent_uuid @@ -565,15 +590,15 @@ async def capture_immediate( self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1 error_batch: list[dict[str, Any]] = [] try: - msg, disable_geoip, property_allowlist, derived_options = ( + msg, disable_geoip, property_allowlist, defaults = ( self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist, derived_options + msg, disable_geoip, property_allowlist ) if prepared is None or sent_uuid is None: return None - processed = await self._process_event(prepared) + processed = await self._process_event(prepared, defaults) if processed is None: return None error_batch = [processed] @@ -626,10 +651,7 @@ def _build_person_properties_event( property_key: properties, "event": event, "uuid": kwargs.get("uuid"), - "options": { - **_get_context_options(), - **_event_options(kwargs.get("options")), - }, + "options": _event_options(kwargs.get("options")), } def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: @@ -637,7 +659,9 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: msg = self._build_person_properties_event("$set", "$set", kwargs) if msg is None: return None - return self._enqueue_built_event(msg, kwargs.get("disable_geoip")) + return self._enqueue_built_event( + msg, kwargs.get("disable_geoip"), _get_context_options() + ) except Exception as error: if self.debug: raise @@ -649,7 +673,9 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: msg = self._build_person_properties_event("$set_once", "$set_once", kwargs) if msg is None: return None - return self._enqueue_built_event(msg, kwargs.get("disable_geoip")) + return self._enqueue_built_event( + msg, kwargs.get("disable_geoip"), _get_context_options() + ) except Exception as error: if self.debug: raise @@ -742,14 +768,18 @@ def alias( return None def _enqueue_built_event( - self, msg: dict[str, Any], disable_geoip: Optional[bool] + self, + msg: dict[str, Any], + disable_geoip: Optional[bool], + context_options: Optional[dict[str, Any]] = None, ) -> Optional[str]: prepared, sent_uuid = self._prepare_event(msg, disable_geoip) if prepared is None or sent_uuid is None: return None if not self.send: return sent_uuid - if not self._enqueue_prepared_event(prepared): + defaults = self._event_defaults(context_options=context_options) + if not self._enqueue_prepared_event(prepared, defaults): return None return sent_uuid diff --git a/posthog/capture_event.py b/posthog/capture_event.py index 5afb31563..3bae1706b 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -20,11 +20,14 @@ import logging import re +from collections.abc import Collection, Mapping +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Optional from uuid import UUID from posthog.utils import _normalize_timestamp +from posthog.utils import clean as _clean log = logging.getLogger("posthog") @@ -85,6 +88,72 @@ def _event_options(value: Any) -> dict[str, Any]: return dict(value) +@dataclass(frozen=True) +class _EventDefaults: + """Context, global and SDK-derived values for one event, highest layer first. + + They fill in after ``before_send``, so the hook never sees them, and the + event's own values and the hook's changes always win. + """ + + property_layers: tuple[Mapping[str, Any], ...] = () + option_layers: tuple[Mapping[str, Any], ...] = () + property_allowlist: Optional[Collection[str]] = None + + +def _build_event_defaults( + *, + super_properties: Optional[Mapping[str, Any]], + super_options: Any, + release_id: Optional[str], + context_properties: Optional[Mapping[str, Any]] = None, + context_options: Optional[Mapping[str, Any]] = None, + derived_options: Optional[Mapping[str, Any]] = None, + property_allowlist: Optional[Collection[str]] = None, +) -> _EventDefaults: + """Order the layers: context, then global, then values the SDK derives.""" + # An explicit `$release_id` in the event or in super properties wins over + # the environment value. + release = {"$release_id": release_id} if release_id is not None else {} + return _EventDefaults( + property_layers=(context_properties or {}, super_properties or {}, release), + option_layers=( + context_options or {}, + _event_options(super_options), + derived_options or {}, + ), + property_allowlist=property_allowlist, + ) + + +def _fill_event_defaults( + msg: dict[str, Any], defaults: Optional[_EventDefaults] +) -> None: + """Fill the keys an event left unset from ``defaults``, layer by layer. + + A property is unset only when its key is missing. An option is unset when + it is missing or ``None``, the same rule hoisting uses. + """ + if defaults is None: + return + allowlist = defaults.property_allowlist + properties = msg.get("properties") + if not isinstance(properties, dict): + properties = {} + msg["properties"] = properties + for property_layer in defaults.property_layers: + for key, value in property_layer.items(): + if key in properties or (allowlist is not None and key not in allowlist): + continue + properties[key] = _clean(value) + options = _event_options(msg.get("options")) + for option_layer in defaults.option_layers: + for key, value in option_layer.items(): + if options.get(key) is None: + options[key] = _clean(value) + msg["options"] = options + + def _v1_timestamp(timestamp: Any) -> str: """Return a UTC RFC3339 timestamp string. diff --git a/posthog/client.py b/posthog/client.py index b94e080ba..45de16979 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -34,7 +34,12 @@ _resolve_capture_ai_compression, _resolve_capture_compression, ) -from posthog.capture_event import _canonical_event_uuid, _event_options +from posthog.capture_event import ( + _build_event_defaults, + _canonical_event_uuid, + _event_options, + _fill_event_defaults, +) from posthog.capture_send import ( _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, @@ -293,12 +298,24 @@ def add_context_tags(properties): context_tags.update(properties) properties = context_tags + return _add_context_session_id(properties) + + +def _add_context_session_id(properties): if "$session_id" not in properties and get_context_session_id(): properties["$session_id"] = get_context_session_id() - return properties +def _context_tag_defaults() -> dict[str, Any]: + """The current context's tags, as event property defaults.""" + current_context = _get_current_context() + if not current_context: + return {} + context_tags = current_context.collect_tags() + return {**context_tags, "$context_tags": set(context_tags.keys())} + + def no_throw(default_return=None): """ Decorator to prevent raising exceptions from public API methods. @@ -805,13 +822,17 @@ def __init__( feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties for every captured event. Context - tags and an event's own properties override them. + super_properties: Properties for every captured event. They fill + only keys the event leaves unset, after ``before_send`` runs. + An event's own properties, ``before_send`` changes and context + tags override them. super_options: Capture options for every captured event, such as - ``{"cookieless_mode": True}``. Context options and an event's - own ``options`` override them. They also win over an event's - legacy property for the same key, such as ``$cookieless_mode``, - so pass per-event overrides of that key as ``options``. + ``{"cookieless_mode": True}``. They fill only options the event + leaves unset, after ``before_send`` runs. An event's own + ``options``, ``before_send`` changes and context options + override them. They also win over an event's legacy property + for the same key, such as ``$cookieless_mode``, so pass + per-event overrides of that key as ``options``. enable_exception_autocapture: Automatically capture uncaught exceptions. log_captured_exceptions: Also log exceptions captured by error @@ -826,7 +847,9 @@ def __init__( through unredacted. ``privacy_mode`` always wins. Defaults to False. before_send: Optional callback that can modify or drop events before - upload. Return ``None`` to drop an event. + upload. Return ``None`` to drop an event. It does not see + context tags, context options, ``super_properties`` or + ``super_options``. They fill in after it runs. flag_fallback_cache_url: Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL. enable_local_evaluation: Whether to poll feature flag definitions for @@ -1334,8 +1357,9 @@ def set_context_option(self, key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options override ``super_options``. An event's own ``options`` - override context options. + Context options fill options an event leaves unset, after + ``before_send`` runs. They override ``super_options``. An event's own + ``options`` and ``before_send`` changes override them. Args: key: The option name, such as ``"process_person_profile"``. @@ -1679,7 +1703,8 @@ def capture( options: Capture options for this event, such as ``{"process_person_profile": False}``. Sent as given, for PostHog to validate. They override context options and - ``super_options``. An option set at any layer wins over its + ``super_options``, which fill in after ``before_send`` runs. + An option set at any layer wins over its legacy ``$`` property, such as ``$process_person_profile``, set at any layer. A ``None`` option counts as unset. @@ -1756,10 +1781,7 @@ def _capture( flags_snapshot = kwargs.get("flags", None) send_feature_flags = kwargs.get("send_feature_flags", False) disable_geoip = kwargs.get("disable_geoip", None) - options = { - **_context_get_context_options(), - **_event_options(kwargs.get("options", None)), - } + options = _event_options(kwargs.get("options", None)) # Internal, set for minimal $feature_flag_called events: a strict allowlist # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) @@ -1769,8 +1791,7 @@ def _capture( if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} - properties = add_context_tags(properties) - assert properties is not None # Type hint for mypy + properties = _add_context_session_id(properties) (distinct_id, personless) = get_identity_state(distinct_id) @@ -1882,6 +1903,8 @@ def _capture( disable_geoip, lane, property_allowlist=property_allowlist, + context_properties=_context_tag_defaults(), + context_options=_context_get_context_options(), derived_options=_personless_options(personless), ) @@ -1970,13 +1993,12 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set": properties, "event": "$set", "uuid": uuid, - "options": { - **_context_get_context_options(), - **_event_options(kwargs.get("options", None)), - }, + "options": _event_options(kwargs.get("options", None)), } - return self._enqueue(msg, disable_geoip) + return self._enqueue( + msg, disable_geoip, context_options=_context_get_context_options() + ) @no_throw() def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: @@ -2023,13 +2045,12 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set_once": properties, "event": "$set_once", "uuid": uuid, - "options": { - **_context_get_context_options(), - **_event_options(kwargs.get("options", None)), - }, + "options": _event_options(kwargs.get("options", None)), } - return self._enqueue(msg, disable_geoip) + return self._enqueue( + msg, disable_geoip, context_options=_context_get_context_options() + ) @no_throw() def group_identify( @@ -2473,6 +2494,8 @@ def _enqueue( disable_geoip, lane=None, property_allowlist=None, + context_properties=None, + context_options=None, derived_options=None, ): # type: (...) -> Optional[str] @@ -2510,30 +2533,17 @@ def _enqueue( if disable_geoip: msg["properties"]["$geoip_disable"] = True - if self.super_properties: - msg["properties"] = {**self.super_properties, **msg["properties"]} + msg["options"] = msg.get("options") or {} - # Each layer overrides the one before it: values the SDK derives, such - # as personless, then super options, then context and event options. - msg["options"] = { - **(derived_options or {}), - **_event_options(self.super_options), - **(msg.get("options") or {}), - } - - # 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. + # Super properties fill only keys the event left unset, so they cannot + # override this SDK's server classification. if self.is_server: msg["properties"]["$is_server"] = True - # Applied after every enrichment step (system context, context tags, super - # properties, $lib/$lib_version) so the final event shape is exactly the - # allowlist regardless of where a property came from. + # Applied after every enrichment step (system context, $lib/$lib_version) + # and again to the defaults filled in after before_send, so the final + # event shape is exactly the allowlist regardless of where a property + # came from. if property_allowlist is not None: msg["properties"] = { k: v for k, v in msg["properties"].items() if k in property_allowlist @@ -2556,6 +2566,19 @@ def _enqueue( self.log.exception(f"Error in before_send callback: {e}") return None + _fill_event_defaults( + msg, + _build_event_defaults( + super_properties=self.super_properties, + super_options=self.super_options, + release_id=self._release_id, + context_properties=context_properties, + context_options=context_options, + derived_options=derived_options, + property_allowlist=property_allowlist, + ), + ) + # Re-normalized after before_send, which may have replaced or removed # msg["uuid"], so the returned uuid always matches the wire event. self._normalize_event_uuid(msg) diff --git a/posthog/contexts.py b/posthog/contexts.py index 59c4b2bf5..cb6838d06 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -303,9 +303,10 @@ def set_context_option(key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options override the client's ``super_options``. An event's own - ``options`` override context options. Child contexts inherit them unless - they are fresh. + Context options fill options an event leaves unset, after ``before_send`` + runs. They override the client's ``super_options``. An event's own + ``options`` and ``before_send`` changes override them. Child contexts + inherit them unless they are fresh. Args: key: The option name, such as ``"process_person_profile"`` diff --git a/posthog/test/capture_helpers.py b/posthog/test/capture_helpers.py index 3ead56f5e..d32f4741f 100644 --- a/posthog/test/capture_helpers.py +++ b/posthog/test/capture_helpers.py @@ -38,6 +38,20 @@ def patch_capture_send(site: str = "client", **kwargs) -> "mock._patch": return mock.patch(f"posthog.{site}.{_SUBMITTER}", **kwargs) +def record_sync_capture_sends(test_case) -> list[dict]: + """Record every event a ``sync_mode`` client uploads until ``test_case`` ends.""" + events: list[dict] = [] + + def record(*args, **kwargs): + events.extend(args[2] if len(args) > 2 else kwargs["batch"]) + return mock.DEFAULT + + patcher = patch_capture_send("client", side_effect=record) + patcher.start() + test_case.addCleanup(patcher.stop) + return events + + def patch_async_capture_send(**kwargs) -> "mock._patch": """Patch the submitter the ``AsyncPosthog`` consumer awaits.""" return mock.patch("posthog._async_consumer.async_send_v1_batch", **kwargs) diff --git a/posthog/test/test_async_consumer.py b/posthog/test/test_async_consumer.py index 4290a9419..db3e17feb 100644 --- a/posthog/test/test_async_consumer.py +++ b/posthog/test/test_async_consumer.py @@ -15,7 +15,7 @@ def make_consumer(*, retries: int) -> _AsyncConsumer: "test-key", host="https://example.com", on_error=None, - process_event=mock.AsyncMock(side_effect=lambda event: event), + process_event=mock.AsyncMock(side_effect=lambda event, defaults: event), flush_at=100, flush_interval=1, retries=retries, diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index cdc3b8ab2..7e5753905 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -1,3 +1,4 @@ +import inspect import logging import pytest @@ -53,7 +54,10 @@ async def send_batch(api_key, host, batch, **kwargs): async with AsyncPosthog( "test-key", before_send=before_send, **config ) as client: - assert call(client) is not None + result = call(client) + if inspect.isawaitable(result): + result = await result + assert result is not None await client.flush(timeout_seconds=1) return [_to_v1_event(msg) for batch in batches for msg in batch] @@ -125,6 +129,7 @@ async def test_async_non_dict_options_are_logged_and_event_is_sent(caplog): "context_beats_super": ({PP: True}, {PP: False}, {}, "u", {PP: False}), "event_beats_context": ({}, {PP: False}, {PP: True}, "u", {PP: True}), "event_beats_all": ({PP: False}, {PP: False}, {PP: True}, None, {PP: True}), + "null_event_option_is_filled": ({PP: True}, {}, {PP: None}, "u", {PP: True}), "layers_merge_by_key": ( {"cookieless_mode": True}, {"product_tour_id": "t"}, @@ -236,3 +241,96 @@ async def test_async_event_and_context_properties_beat_super_properties(): properties["only_super"], properties["only_event"], ) == ("event", "context", 1, 1) + + +DEFAULTS_CONFIG = { + "super_properties": {"shared": "super", "only_super": 1}, + "super_options": {"cookieless_mode": True}, +} + + +def _recording_hook(seen): + def hook(msg): + seen.append((dict(msg["properties"]), dict(msg["options"]))) + properties = {**msg["properties"], "shared": "hook"} + return {**msg, "properties": properties, "options": {"cookieless_mode": False}} + + return hook + + +def _set_context_values(): + tag("from_context", "context") + set_context_option("product_tour_id", "context-tour") + + +def _context_capture(method): + def call(client): + if method == "capture": + with new_context(fresh=True): + _set_context_values() + return client.capture("e", distinct_id="u") + + async def immediate(): + with new_context(fresh=True): + _set_context_values() + return await client.capture_immediate("e", distinct_id="u") + + return immediate() + + return call + + +def _assert_defaults_fill_after_hook(seen, events): + hook_properties, hook_options = seen[0] + assert not {"shared", "only_super", "from_context"} & hook_properties.keys() + assert hook_options == {} + properties = events[0]["properties"] + assert ( + properties["shared"], + properties["only_super"], + properties["from_context"], + ) == ("hook", 1, "context") + assert events[0]["options"] == { + "cookieless_mode": False, + "product_tour_id": "context-tour", + } + + +def test_sync_defaults_fill_after_before_send(): + seen: list = [] + events = _sync_wire_events( + _context_capture("capture"), + before_send=_recording_hook(seen), + **DEFAULTS_CONFIG, + ) + _assert_defaults_fill_after_hook(seen, events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["capture", "capture_immediate"]) +async def test_async_defaults_fill_after_before_send(method): + seen: list = [] + events = await _async_wire_events( + _context_capture(method), before_send=_recording_hook(seen), **DEFAULTS_CONFIG + ) + _assert_defaults_fill_after_hook(seen, events) + + +def test_late_options_replace_and_remove_legacy_properties(): + def call(client): + with new_context(fresh=True): + set_context_option("product_tour_id", "context-tour") + return client.capture( + "e", distinct_id="u", properties={"$product_tour_id": "event-tour"} + ) + + events = _sync_wire_events( + call, + super_properties={"$cookieless_mode": False}, + super_options={"cookieless_mode": True}, + ) + assert events[0]["options"] == { + "cookieless_mode": True, + "product_tour_id": "context-tour", + } + assert not {"$cookieless_mode", "$product_tour_id"} & events[0]["properties"].keys() diff --git a/posthog/test/test_feature_flag_called_minimization.py b/posthog/test/test_feature_flag_called_minimization.py index b355a68d6..81cf76a1f 100644 --- a/posthog/test/test_feature_flag_called_minimization.py +++ b/posthog/test/test_feature_flag_called_minimization.py @@ -16,6 +16,7 @@ from posthog.capture_event import _to_v1_event from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client from posthog.request import GetResponse +from posthog.test.capture_helpers import record_sync_capture_sends from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.utils import system_context @@ -65,21 +66,15 @@ def _local_flag_definition(has_experiment): class _CapturedEventsMixin: - """Builds a non-sending client whose fully-enriched events are captured via - ``before_send``, so tests assert the exact wire shape after every enrichment - step (system context, super properties, $lib, ...).""" + """Builds a sync client whose fully-enriched events are recorded at upload, + so tests assert the exact wire shape after every enrichment step (system + context, super properties, $lib, ...).""" def _make_client(self, **kwargs): - captured = [] - - def before_send(msg): - captured.append(msg) - return msg - + captured = record_sync_capture_sends(self) client = Client( FAKE_TEST_API_KEY, - send=False, - before_send=before_send, + sync_mode=True, super_properties={"app_version": "1.2.3"}, **kwargs, ) diff --git a/posthog/test/test_release_id.py b/posthog/test/test_release_id.py index 109f79f44..8d72a12bf 100644 --- a/posthog/test/test_release_id.py +++ b/posthog/test/test_release_id.py @@ -10,7 +10,10 @@ 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 -from posthog.test.capture_helpers import patch_async_capture_send +from posthog.test.capture_helpers import ( + patch_async_capture_send, + record_sync_capture_sends, +) # (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 @@ -80,17 +83,10 @@ def test_env_var_resolution(self, _name, env_value, expected) -> None: 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 - + """Build a client under `env_value` and collect the events it sends.""" + events = record_sync_capture_sends(self) with _release_id_env(env_value): - client = Client( - FAKE_TEST_API_KEY, send=False, before_send=before_send, **kwargs - ) + client = Client(FAKE_TEST_API_KEY, sync_mode=True, **kwargs) return client, events @parameterized.expand(EVENT_CALLS) From 1d9640ca61fbeb0c5ee2831455a6ee74510fe9a5 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 8 Oct 2026 14:09:37 -0700 Subject: [PATCH 4/5] feat!: fill context and super values before before_send Context tags, context options, super_properties, super_options, the derived personless option and the environment $release_id now fill before before_send runs, still only into keys the event leaves unset. before_send sees every value and has the final say, including removing a super property. $set, $set_once, $groups and $group_set fill one level deep when both values are dicts. The set() and set_once() values now win key by key over a $set or $set_once in properties, as ingestion merges them. Hoisting still runs once, after the hook. --- posthog/__init__.py | 22 +++++----- posthog/async_client.py | 6 +-- posthog/capture_event.py | 32 ++++++++++---- posthog/client.py | 61 ++++++++++++++------------- posthog/contexts.py | 8 ++-- posthog/test/test_capture_event.py | 3 +- posthog/test/test_event_options.py | 68 ++++++++++++++++++++++-------- 7 files changed, 124 insertions(+), 76 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index dc729902c..d228d811e 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -309,9 +309,9 @@ def set_context_option(key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options fill options an event leaves unset, after ``before_send`` - runs. They override ``super_options``. An event's own ``options`` and - ``before_send`` changes override them. + Context options fill options an event leaves unset, before ``before_send`` + runs, so the hook sees them. They override ``super_options``. An event's + own ``options`` override them, and ``before_send`` can change them. Args: key: The option name, such as ``"process_person_profile"`` @@ -385,12 +385,14 @@ def get_context_options() -> Dict[str, Any]: requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. super_properties: Properties for every captured event. They fill only - keys the event leaves unset, after ``before_send`` runs. An event's own - properties, ``before_send`` changes and context tags override them. + keys the event leaves unset, before ``before_send`` runs. ``$set``, + ``$set_once``, ``$groups`` and ``$group_set`` fill one level deep. An + event's own properties and context tags override them, and + ``before_send`` can change or remove them. super_options: Capture options for every captured event, such as ``{"cookieless_mode": True}``. They fill only options the event leaves - unset, after ``before_send`` runs. An event's own ``options``, - ``before_send`` changes and context options override them. They also + unset, before ``before_send`` runs. An event's own ``options`` and + context options override them, and ``before_send`` can change them. They also win over an event's legacy property for the same key, such as ``$cookieless_mode``, so pass per-event overrides of that key as ``options``. @@ -417,9 +419,9 @@ def get_context_options() -> Dict[str, Any]: project_root: Root path used to determine in-app exception stack frames. privacy_mode: Capture AI usage metadata without prompt inputs or outputs. before_send: Optional callback that can modify or drop events before upload. - Return ``None`` to drop an event. It does not see context tags, context - options, ``super_properties`` or ``super_options``. They fill in after - it runs. + Return ``None`` to drop an event. Context tags, context options, + ``super_properties`` and ``super_options`` fill in before it runs, so + it can change or remove them. enable_local_evaluation: Whether to poll feature flag definitions for local evaluation when a personal API key is configured. flag_definition_cache_provider: Optional external cache provider for sharing diff --git a/posthog/async_client.py b/posthog/async_client.py index 1c3c79ed3..5c25e07a2 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -453,10 +453,8 @@ def _prepare_event( async def _process_event( self, msg: dict[str, Any], defaults: Optional[_EventDefaults] ) -> Optional[dict[str, Any]]: - processed = await self._run_before_send(msg) - if processed is not None: - _fill_event_defaults(processed, defaults) - return processed + _fill_event_defaults(msg, defaults) + return await self._run_before_send(msg) async def _run_before_send(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: if self.before_send is None: diff --git a/posthog/capture_event.py b/posthog/capture_event.py index 3bae1706b..afddcc39c 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -40,6 +40,9 @@ # Top-level legacy keys relocated into properties (v1 has no top-level form). _RELOCATE_TO_PROPERTIES = ("$set", "$set_once") +# Properties that defaults fill one level deep when both values are dicts. +_NESTED_FILL_PROPERTIES = frozenset({"$set", "$set_once", "$groups", "$group_set"}) + # Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). _STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") @@ -92,8 +95,8 @@ def _event_options(value: Any) -> dict[str, Any]: class _EventDefaults: """Context, global and SDK-derived values for one event, highest layer first. - They fill in after ``before_send``, so the hook never sees them, and the - event's own values and the hook's changes always win. + They fill in before ``before_send``, so the hook sees them and can change + or remove them. The event's own values win over every default. """ property_layers: tuple[Mapping[str, Any], ...] = () @@ -132,7 +135,9 @@ def _fill_event_defaults( """Fill the keys an event left unset from ``defaults``, layer by layer. A property is unset only when its key is missing. An option is unset when - it is missing or ``None``, the same rule hoisting uses. + it is missing or ``None``, the same rule hoisting uses. ``$set``, + ``$set_once``, ``$groups`` and ``$group_set`` fill one level deep when the + event's value and the default are both dicts. """ if defaults is None: return @@ -143,9 +148,18 @@ def _fill_event_defaults( msg["properties"] = properties for property_layer in defaults.property_layers: for key, value in property_layer.items(): - if key in properties or (allowlist is not None and key not in allowlist): + if allowlist is not None and key not in allowlist: + continue + if key not in properties: + properties[key] = _clean(value) continue - properties[key] = _clean(value) + existing = properties[key] + if ( + key in _NESTED_FILL_PROPERTIES + and isinstance(existing, dict) + and isinstance(value, Mapping) + ): + properties[key] = {**_clean(dict(value)), **existing} options = _event_options(msg.get("options")) for option_layer in defaults.option_layers: for key, value in option_layer.items(): @@ -177,16 +191,16 @@ def _to_v1_event(msg: dict) -> dict: properties = dict(msg.get("properties") or {}) # Relocate top-level $set/$set_once into properties; v1 has no top-level - # form. On the unusual collision where properties already carries the key, - # the properties value wins. + # form. The top-level value comes from the set() or set_once() call, so it + # wins key by key over a $set in properties, as ingestion merges them. for key in _RELOCATE_TO_PROPERTIES: top_val = msg.get(key) if top_val is None: continue existing = properties.get(key) if isinstance(top_val, dict) and isinstance(existing, dict): - properties[key] = {**top_val, **existing} - elif key not in properties: + properties[key] = {**existing, **top_val} + else: properties[key] = top_val for key in _STRIP_FROM_PROPERTIES: diff --git a/posthog/client.py b/posthog/client.py index 45de16979..69edd111e 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -823,14 +823,15 @@ def __init__( requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. super_properties: Properties for every captured event. They fill - only keys the event leaves unset, after ``before_send`` runs. - An event's own properties, ``before_send`` changes and context - tags override them. + only keys the event leaves unset, before ``before_send`` runs. + ``$set``, ``$set_once``, ``$groups`` and ``$group_set`` fill + one level deep. An event's own properties and context tags + override them, and ``before_send`` can change or remove them. super_options: Capture options for every captured event, such as ``{"cookieless_mode": True}``. They fill only options the event - leaves unset, after ``before_send`` runs. An event's own - ``options``, ``before_send`` changes and context options - override them. They also win over an event's legacy property + leaves unset, before ``before_send`` runs. An event's own + ``options`` and context options override them, and + ``before_send`` can change them. They also win over an event's legacy property for the same key, such as ``$cookieless_mode``, so pass per-event overrides of that key as ``options``. enable_exception_autocapture: Automatically capture uncaught @@ -847,9 +848,9 @@ def __init__( through unredacted. ``privacy_mode`` always wins. Defaults to False. before_send: Optional callback that can modify or drop events before - upload. Return ``None`` to drop an event. It does not see - context tags, context options, ``super_properties`` or - ``super_options``. They fill in after it runs. + upload. Return ``None`` to drop an event. Context tags, + context options, ``super_properties`` and ``super_options`` + fill in before it runs, so it can change or remove them. flag_fallback_cache_url: Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL. enable_local_evaluation: Whether to poll feature flag definitions for @@ -1357,9 +1358,10 @@ def set_context_option(self, key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options fill options an event leaves unset, after - ``before_send`` runs. They override ``super_options``. An event's own - ``options`` and ``before_send`` changes override them. + Context options fill options an event leaves unset, before + ``before_send`` runs, so the hook sees them. They override + ``super_options``. An event's own ``options`` override them, and + ``before_send`` can change them. Args: key: The option name, such as ``"process_person_profile"``. @@ -1703,7 +1705,7 @@ def capture( options: Capture options for this event, such as ``{"process_person_profile": False}``. Sent as given, for PostHog to validate. They override context options and - ``super_options``, which fill in after ``before_send`` runs. + ``super_options``, which fill in before ``before_send`` runs. An option set at any layer wins over its legacy ``$`` property, such as ``$process_person_profile``, set at any layer. A ``None`` option counts as unset. @@ -2540,10 +2542,22 @@ def _enqueue( if self.is_server: msg["properties"]["$is_server"] = True - # Applied after every enrichment step (system context, $lib/$lib_version) - # and again to the defaults filled in after before_send, so the final - # event shape is exactly the allowlist regardless of where a property - # came from. + _fill_event_defaults( + msg, + _build_event_defaults( + super_properties=self.super_properties, + super_options=self.super_options, + release_id=self._release_id, + context_properties=context_properties, + context_options=context_options, + derived_options=derived_options, + property_allowlist=property_allowlist, + ), + ) + + # Applied after every enrichment step (system context, $lib/$lib_version, + # filled defaults), so the final event shape is exactly the allowlist + # regardless of where a property came from. if property_allowlist is not None: msg["properties"] = { k: v for k, v in msg["properties"].items() if k in property_allowlist @@ -2566,19 +2580,6 @@ def _enqueue( self.log.exception(f"Error in before_send callback: {e}") return None - _fill_event_defaults( - msg, - _build_event_defaults( - super_properties=self.super_properties, - super_options=self.super_options, - release_id=self._release_id, - context_properties=context_properties, - context_options=context_options, - derived_options=derived_options, - property_allowlist=property_allowlist, - ), - ) - # Re-normalized after before_send, which may have replaced or removed # msg["uuid"], so the returned uuid always matches the wire event. self._normalize_event_uuid(msg) diff --git a/posthog/contexts.py b/posthog/contexts.py index cb6838d06..a6af01635 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -303,10 +303,10 @@ def set_context_option(key: str, value: Any) -> None: """ Set a capture option for every event captured within the current context. - Context options fill options an event leaves unset, after ``before_send`` - runs. They override the client's ``super_options``. An event's own - ``options`` and ``before_send`` changes override them. Child contexts - inherit them unless they are fresh. + Context options fill options an event leaves unset, before ``before_send`` + runs, so the hook sees them. They override the client's ``super_options``. + An event's own ``options`` override them, and ``before_send`` can change + them. Child contexts inherit them unless they are fresh. Args: key: The option name, such as ``"process_person_profile"`` diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py index 1e17e76a6..21380fb79 100644 --- a/posthog/test/test_capture_event.py +++ b/posthog/test/test_capture_event.py @@ -204,7 +204,6 @@ def test_top_level_set_relocated_into_properties(self, _name, key) -> None: self.assertNotIn(key, event) # not a top-level v1 field def test_top_level_set_merges_with_existing_properties_set(self) -> None: - # properties wins on key collision. msg = _legacy_msg( properties={"$set": {"a": "from_props", "b": "props_only"}}, **{"$set": {"a": "from_top", "c": "top_only"}}, @@ -212,7 +211,7 @@ def test_top_level_set_merges_with_existing_properties_set(self) -> None: event = _to_v1_event(msg) self.assertEqual( event["properties"]["$set"], - {"a": "from_props", "b": "props_only", "c": "top_only"}, + {"a": "from_top", "b": "props_only", "c": "top_only"}, ) def test_groups_left_in_properties(self) -> None: diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index 7e5753905..220fcf727 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -252,7 +252,10 @@ async def test_async_event_and_context_properties_beat_super_properties(): def _recording_hook(seen): def hook(msg): seen.append((dict(msg["properties"]), dict(msg["options"]))) - properties = {**msg["properties"], "shared": "hook"} + properties = { + **{k: v for k, v in msg["properties"].items() if k != "only_super"}, + "shared": "hook", + } return {**msg, "properties": properties, "options": {"cookieless_mode": False}} return hook @@ -280,40 +283,71 @@ async def immediate(): return call -def _assert_defaults_fill_after_hook(seen, events): +def _assert_hook_sees_defaults_and_has_final_say(seen, events): hook_properties, hook_options = seen[0] - assert not {"shared", "only_super", "from_context"} & hook_properties.keys() - assert hook_options == {} - properties = events[0]["properties"] assert ( - properties["shared"], - properties["only_super"], - properties["from_context"], - ) == ("hook", 1, "context") - assert events[0]["options"] == { - "cookieless_mode": False, - "product_tour_id": "context-tour", - } + hook_properties["shared"], + hook_properties["only_super"], + hook_properties["from_context"], + ) == ("super", 1, "context") + assert hook_options == {"cookieless_mode": True, "product_tour_id": "context-tour"} + properties = events[0]["properties"] + assert (properties["shared"], properties["from_context"]) == ("hook", "context") + assert "only_super" not in properties + assert events[0]["options"] == {"cookieless_mode": False} -def test_sync_defaults_fill_after_before_send(): +def test_sync_defaults_fill_before_before_send(): seen: list = [] events = _sync_wire_events( _context_capture("capture"), before_send=_recording_hook(seen), **DEFAULTS_CONFIG, ) - _assert_defaults_fill_after_hook(seen, events) + _assert_hook_sees_defaults_and_has_final_say(seen, events) @pytest.mark.asyncio @pytest.mark.parametrize("method", ["capture", "capture_immediate"]) -async def test_async_defaults_fill_after_before_send(method): +async def test_async_defaults_fill_before_before_send(method): seen: list = [] events = await _async_wire_events( _context_capture(method), before_send=_recording_hook(seen), **DEFAULTS_CONFIG ) - _assert_defaults_fill_after_hook(seen, events) + _assert_hook_sees_defaults_and_has_final_say(seen, events) + + +NESTED_SUPER_PROPERTIES = { + "$set": {"plan": "free", "source": "super"}, + "$groups": {"company": "acme", "team": "core"}, + "$unset": ["stale"], +} + + +def test_nested_properties_fill_one_level_deep(): + events = _sync_wire_events( + lambda c: c.capture( + "e", + distinct_id="u", + properties={"$set": {"plan": "pro"}, "$unset": ["other"]}, + groups={"company": "posthog"}, + ), + super_properties=NESTED_SUPER_PROPERTIES, + ) + properties = events[0]["properties"] + assert properties["$set"] == {"plan": "pro", "source": "super"} + assert properties["$groups"] == {"company": "posthog", "team": "core"} + assert properties["$unset"] == ["other"] + + +@pytest.mark.parametrize("method", ["set", "set_once"]) +def test_set_call_wins_over_super_person_properties(method): + key = f"${method}" + events = _sync_wire_events( + lambda c: getattr(c, method)(distinct_id="u", properties={"plan": "pro"}), + super_properties={key: {"plan": "free", "source": "super"}}, + ) + assert events[0]["properties"][key] == {"plan": "pro", "source": "super"} def test_late_options_replace_and_remove_legacy_properties(): From 4120a8b7a645f12a08582ed89281a4f1b0019fbb Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 8 Oct 2026 15:48:06 -0700 Subject: [PATCH 5/5] feat!: SDK values are defaults that caller values override $is_server, $geoip_disable and system context now fill last, only keys the event, context tags and super_properties left unset, before before_send. A super property $geoip_disable: False now wins over disable_geoip=True. The groups argument merges into a $groups property key by key and wins, in Client, AsyncClient and posthog.mcp events. --- posthog/async_client.py | 46 ++++++------ posthog/capture_event.py | 30 +++++++- posthog/client.py | 38 ++++++---- posthog/mcp/_posthog_events.py | 7 +- posthog/test/mcp/test_pipeline.py | 3 +- posthog/test/test_client.py | 14 ---- posthog/test/test_event_options.py | 114 +++++++++++++++++++++++++---- 7 files changed, 176 insertions(+), 76 deletions(-) diff --git a/posthog/async_client.py b/posthog/async_client.py index 5c25e07a2..54150c0d6 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -41,6 +41,7 @@ _event_options, _EventDefaults, _fill_event_defaults, + _merge_groups, ) from .capture_send import _CAPTURE_V1_PATH from .client import ( @@ -409,7 +410,6 @@ def _normalize_uuid(self, msg: dict[str, Any]) -> str: def _prepare_event( self, msg: dict[str, Any], - disable_geoip: Optional[bool], property_allowlist=None, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: if self.disabled or not self._accepting: @@ -432,13 +432,7 @@ def _prepare_event( properties["$lib"] = "posthog-python" properties["$lib_version"] = VERSION - if disable_geoip is None: - disable_geoip = self.disable_geoip - if disable_geoip: - properties["$geoip_disable"] = True msg["options"] = msg.get("options") or {} - if self.is_server: - msg["properties"]["$is_server"] = True if property_allowlist is not None: msg["properties"] = { key: value @@ -481,7 +475,11 @@ def _event_defaults( context_options: Optional[dict[str, Any]] = None, derived_options: Optional[dict[str, Any]] = None, property_allowlist=None, + disable_geoip: Optional[bool] = None, + system_properties: Optional[dict[str, Any]] = None, ) -> _EventDefaults: + if disable_geoip is None: + disable_geoip = self.disable_geoip return _build_event_defaults( super_properties=self.super_properties, super_options=self.super_options, @@ -490,12 +488,15 @@ def _event_defaults( context_options=context_options, derived_options=derived_options, property_allowlist=property_allowlist, + is_server=self.is_server, + disable_geoip=disable_geoip, + system_properties=system_properties, ) def _build_capture_event( self, event: str, kwargs: OptionalCaptureArgs - ) -> tuple[dict[str, Any], Optional[bool], Any, _EventDefaults]: - properties = {**(kwargs.get("properties") or {}), **system_context()} + ) -> tuple[dict[str, Any], Any, _EventDefaults]: + properties = dict(kwargs.get("properties") or {}) if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} properties = _add_context_session_id(properties) @@ -503,7 +504,7 @@ def _build_capture_event( distinct_id, personless = _get_identity_state(kwargs.get("distinct_id")) groups = kwargs.get("groups") if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) flags_snapshot = kwargs.get("flags") send_feature_flags = kwargs.get("send_feature_flags") @@ -526,13 +527,14 @@ def _build_capture_event( "uuid": kwargs.get("uuid"), "options": _event_options(kwargs.get("options")), }, - kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), self._event_defaults( context_properties=_context_tag_defaults(), context_options=_get_context_options(), derived_options=_personless_options(personless), property_allowlist=kwargs.get("_property_allowlist"), + disable_geoip=kwargs.get("disable_geoip"), + system_properties=system_context(), ), ) @@ -541,12 +543,8 @@ def capture( ) -> Optional[str]: """Queue an event without blocking for network delivery.""" try: - msg, disable_geoip, property_allowlist, defaults = ( - self._build_capture_event(event, kwargs) - ) - prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist - ) + msg, property_allowlist, defaults = self._build_capture_event(event, kwargs) + prepared, sent_uuid = self._prepare_event(msg, property_allowlist) if prepared is None or sent_uuid is None: return None if not self.send: @@ -588,12 +586,8 @@ async def capture_immediate( self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1 error_batch: list[dict[str, Any]] = [] try: - msg, disable_geoip, property_allowlist, defaults = ( - self._build_capture_event(event, kwargs) - ) - prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist - ) + msg, property_allowlist, defaults = self._build_capture_event(event, kwargs) + prepared, sent_uuid = self._prepare_event(msg, property_allowlist) if prepared is None or sent_uuid is None: return None processed = await self._process_event(prepared, defaults) @@ -771,12 +765,14 @@ def _enqueue_built_event( disable_geoip: Optional[bool], context_options: Optional[dict[str, Any]] = None, ) -> Optional[str]: - prepared, sent_uuid = self._prepare_event(msg, disable_geoip) + prepared, sent_uuid = self._prepare_event(msg) if prepared is None or sent_uuid is None: return None if not self.send: return sent_uuid - defaults = self._event_defaults(context_options=context_options) + defaults = self._event_defaults( + context_options=context_options, disable_geoip=disable_geoip + ) if not self._enqueue_prepared_event(prepared, defaults): return None return sent_uuid diff --git a/posthog/capture_event.py b/posthog/capture_event.py index afddcc39c..a7acabd7b 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -113,13 +113,26 @@ def _build_event_defaults( context_options: Optional[Mapping[str, Any]] = None, derived_options: Optional[Mapping[str, Any]] = None, property_allowlist: Optional[Collection[str]] = None, + is_server: bool = False, + disable_geoip: bool = False, + system_properties: Optional[Mapping[str, Any]] = None, ) -> _EventDefaults: """Order the layers: context, then global, then values the SDK derives.""" - # An explicit `$release_id` in the event or in super properties wins over - # the environment value. - release = {"$release_id": release_id} if release_id is not None else {} + # A value in the event, the context or super properties wins over every + # value the SDK adds, including `$is_server` and `$geoip_disable`. + sdk_properties = dict(system_properties or {}) + if release_id is not None: + sdk_properties["$release_id"] = release_id + if is_server: + sdk_properties["$is_server"] = True + if disable_geoip: + sdk_properties["$geoip_disable"] = True return _EventDefaults( - property_layers=(context_properties or {}, super_properties or {}, release), + property_layers=( + context_properties or {}, + super_properties or {}, + sdk_properties, + ), option_layers=( context_options or {}, _event_options(super_options), @@ -168,6 +181,15 @@ def _fill_event_defaults( msg["options"] = options +def _merge_groups(properties: dict[str, Any], groups: Mapping[str, Any]) -> None: + """Merge typed ``groups`` into the ``$groups`` property; ``groups`` wins key by key.""" + existing = properties.get("$groups") + if isinstance(existing, dict): + properties["$groups"] = {**existing, **groups} + else: + properties["$groups"] = dict(groups) + + def _v1_timestamp(timestamp: Any) -> str: """Return a UTC RFC3339 timestamp string. diff --git a/posthog/client.py b/posthog/client.py index 69edd111e..1ce5108ba 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -39,6 +39,7 @@ _canonical_event_uuid, _event_options, _fill_event_defaults, + _merge_groups, ) from posthog.capture_send import ( _CAPTURE_AI_V1_PATH, @@ -812,10 +813,13 @@ def __init__( accepts a Project Secret API Key. disabled: If True, disable captures and API requests. Useful in tests. disable_geoip: Whether to disable server-side GeoIP enrichment. - Defaults to True. + Defaults to True. Events get ``$geoip_disable`` only when the + event, context tags and ``super_properties`` leave it unset. is_server: Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI - so the device OS is attributed to the person normally. + so the device OS is attributed to the person normally. Events + get ``$is_server`` only when the event, context tags and + ``super_properties`` leave it unset. historical_migration: Mark events as historical migration imports. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests. @@ -849,8 +853,10 @@ def __init__( False. before_send: Optional callback that can modify or drop events before upload. Return ``None`` to drop an event. Context tags, - context options, ``super_properties`` and ``super_options`` - fill in before it runs, so it can change or remove them. + context options, ``super_properties``, ``super_options`` and + the values the SDK adds, such as ``$is_server``, + ``$geoip_disable`` and ``$os``, fill in before it runs, so it + can change or remove them. flag_fallback_cache_url: Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL. enable_local_evaluation: Whether to poll feature flag definitions for @@ -1694,14 +1700,17 @@ def capture( uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. - groups: A dictionary of group information. + groups: A dictionary of group information. It merges into a + ``$groups`` property and wins key by key. flags: A FeatureFlagEvaluations snapshot from evaluate_flags(). The exact values from the snapshot are attached with no extra /flags request. send_feature_flags: Deprecated. Prefer flags=... from evaluate_flags(). When truthy, evaluates flags during capture and attaches them to the event. - disable_geoip: Whether to disable GeoIP for this event. + disable_geoip: Whether to disable GeoIP for this event. A + ``$geoip_disable`` property in the event, context tags or + ``super_properties`` wins. options: Capture options for this event, such as ``{"process_person_profile": False}``. Sent as given, for PostHog to validate. They override context options and @@ -1788,7 +1797,7 @@ def _capture( # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) - properties = {**(properties or {}), **system_context()} + properties = dict(properties or {}) if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} @@ -1807,7 +1816,7 @@ def _capture( } if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) extra_properties: dict[str, Any] = {} @@ -1908,6 +1917,7 @@ def _capture( context_properties=_context_tag_defaults(), context_options=_context_get_context_options(), derived_options=_personless_options(personless), + system_properties=system_context(), ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -2499,6 +2509,7 @@ def _enqueue( context_properties=None, context_options=None, derived_options=None, + system_properties=None, ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2532,16 +2543,8 @@ def _enqueue( if disable_geoip is None: disable_geoip = self.disable_geoip - if disable_geoip: - msg["properties"]["$geoip_disable"] = True - msg["options"] = msg.get("options") or {} - # Super properties fill only keys the event left unset, so they cannot - # override this SDK's server classification. - if self.is_server: - msg["properties"]["$is_server"] = True - _fill_event_defaults( msg, _build_event_defaults( @@ -2552,6 +2555,9 @@ def _enqueue( context_options=context_options, derived_options=derived_options, property_allowlist=property_allowlist, + is_server=self.is_server, + disable_geoip=disable_geoip, + system_properties=system_properties, ), ) diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index 90040011a..0ee8e31f0 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone from typing import Any, Dict, List +from posthog.capture_event import _merge_groups + from .constants import ( POSTHOG_MCP_ANALYTICS_SOURCE, PostHogMCPAnalyticsEvent, @@ -63,9 +65,9 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent: _add_session_id(event, properties) _add_conversation_id(event, properties) _add_person_processing(event, properties) - _add_groups(event, properties) _add_common_properties(event, properties) _add_custom_properties(event, properties) + _add_groups(event, properties) _add_server_build(event, properties) event_name = ( @@ -92,9 +94,10 @@ def _add_conversation_id(event: Event, properties: Dict[str, Any]) -> None: def _add_groups(event: Event, properties: Dict[str, Any]) -> None: + """Merge the typed groups over a custom ``$groups`` property, key by key.""" groups = event.get("groups") if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) def _add_person_processing(event: Event, properties: Dict[str, Any]) -> None: diff --git a/posthog/test/mcp/test_pipeline.py b/posthog/test/mcp/test_pipeline.py index 81b6db644..1cc6008d2 100644 --- a/posthog/test/mcp/test_pipeline.py +++ b/posthog/test/mcp/test_pipeline.py @@ -925,6 +925,7 @@ def test_identity_enables_person_processing_and_set(): "identify_actor_given_id": "user_1", "identify_actor_data": {"email": "a@b.com"}, "groups": {"organization": "org_1"}, + "properties": {"$groups": {"organization": "custom", "project": "p1"}}, "timestamp": datetime.now(timezone.utc), } [capture] = build_posthog_capture_events(event) @@ -932,7 +933,7 @@ def test_identity_enables_person_processing_and_set(): assert capture["distinct_id"] == "user_1" assert "$process_person_profile" not in props assert props["$set"] == {"email": "a@b.com"} - assert props["$groups"] == {"organization": "org_1"} + assert props["$groups"] == {"organization": "org_1", "project": "p1"} def test_listed_tool_names_only_on_tools_list(): diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 3f6052b81..c2c3e9ad9 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -420,20 +420,6 @@ def test_capture_omits_is_server_when_disabled(self): self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertNotIn("$is_server", msg["properties"]) - def test_is_server_not_overridden_by_super_properties(self): - with patch_capture_send("client") as mock_post: - client = Client( - FAKE_TEST_API_KEY, - on_error=self.set_fail, - sync_mode=True, - super_properties={"$is_server": False}, - ) - client.capture("python test event", distinct_id="distinct_id") - self.assertFalse(self.failed) - - msg = sent_batch(mock_post)[0] - self.assertEqual(msg["properties"]["$is_server"], True) - def test_basic_capture_with_uuid(self): with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index 220fcf727..4fc89977e 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -252,8 +252,9 @@ async def test_async_event_and_context_properties_beat_super_properties(): def _recording_hook(seen): def hook(msg): seen.append((dict(msg["properties"]), dict(msg["options"]))) + removed = {"only_super", "$is_server"} properties = { - **{k: v for k, v in msg["properties"].items() if k != "only_super"}, + **{k: v for k, v in msg["properties"].items() if k not in removed}, "shared": "hook", } return {**msg, "properties": properties, "options": {"cookieless_mode": False}} @@ -289,11 +290,14 @@ def _assert_hook_sees_defaults_and_has_final_say(seen, events): hook_properties["shared"], hook_properties["only_super"], hook_properties["from_context"], - ) == ("super", 1, "context") + hook_properties["$is_server"], + hook_properties["$geoip_disable"], + ) == ("super", 1, "context", True, True) + assert "$os" in hook_properties assert hook_options == {"cookieless_mode": True, "product_tour_id": "context-tour"} properties = events[0]["properties"] assert (properties["shared"], properties["from_context"]) == ("hook", "context") - assert "only_super" not in properties + assert not {"only_super", "$is_server"} & properties.keys() assert events[0]["options"] == {"cookieless_mode": False} @@ -319,27 +323,109 @@ async def test_async_defaults_fill_before_before_send(method): NESTED_SUPER_PROPERTIES = { "$set": {"plan": "free", "source": "super"}, - "$groups": {"company": "acme", "team": "core"}, + "$groups": {"company": "acme", "team": "core", "region": "us"}, "$unset": ["stale"], } -def test_nested_properties_fill_one_level_deep(): - events = _sync_wire_events( - lambda c: c.capture( - "e", - distinct_id="u", - properties={"$set": {"plan": "pro"}, "$unset": ["other"]}, - groups={"company": "posthog"}, - ), - super_properties=NESTED_SUPER_PROPERTIES, +def _nested_capture(client): + return client.capture( + "e", + distinct_id="u", + properties={ + "$set": {"plan": "pro"}, + "$unset": ["other"], + "$groups": {"company": "event", "team": "event"}, + }, + groups={"company": "posthog"}, ) + + +def _assert_nested_properties(events): properties = events[0]["properties"] assert properties["$set"] == {"plan": "pro", "source": "super"} - assert properties["$groups"] == {"company": "posthog", "team": "core"} + assert properties["$groups"] == { + "company": "posthog", + "team": "event", + "region": "us", + } assert properties["$unset"] == ["other"] +def test_nested_properties_fill_one_level_deep(): + events = _sync_wire_events( + _nested_capture, super_properties=NESTED_SUPER_PROPERTIES + ) + _assert_nested_properties(events) + + +@pytest.mark.asyncio +async def test_async_nested_properties_fill_one_level_deep(): + events = await _async_wire_events( + _nested_capture, super_properties=NESTED_SUPER_PROPERTIES + ) + _assert_nested_properties(events) + + +CALLER_SDK_VALUES = {"$is_server": False, "$geoip_disable": False, "$os": "caller-os"} +SDK_FLAGS = {"$is_server": False, "$geoip_disable": False} + + +def _capture_with_caller_sdk_values(source): + def call(client): + with new_context(fresh=True): + if source == "context": + for key, value in CALLER_SDK_VALUES.items(): + tag(key, value) + properties = CALLER_SDK_VALUES if source == "event" else None + return client.capture("e", distinct_id="u", properties=properties) + + return call + + +def _sdk_value_config(source): + return {"super_properties": CALLER_SDK_VALUES} if source == "super" else {} + + +def _assert_caller_values(events, expected): + properties = events[0]["properties"] + assert {key: properties.get(key) for key in expected} == expected + + +@pytest.mark.parametrize("source", ["event", "context", "super"]) +def test_sync_caller_values_beat_sdk_values(source): + events = _sync_wire_events( + _capture_with_caller_sdk_values(source), **_sdk_value_config(source) + ) + _assert_caller_values(events, CALLER_SDK_VALUES) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["event", "context", "super"]) +async def test_async_caller_values_beat_sdk_values(source): + events = await _async_wire_events( + _capture_with_caller_sdk_values(source), **_sdk_value_config(source) + ) + _assert_caller_values(events, CALLER_SDK_VALUES) + + +@pytest.mark.parametrize("method", list(CAPTURE_CALLS)) +def test_sync_super_properties_beat_sdk_values_on_every_path(method): + events = _sync_wire_events( + lambda c: CAPTURE_CALLS[method](c, None), super_properties=SDK_FLAGS + ) + _assert_caller_values(events, SDK_FLAGS) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", list(ASYNC_CAPTURE_CALLS)) +async def test_async_super_properties_beat_sdk_values_on_every_path(method): + events = await _async_wire_events( + lambda c: ASYNC_CAPTURE_CALLS[method](c, None), super_properties=SDK_FLAGS + ) + _assert_caller_values(events, SDK_FLAGS) + + @pytest.mark.parametrize("method", ["set", "set_once"]) def test_set_call_wins_over_super_person_properties(method): key = f"${method}"