diff --git a/posthog/__init__.py b/posthog/__init__.py index 71e4493b..f7ed8e52 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -638,6 +638,7 @@ def group_identify( uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Set properties on a group. @@ -653,6 +654,7 @@ def group_identify( uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup distinct_id: Optional distinct ID of the user performing the action + options: Optional capture options for the event, sent as given Examples: ```python @@ -676,6 +678,7 @@ def group_identify( uuid=uuid, disable_geoip=disable_geoip, distinct_id=distinct_id, + options=options, ) @@ -685,6 +688,7 @@ def alias( timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Associate user behaviour before and after they e.g. register, login, or perform some other identifying action. @@ -696,6 +700,7 @@ def alias( datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup + options: Optional capture options for the event, sent as given Details: To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action. @@ -717,6 +722,7 @@ def alias( timestamp=timestamp, uuid=uuid, disable_geoip=disable_geoip, + options=options, ) @@ -730,7 +736,7 @@ def capture_exception( Args: exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, - timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. + timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options. Details: Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`. diff --git a/posthog/args.py b/posthog/args.py index 42083b92..1b2aa411 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -48,12 +48,16 @@ class OptionalCaptureArgs(TypedDict): hidden ``/flags`` request on capture and may return different values than the ones the code branched on. disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False. + 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``. A value that is not a dict is logged and ignored. """ distinct_id: NotRequired[Optional[ID_TYPES]] properties: NotRequired[Optional[Dict[str, Any]]] timestamp: NotRequired[Optional[Union[datetime, str]]] uuid: NotRequired[Optional[Union[str, UUID]]] + options: NotRequired[Optional[Dict[str, Any]]] groups: NotRequired[Optional[Dict[str, str]]] flags: NotRequired[Optional["FeatureFlagEvaluations"]] send_feature_flags: NotRequired[ @@ -83,6 +87,7 @@ class OptionalSetArgs(TypedDict): it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False. + options: Capture options for this event, sent as given. See ``OptionalCaptureArgs``. """ distinct_id: NotRequired[Optional[ID_TYPES]] @@ -90,6 +95,7 @@ class OptionalSetArgs(TypedDict): timestamp: NotRequired[Optional[Union[datetime, str]]] uuid: NotRequired[Optional[Union[str, UUID]]] disable_geoip: NotRequired[Optional[bool]] + options: NotRequired[Optional[Dict[str, Any]]] ExcInfo = Union[ diff --git a/posthog/async_client.py b/posthog/async_client.py index 37dfd229..5597d79c 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -35,6 +35,7 @@ CaptureCompression, _resolve_capture_compression, ) +from .capture_event import _canonical_event_uuid, _event_options from .capture_send import _CAPTURE_V1_PATH from .client import ( MAX_DICT_SIZE as _MAX_DICT_SIZE, @@ -378,9 +379,8 @@ def enqueue_on_bound_loop() -> None: def _normalize_uuid(self, msg: dict[str, Any]) -> str: raw_uuid = msg.pop("uuid", None) if raw_uuid is not None: - try: - normalized = str(UUID(str(raw_uuid))) - except (TypeError, ValueError, AttributeError): + normalized = _canonical_event_uuid(raw_uuid) + if normalized is None: self.log.error( "Invalid UUID %r. Falling back to a generated UUID.", raw_uuid ) @@ -493,6 +493,7 @@ def _build_capture_event( "distinct_id": distinct_id, "event": event, "uuid": kwargs.get("uuid"), + "options": _event_options(kwargs.get("options")), }, kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), @@ -611,6 +612,7 @@ def _build_person_properties_event( property_key: properties, "event": event, "uuid": kwargs.get("uuid"), + "options": _event_options(kwargs.get("options")), } def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: @@ -646,6 +648,7 @@ def group_identify( uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: try: if not _stringify_id(group_type): @@ -670,6 +673,7 @@ def group_identify( "distinct_id": resolved_distinct_id, "timestamp": timestamp, "uuid": uuid, + "options": _event_options(options), } session_id = _get_context_session_id() if session_id: @@ -688,6 +692,7 @@ def alias( timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: try: resolved_previous_id = _stringify_id(previous_id) @@ -707,6 +712,7 @@ def alias( "event": "$create_alias", "distinct_id": resolved_previous_id, "uuid": uuid, + "options": _event_options(options), } session_id = _get_context_session_id() if session_id: @@ -809,6 +815,7 @@ def capture_exception( groups=kwargs.get("groups"), flags=kwargs.get("flags"), disable_geoip=kwargs.get("disable_geoip"), + options=kwargs.get("options"), ) if exception is not None and result is not None: mark_exception_as_captured(exception, result) diff --git a/posthog/capture_event.py b/posthog/capture_event.py index bc50a462..5afb3156 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -7,9 +7,10 @@ the legacy queued-message shape in a few load-bearing ways that this module encodes: -- A typed ``options`` object carries a handful of sentinel properties, renamed - and strictly typed. Wrong JSON types fail deserialization of the *whole - batch*, so values are coerced to native types or omitted entirely. +- An ``options`` object carries per-event processing options. Options the + caller sets are sent as given, for PostHog to validate. Four legacy ``$`` + properties fill the matching option when the caller left it unset, and are + always removed from ``properties``. - ``$set``/``$set_once`` have no top-level form in v1; the server reads them from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at the top level, so they are relocated into ``properties`` here. @@ -17,12 +18,16 @@ ``PostHog-Sdk-Info`` header and are stripped from v1 properties. """ -from collections.abc import Callable +import logging +import re from datetime import datetime, timezone from typing import Any, Optional +from uuid import UUID from posthog.utils import _normalize_timestamp +log = logging.getLogger("posthog") + # Sentinel properties lifted to top-level string fields on the event. _TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( ("$session_id", "session_id"), @@ -35,45 +40,49 @@ # Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). _STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") +# Legacy properties and the option each one fills. The order matches posthog-rs +# and posthog-go. +_LEGACY_OPTION_PROPERTIES: tuple[tuple[str, str], ...] = ( + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$process_person_profile", "process_person_profile"), +) + +# The uuid forms Go's uuid.Validate accepts. Python's UUID() also accepts +# misplaced hyphens and a bare "uuid:" prefix, which other SDKs reject. +_EVENT_UUID_PATTERN = re.compile( + r"(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + r"|\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}" + r"|[0-9a-f]{32}", + re.IGNORECASE, +) + -def _coerce_bool(value: Any) -> Optional[bool]: - """Coerce a sentinel value to ``bool`` using the backend's truthiness rules. +def _canonical_event_uuid(value: Any) -> Optional[str]: + """Return the canonical form of a caller's event uuid, or None if invalid. - Native bool passes through; ``"true"``/``"1"`` and ``"false"``/``"0"`` - (case-insensitive, trimmed) map to the obvious bool; any other numeric value - is nonzero-truthy. Anything else returns ``None`` so the option is omitted - rather than sent with a type the strict v1 schema would reject. + Capture keys per-event results by the canonical lowercase hyphenated form, + so a uuid sent in any other form would never match its result. """ - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in ("true", "1"): - return True - if normalized in ("false", "0"): - return False + if isinstance(value, UUID): + return str(value) + if not isinstance(value, str) or not _EVENT_UUID_PATTERN.fullmatch(value): return None - if isinstance(value, (int, float)): - return value != 0 - return None - - -def _coerce_str(value: Any) -> Optional[str]: - """Accept only ``str`` (the backend's ``product_tour_id`` is ``Option``).""" - return value if isinstance(value, str) else None - - -# Sentinel properties lifted into the typed `options` object: legacy property -# key, the backend's field name, and the coercer enforcing its strict type -# (wrong JSON types fail deserialization of the whole batch, so a value that -# won't coerce is omitted). The coercer is stored directly to keep the dispatch -# type-checked rather than keyed by a stringly-typed name. -_OPTION_SENTINELS: tuple[tuple[str, str, Callable[[Any], Any]], ...] = ( - ("$cookieless_mode", "cookieless_mode", _coerce_bool), - ("$ignore_sent_at", "disable_skew_correction", _coerce_bool), - ("$product_tour_id", "product_tour_id", _coerce_str), - ("$process_person_profile", "process_person_profile", _coerce_bool), -) + return str(UUID(value.lower())) + + +def _event_options(value: Any) -> dict[str, Any]: + """Return a copy of a caller's ``options``, or ``{}`` when it is not a dict.""" + if value is None: + return {} + if not isinstance(value, dict): + log.error( + "options must be a dict, got %s. Sending the event without them.", + type(value).__name__, + ) + return {} + return dict(value) def _v1_timestamp(timestamp: Any) -> str: @@ -114,23 +123,24 @@ def _to_v1_event(msg: dict) -> dict: for key in _STRIP_FROM_PROPERTIES: properties.pop(key, None) - options: dict[str, Any] = {} - for prop_key, wire_key, coercer in _OPTION_SENTINELS: + options = _event_options(msg.get("options")) + for prop_key, option_key in _LEGACY_OPTION_PROPERTIES: if prop_key not in properties: continue - # Always removed from properties — these sentinels must never reach v1 - # backend properties — but only emitted as an option when coercible. - coerced = coercer(properties.pop(prop_key)) - if coerced is not None: - options[wire_key] = coerced + legacy = properties.pop(prop_key) + # A null option counts as unset, so the legacy value fills it. + if options.get(option_key) is None: + options[option_key] = legacy top_level: dict[str, str] = {} for prop_key, field_name in _TOPLEVEL_SENTINELS: if prop_key not in properties: continue - coerced_str = _coerce_str(properties.pop(prop_key)) - if coerced_str is not None: - top_level[field_name] = coerced_str + # Always removed. A non-string value would fail the whole batch, so it + # is dropped. + value = properties.pop(prop_key) + if isinstance(value, str): + top_level[field_name] = value event = { "event": msg["event"], diff --git a/posthog/client.py b/posthog/client.py index cbf24e9f..3835f9af 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -33,6 +33,7 @@ _resolve_capture_ai_compression, _resolve_capture_compression, ) +from posthog.capture_event import _canonical_event_uuid, _event_options from posthog.capture_send import ( _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, @@ -261,22 +262,12 @@ def get_identity_state(passed) -> tuple[str, bool]: def _stringify_event_uuid(value) -> str: - if isinstance(value, UUID): - return str(value) - - stringified = stringify_id(value) - if not stringified: + canonical = _canonical_event_uuid(value) + if canonical is None: raise ValueError( f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance." ) - - try: - # Canonical form, because capture keys per-event results by it. - return str(UUID(stringified)) - except ValueError: - raise ValueError( - f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance." - ) from None + return canonical def add_context_tags(properties): @@ -346,8 +337,14 @@ def wrapper(self, *args, **kwargs): # Correctness-required "$groups", "$process_person_profile", + # Option sources: legacy properties move into options after this + # allowlist runs, so dropping them would drop the option. + "$cookieless_mode", + "$ignore_sent_at", + "$product_tour_id", # Linkage / SDK identity "$session_id", + "$window_id", "$lib", "$lib_version", "$is_server", @@ -1630,6 +1627,10 @@ def capture( evaluate_flags(). When truthy, evaluates flags during capture and attaches them to the event. 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``. Examples: ```python @@ -1704,6 +1705,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 = _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) @@ -1727,6 +1729,7 @@ def _capture( "distinct_id": distinct_id, "event": event, "uuid": uuid, + "options": options, } if groups: @@ -1912,6 +1915,7 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set": properties, "event": "$set", "uuid": uuid, + "options": _event_options(kwargs.get("options", None)), } return self._enqueue(msg, disable_geoip) @@ -1961,6 +1965,7 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set_once": properties, "event": "$set_once", "uuid": uuid, + "options": _event_options(kwargs.get("options", None)), } return self._enqueue(msg, disable_geoip) @@ -1975,6 +1980,7 @@ def group_identify( uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Identify a group and set its properties. @@ -1992,6 +1998,7 @@ def group_identify( ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP for this event. distinct_id: The distinct ID of the user performing the action. + options: Capture options for this event, sent as given. Examples: ```python @@ -2033,6 +2040,7 @@ def group_identify( "distinct_id": distinct_id, "timestamp": timestamp, "uuid": uuid, + "options": _event_options(options), } # NOTE - group_identify doesn't generally use context properties - should it? @@ -2049,6 +2057,7 @@ def alias( timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Create an alias between two distinct IDs. @@ -2065,6 +2074,7 @@ def alias( valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP for this event. + options: Capture options for this event, sent as given. Examples: ```python @@ -2101,6 +2111,7 @@ def alias( "event": "$create_alias", "distinct_id": previous_id, "uuid": uuid, + "options": _event_options(options), } if get_context_session_id(): @@ -2245,6 +2256,7 @@ def capture_exception( flags=flags_snapshot, send_feature_flags=send_feature_flags, disable_geoip=disable_geoip, + options=kwargs.get("options", None), ) # Mark the exception as captured to prevent duplicate captures diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py index e3b48195..1e17e76a 100644 --- a/posthog/test/test_capture_event.py +++ b/posthog/test/test_capture_event.py @@ -3,10 +3,11 @@ from parameterized import parameterized +from uuid import UUID + from posthog.capture_event import ( _build_v1_batch_body, - _coerce_bool, - _coerce_str, + _canonical_event_uuid, _to_v1_event, ) @@ -27,42 +28,37 @@ def _legacy_msg(event="my_event", properties=None, **overrides) -> dict: return msg -class TestCoercion(unittest.TestCase): +_CANONICAL_UUID = "0190a8f3-1b2c-7d4e-8f90-123456789abc" + + +class TestCanonicalEventUuid(unittest.TestCase): @parameterized.expand( [ - ("bool_true", True, True), - ("bool_false", False, False), - ("str_true", "true", True), - ("str_true_upper", "TRUE", True), - ("str_true_padded", " true ", True), - ("str_one", "1", True), - ("str_false", "false", False), - ("str_zero", "0", False), - ("int_nonzero", 5, True), - ("int_zero", 0, False), - ("float_nonzero", 1.5, True), - ("float_zero", 0.0, False), - ("neg_int", -1, True), - ("str_yes_uncoercible", "yes", None), - ("str_empty_uncoercible", "", None), - ("none_uncoercible", None, None), - ("dict_uncoercible", {"a": 1}, None), + ("canonical", _CANONICAL_UUID), + ("uppercase", _CANONICAL_UUID.upper()), + ("unhyphenated", _CANONICAL_UUID.replace("-", "")), + ("braced", "{" + _CANONICAL_UUID + "}"), + ("urn", "urn:uuid:" + _CANONICAL_UUID), + ("urn_uppercase", "URN:UUID:" + _CANONICAL_UUID.upper()), + ("uuid_instance", UUID(_CANONICAL_UUID)), ] ) - def test_coerce_bool(self, _name, value, expected) -> None: - self.assertIs(_coerce_bool(value), expected) + def test_accepted_forms_are_canonicalized(self, _name, value) -> None: + self.assertEqual(_canonical_event_uuid(value), _CANONICAL_UUID) @parameterized.expand( [ - ("str", "tour-1", "tour-1"), - ("empty_str", "", ""), - ("int", 123, None), - ("bool", True, None), - ("none", None, None), + ("bare_uuid_prefix", "uuid:" + _CANONICAL_UUID), + ("misplaced_hyphen", "0190a8f31-b2c-7d4e-8f90-123456789abc"), + ("surrounding_whitespace", " " + _CANONICAL_UUID), + ("braced_unhyphenated", "{" + _CANONICAL_UUID.replace("-", "") + "}"), + ("too_short", _CANONICAL_UUID[:-1]), + ("empty", ""), + ("int", 123), ] ) - def test_coerce_str(self, _name, value, expected) -> None: - self.assertEqual(_coerce_str(value), expected) + def test_other_forms_are_rejected(self, _name, value) -> None: + self.assertIsNone(_canonical_event_uuid(value)) class TestToV1Event(unittest.TestCase): @@ -94,60 +90,64 @@ def test_does_not_leak_non_wire_top_level_keys(self) -> None: def test_does_not_mutate_input(self) -> None: msg = _legacy_msg( properties={"$cookieless_mode": True, "$session_id": "s-1"}, + options={"product_tour_id": "tour-1"}, **{"$set": {"name": "Max"}}, ) original_properties = dict(msg["properties"]) _to_v1_event(msg) self.assertEqual(msg["properties"], original_properties) + self.assertEqual(msg["options"], {"product_tour_id": "tour-1"}) self.assertIn("$set", msg) # top-level $set untouched on the original @parameterized.expand( [ - ("cookieless_mode", "$cookieless_mode", "cookieless_mode", True, True), - ( - "ignore_sent_at_rename", - "$ignore_sent_at", - "disable_skew_correction", - "true", - True, - ), + ("cookieless_mode", "$cookieless_mode", "cookieless_mode", True), + ("ignore_sent_at", "$ignore_sent_at", "disable_skew_correction", "true"), ( "process_person_profile", "$process_person_profile", "process_person_profile", - "false", - False, - ), - ( - "product_tour_id", - "$product_tour_id", - "product_tour_id", - "tour-7", - "tour-7", + 0, ), + ("product_tour_id", "$product_tour_id", "product_tour_id", 123), ] ) - def test_option_sentinels_lifted_renamed_and_coerced( - self, _name, prop_key, wire_key, raw, expected + def test_legacy_property_fills_option_unchanged( + self, _name, prop_key, option_key, raw ) -> None: event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - self.assertEqual(event["options"], {wire_key: expected}) + self.assertEqual(event["options"], {option_key: raw}) self.assertNotIn(prop_key, event["properties"]) @parameterized.expand( [ - ("bad_bool", "$cookieless_mode", "maybe"), - ("bad_tour_id_int", "$product_tour_id", 123), + ("option_set", {"cookieless_mode": False}, False), + ("option_null", {"cookieless_mode": None}, True), + ("option_missing", {}, True), ] ) - def test_option_sentinel_removed_but_omitted_on_bad_coercion( - self, _name, prop_key, raw + def test_caller_option_wins_over_legacy_property( + self, _name, options, expected ) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - # Removed from properties (sentinels must never reach v1 props) but not - # emitted as an option, so a wrong type cannot 400 the whole batch. - self.assertNotIn(prop_key, event["properties"]) - self.assertEqual(event["options"], {}) + event = _to_v1_event( + _legacy_msg(properties={"$cookieless_mode": True}, options=options) + ) + self.assertEqual(event["options"], {"cookieless_mode": expected}) + self.assertNotIn("$cookieless_mode", event["properties"]) + + def test_caller_options_pass_through_unchanged(self) -> None: + options = {"process_person_profile": "false", "future_option": {"a": [1]}} + event = _to_v1_event(_legacy_msg(options=options)) + self.assertEqual(event["options"], options) + + @parameterized.expand([("list", ["x"]), ("string", "x"), ("int", 1)]) + def test_non_dict_options_are_logged_and_ignored(self, _name, options) -> None: + with self.assertLogs("posthog", level="ERROR") as logs: + event = _to_v1_event( + _legacy_msg(properties={"$cookieless_mode": True}, options=options) + ) + self.assertEqual(event["options"], {"cookieless_mode": True}) + self.assertIn("options must be a dict", logs.output[0]) @parameterized.expand( [ @@ -170,9 +170,9 @@ def test_all_sentinels_together(self) -> None: _legacy_msg( properties={ "$cookieless_mode": True, - "$ignore_sent_at": "1", + "$ignore_sent_at": True, "$product_tour_id": "tour-x", - "$process_person_profile": 0, + "$process_person_profile": False, "$session_id": "s-1", "$window_id": "w-1", "$geoip_disable": True, diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py new file mode 100644 index 00000000..33e6c3a5 --- /dev/null +++ b/posthog/test/test_event_options.py @@ -0,0 +1,111 @@ +import logging + +import pytest + +from posthog import AsyncPosthog +from posthog.capture_event import _to_v1_event +from posthog.client import Client +from posthog.test.capture_helpers import ( + patch_async_capture_send, + patch_capture_send, + sent_events, +) +from posthog.test.test_utils import FAKE_TEST_API_KEY + +OPTIONS = {"cookieless_mode": True, "future_option": "kept"} + +CAPTURE_CALLS = { + "capture": lambda c, o: c.capture("e", distinct_id="u", options=o), + "capture_ai": lambda c, o: c.capture_ai( + "$ai_generation", distinct_id="u", options=o + ), + "set": lambda c, o: c.set(distinct_id="u", properties={"a": 1}, options=o), + "set_once": lambda c, o: c.set_once( + distinct_id="u", properties={"a": 1}, options=o + ), + "group_identify": lambda c, o: c.group_identify( + "company", "acme", distinct_id="u", options=o + ), + "alias": lambda c, o: c.alias("previous", "u", options=o), + "capture_exception": lambda c, o: c.capture_exception( + ValueError("boom"), distinct_id="u", options=o + ), +} +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]: + with patch_capture_send("client") as send: + client = Client(FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send) + 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]: + 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: + 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] + + +@pytest.mark.parametrize("method", list(CAPTURE_CALLS)) +def test_sync_options_reach_the_wire(method): + events = _sync_wire_events(lambda c: CAPTURE_CALLS[method](c, OPTIONS)) + assert [e["options"] for e in events] == [OPTIONS] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", list(ASYNC_CAPTURE_CALLS)) +async def test_async_options_reach_the_wire(method): + events = await _async_wire_events(lambda c: ASYNC_CAPTURE_CALLS[method](c, OPTIONS)) + assert [e["options"] for e in events] == [OPTIONS] + + +def _hook(msg): + properties = {k: v for k, v in msg["properties"].items() if k != "$product_tour_id"} + return {**msg, "options": {"cookieless_mode": False}, "properties": properties} + + +def _hooked_capture(client): + return client.capture( + "e", + distinct_id="u", + properties={"$product_tour_id": "tour-1"}, + options={"cookieless_mode": True}, + ) + + +def test_sync_before_send_changes_options_and_legacy_properties(): + events = _sync_wire_events(_hooked_capture, before_send=_hook) + assert events[0]["options"] == {"cookieless_mode": False} + + +@pytest.mark.asyncio +async def test_async_before_send_changes_options_and_legacy_properties(): + events = await _async_wire_events(_hooked_capture, before_send=_hook) + assert events[0]["options"] == {"cookieless_mode": False} + + +def test_sync_non_dict_options_are_logged_and_event_is_sent(caplog): + with caplog.at_level(logging.ERROR, logger="posthog"): + events = _sync_wire_events( + lambda c: c.capture("e", distinct_id="u", options=["cookieless_mode"]) + ) + assert events[0]["options"] == {} + assert "options must be a dict" in caplog.text + + +@pytest.mark.asyncio +async def test_async_non_dict_options_are_logged_and_event_is_sent(caplog): + with caplog.at_level(logging.ERROR, logger="posthog"): + events = await _async_wire_events( + lambda c: c.capture("e", distinct_id="u", options=["cookieless_mode"]) + ) + assert events[0]["options"] == {} + assert "options must be a dict" in caplog.text diff --git a/posthog/test/test_feature_flag_called_minimization.py b/posthog/test/test_feature_flag_called_minimization.py index c32b4ea2..b355a68d 100644 --- a/posthog/test/test_feature_flag_called_minimization.py +++ b/posthog/test/test_feature_flag_called_minimization.py @@ -13,6 +13,7 @@ from parameterized import parameterized +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.test_utils import FAKE_TEST_API_KEY @@ -153,6 +154,34 @@ def test_gated_non_experiment_flag_with_groups_keeps_group_context( self.assertEqual(properties["$groups"], {"organization": "org-1"}) self.assertLessEqual(set(properties), _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES) + @mock.patch("posthog.client.flags") + def test_gated_event_keeps_option_sources(self, patch_flags): + patch_flags.return_value = _flags_response(has_experiment=False, gate=True) + client, captured = self._make_client() + client.super_properties = { + "$cookieless_mode": True, + "$ignore_sent_at": True, + "$product_tour_id": "tour-1", + "$window_id": "w-1", + "app_version": "1.2.3", + } + + client.get_feature_flag_result("person-flag", "some-distinct-id") + + event = _to_v1_event( + next(m for m in captured if m["event"] == "$feature_flag_called") + ) + self.assertEqual( + event["options"], + { + "cookieless_mode": True, + "disable_skew_correction": True, + "product_tour_id": "tour-1", + }, + ) + self.assertEqual(event["window_id"], "w-1") + self.assertNotIn("app_version", event["properties"]) + @mock.patch("posthog.client.flags") def test_remote_evaluation_uses_this_responses_gate_not_a_concurrent_update( self, patch_flags diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index 6ebd8fe4..655b1d2c 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -169,6 +169,7 @@ def test_group_identify_propagates_distinct_id(self): "company_123", {"name": "Awesome Inc."}, distinct_id="user_456", + options={"cookieless_mode": True}, ) self.mock_client.group_identify.assert_called_once_with( group_type="company", @@ -178,6 +179,7 @@ def test_group_identify_propagates_distinct_id(self): uuid=None, disable_geoip=None, distinct_id="user_456", + options={"cookieless_mode": True}, ) def test_group_identify_distinct_id_defaults_to_none(self): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 282645df..07db58cf 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -641,12 +641,14 @@ attribute posthog.args.OptionalCaptureArgs.disable_geoip: NotRequired[Optional[b attribute posthog.args.OptionalCaptureArgs.distinct_id: NotRequired[Optional[ID_TYPES]] attribute posthog.args.OptionalCaptureArgs.flags: NotRequired[Optional[FeatureFlagEvaluations]] attribute posthog.args.OptionalCaptureArgs.groups: NotRequired[Optional[Dict[str, str]]] +attribute posthog.args.OptionalCaptureArgs.options: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalCaptureArgs.properties: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalCaptureArgs.send_feature_flags: NotRequired[Optional[Union[bool, SendFeatureFlagsOptions]]] attribute posthog.args.OptionalCaptureArgs.timestamp: NotRequired[Optional[Union[datetime, str]]] attribute posthog.args.OptionalCaptureArgs.uuid: NotRequired[Optional[Union[str, UUID]]] attribute posthog.args.OptionalSetArgs.disable_geoip: NotRequired[Optional[bool]] attribute posthog.args.OptionalSetArgs.distinct_id: NotRequired[Optional[ID_TYPES]] +attribute posthog.args.OptionalSetArgs.options: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalSetArgs.properties: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalSetArgs.timestamp: NotRequired[Optional[Union[datetime, str]]] attribute posthog.args.OptionalSetArgs.uuid: NotRequired[Optional[Union[str, UUID]]] @@ -689,6 +691,7 @@ attribute posthog.capture_compression.CaptureCompression.DEFLATE = 'deflate' attribute posthog.capture_compression.CaptureCompression.GZIP = 'gzip' attribute posthog.capture_compression.CaptureCompression.NONE = 'none' attribute posthog.capture_compression.CaptureCompression.ZSTD = 'zstd' +attribute posthog.capture_event.log = logging.getLogger('posthog') attribute posthog.capture_exception_code_variables = False attribute posthog.capture_send.CaptureError.attempts = attempts attribute posthog.capture_send.CaptureError.drops = drops or [] @@ -1301,7 +1304,7 @@ function posthog.ai.utils.merge_system_prompt(kwargs: Dict[str, Any], provider: function posthog.ai.utils.merge_usage_stats(target: TokenUsage, source: TokenUsage, mode: str = 'incremental') -> None function posthog.ai.utils.serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]] function posthog.ai.utils.with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any) -function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] function posthog.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1388,7 +1391,7 @@ function posthog.get_feature_flag_payload(key: str, distinct_id: ID_TYPES, match function posthog.get_feature_flag_result(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[FeatureFlagResult] function posthog.get_remote_config_payload(key: str) function posthog.get_tags() -> Dict[str, Any] -function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) function posthog.join() -> None @@ -1553,14 +1556,14 @@ method posthog.ai.prompts.Prompts.get(name: str, *, with_metadata: Optional[bool method posthog.ai.prompts.Prompts.get_all(*, label: Optional[str] = None) -> Dict[str, PromptResult] method posthog.ai.stream.AsyncStreamWrapper.aclose() -> None method posthog.ai.stream.AsyncStreamWrapper.close() -> None -method posthog.async_client.AsyncClient.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +method posthog.async_client.AsyncClient.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.async_client.AsyncClient.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.async_client.AsyncClient.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.async_client.AsyncClient.capture_immediate(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.async_client.AsyncClient.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, disable_geoip: Optional[bool] = None, flag_keys: Optional[list[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations method posthog.async_client.AsyncClient.flush(timeout_seconds: Optional[float] = 10) -> None method posthog.async_client.AsyncClient.get_remote_config_payload(key: str) -> Optional[Any] -method posthog.async_client.AsyncClient.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +method posthog.async_client.AsyncClient.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.async_client.AsyncClient.join() -> None method posthog.async_client.AsyncClient.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.async_client.AsyncClient.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] @@ -1568,7 +1571,7 @@ method posthog.async_client.AsyncClient.shutdown() -> None method posthog.bucketed_rate_limiter.BucketedRateLimiter.consume_rate_limit(key: Hashable) -> bool method posthog.bucketed_rate_limiter.BucketedRateLimiter.stop() -> None method posthog.capture_send.CaptureError.verdict_summary() -> str -method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.client.Client.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_exception(exception: Optional[ExceptionArg], **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1590,7 +1593,7 @@ method posthog.client.Client.get_feature_variants(distinct_id: ID_TYPES, groups: method posthog.client.Client.get_flags_decision(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, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> FlagsResponse method posthog.client.Client.get_remote_config_payload(key: str) method posthog.client.Client.get_tags() -> Dict[str, Any] -method posthog.client.Client.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +method posthog.client.Client.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.client.Client.identify_context(distinct_id: str) -> None method posthog.client.Client.join() -> None method posthog.client.Client.load_feature_flags()