Skip to content

Commit a515200

Browse files
committed
feat!: per-event capture options and legacy property hoisting
1 parent 8da410d commit a515200

10 files changed

Lines changed: 318 additions & 132 deletions

‎posthog/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ def group_identify(
638638
uuid: Optional[str] = None,
639639
disable_geoip: Optional[bool] = None,
640640
distinct_id: Optional[ID_TYPES] = None,
641+
options: Optional[Dict[str, Any]] = None,
641642
) -> Optional[str]:
642643
"""
643644
Set properties on a group.
@@ -653,6 +654,7 @@ def group_identify(
653654
uuid: Optional UUID for the event
654655
disable_geoip: Whether to disable GeoIP lookup
655656
distinct_id: Optional distinct ID of the user performing the action
657+
options: Optional capture options for the event, sent as given
656658
657659
Examples:
658660
```python
@@ -676,6 +678,7 @@ def group_identify(
676678
uuid=uuid,
677679
disable_geoip=disable_geoip,
678680
distinct_id=distinct_id,
681+
options=options,
679682
)
680683

681684

@@ -685,6 +688,7 @@ def alias(
685688
timestamp: Optional[Union[datetime.datetime, str]] = None,
686689
uuid: Optional[str] = None,
687690
disable_geoip: Optional[bool] = None,
691+
options: Optional[Dict[str, Any]] = None,
688692
) -> Optional[str]:
689693
"""
690694
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
@@ -696,6 +700,7 @@ def alias(
696700
datetimes and parseable ISO timestamp strings are converted to UTC.
697701
uuid: Optional UUID for the event
698702
disable_geoip: Whether to disable GeoIP lookup
703+
options: Optional capture options for the event, sent as given
699704
700705
Details:
701706
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(
717722
timestamp=timestamp,
718723
uuid=uuid,
719724
disable_geoip=disable_geoip,
725+
options=options,
720726
)
721727

722728

@@ -730,7 +736,7 @@ def capture_exception(
730736
Args:
731737
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
732738
**kwargs: Optional capture arguments including distinct_id, properties,
733-
timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.
739+
timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options.
734740
735741
Details:
736742
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`.

‎posthog/args.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,16 @@ class OptionalCaptureArgs(TypedDict):
4848
hidden ``/flags`` request on capture and may return different values than the ones
4949
the code branched on.
5050
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
51+
options: Capture options for this event, such as ``{"process_person_profile": False}``.
52+
Sent as given, for PostHog to validate. An option wins over its legacy ``$`` property,
53+
such as ``$process_person_profile``. A value that is not a dict is logged and ignored.
5154
"""
5255

5356
distinct_id: NotRequired[Optional[ID_TYPES]]
5457
properties: NotRequired[Optional[Dict[str, Any]]]
5558
timestamp: NotRequired[Optional[Union[datetime, str]]]
5659
uuid: NotRequired[Optional[Union[str, UUID]]]
60+
options: NotRequired[Optional[Dict[str, Any]]]
5761
groups: NotRequired[Optional[Dict[str, str]]]
5862
flags: NotRequired[Optional["FeatureFlagEvaluations"]]
5963
send_feature_flags: NotRequired[
@@ -83,13 +87,15 @@ class OptionalSetArgs(TypedDict):
8387
it must be a valid UUID string or uuid.UUID instance; invalid values are ignored
8488
and replaced with a newly generated UUID.
8589
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
90+
options: Capture options for this event, sent as given. See ``OptionalCaptureArgs``.
8691
"""
8792

8893
distinct_id: NotRequired[Optional[ID_TYPES]]
8994
properties: NotRequired[Optional[Dict[str, Any]]]
9095
timestamp: NotRequired[Optional[Union[datetime, str]]]
9196
uuid: NotRequired[Optional[Union[str, UUID]]]
9297
disable_geoip: NotRequired[Optional[bool]]
98+
options: NotRequired[Optional[Dict[str, Any]]]
9399

94100

95101
ExcInfo = Union[

‎posthog/async_client.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
CaptureCompression,
3636
_resolve_capture_compression,
3737
)
38+
from .capture_event import _canonical_event_uuid, _event_options
3839
from .capture_send import _CAPTURE_V1_PATH
3940
from .client import (
4041
MAX_DICT_SIZE as _MAX_DICT_SIZE,
@@ -378,9 +379,8 @@ def enqueue_on_bound_loop() -> None:
378379
def _normalize_uuid(self, msg: dict[str, Any]) -> str:
379380
raw_uuid = msg.pop("uuid", None)
380381
if raw_uuid is not None:
381-
try:
382-
normalized = str(UUID(str(raw_uuid)))
383-
except (TypeError, ValueError, AttributeError):
382+
normalized = _canonical_event_uuid(raw_uuid)
383+
if normalized is None:
384384
self.log.error(
385385
"Invalid UUID %r. Falling back to a generated UUID.", raw_uuid
386386
)
@@ -493,6 +493,7 @@ def _build_capture_event(
493493
"distinct_id": distinct_id,
494494
"event": event,
495495
"uuid": kwargs.get("uuid"),
496+
"options": _event_options(kwargs.get("options")),
496497
},
497498
kwargs.get("disable_geoip"),
498499
kwargs.get("_property_allowlist"),
@@ -611,6 +612,7 @@ def _build_person_properties_event(
611612
property_key: properties,
612613
"event": event,
613614
"uuid": kwargs.get("uuid"),
615+
"options": _event_options(kwargs.get("options")),
614616
}
615617

616618
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
@@ -646,6 +648,7 @@ def group_identify(
646648
uuid: Optional[Union[str, UUID]] = None,
647649
disable_geoip: Optional[bool] = None,
648650
distinct_id: Optional[ID_TYPES] = None,
651+
options: Optional[Dict[str, Any]] = None,
649652
) -> Optional[str]:
650653
try:
651654
if not _stringify_id(group_type):
@@ -670,6 +673,7 @@ def group_identify(
670673
"distinct_id": resolved_distinct_id,
671674
"timestamp": timestamp,
672675
"uuid": uuid,
676+
"options": _event_options(options),
673677
}
674678
session_id = _get_context_session_id()
675679
if session_id:
@@ -688,6 +692,7 @@ def alias(
688692
timestamp: Optional[Union[datetime, str]] = None,
689693
uuid: Optional[str] = None,
690694
disable_geoip: Optional[bool] = None,
695+
options: Optional[Dict[str, Any]] = None,
691696
) -> Optional[str]:
692697
try:
693698
resolved_previous_id = _stringify_id(previous_id)
@@ -707,6 +712,7 @@ def alias(
707712
"event": "$create_alias",
708713
"distinct_id": resolved_previous_id,
709714
"uuid": uuid,
715+
"options": _event_options(options),
710716
}
711717
session_id = _get_context_session_id()
712718
if session_id:
@@ -809,6 +815,7 @@ def capture_exception(
809815
groups=kwargs.get("groups"),
810816
flags=kwargs.get("flags"),
811817
disable_geoip=kwargs.get("disable_geoip"),
818+
options=kwargs.get("options"),
812819
)
813820
if exception is not None and result is not None:
814821
mark_exception_as_captured(exception, result)

‎posthog/capture_event.py‎

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,27 @@
77
the legacy queued-message shape in a few load-bearing ways that this module
88
encodes:
99
10-
- A typed ``options`` object carries a handful of sentinel properties, renamed
11-
and strictly typed. Wrong JSON types fail deserialization of the *whole
12-
batch*, so values are coerced to native types or omitted entirely.
10+
- An ``options`` object carries per-event processing options. Options the
11+
caller sets are sent as given, for PostHog to validate. Four legacy ``$``
12+
properties fill the matching option when the caller left it unset, and are
13+
always removed from ``properties``.
1314
- ``$set``/``$set_once`` have no top-level form in v1; the server reads them
1415
from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at
1516
the top level, so they are relocated into ``properties`` here.
1617
- ``$lib``/``$lib_version`` are injected server-side from the required
1718
``PostHog-Sdk-Info`` header and are stripped from v1 properties.
1819
"""
1920

20-
from collections.abc import Callable
21+
import logging
22+
import re
2123
from datetime import datetime, timezone
2224
from typing import Any, Optional
25+
from uuid import UUID
2326

2427
from posthog.utils import _normalize_timestamp
2528

29+
log = logging.getLogger("posthog")
30+
2631
# Sentinel properties lifted to top-level string fields on the event.
2732
_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = (
2833
("$session_id", "session_id"),
@@ -35,45 +40,49 @@
3540
# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info).
3641
_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version")
3742

43+
# Legacy properties and the option each one fills. The order matches posthog-rs
44+
# and posthog-go.
45+
_LEGACY_OPTION_PROPERTIES: tuple[tuple[str, str], ...] = (
46+
("$cookieless_mode", "cookieless_mode"),
47+
("$ignore_sent_at", "disable_skew_correction"),
48+
("$product_tour_id", "product_tour_id"),
49+
("$process_person_profile", "process_person_profile"),
50+
)
51+
52+
# The uuid forms Go's uuid.Validate accepts. Python's UUID() also accepts
53+
# misplaced hyphens and a bare "uuid:" prefix, which other SDKs reject.
54+
_EVENT_UUID_PATTERN = re.compile(
55+
r"(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
56+
r"|\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}"
57+
r"|[0-9a-f]{32}",
58+
re.IGNORECASE,
59+
)
60+
3861

39-
def _coerce_bool(value: Any) -> Optional[bool]:
40-
"""Coerce a sentinel value to ``bool`` using the backend's truthiness rules.
62+
def _canonical_event_uuid(value: Any) -> Optional[str]:
63+
"""Return the canonical form of a caller's event uuid, or None if invalid.
4164
42-
Native bool passes through; ``"true"``/``"1"`` and ``"false"``/``"0"``
43-
(case-insensitive, trimmed) map to the obvious bool; any other numeric value
44-
is nonzero-truthy. Anything else returns ``None`` so the option is omitted
45-
rather than sent with a type the strict v1 schema would reject.
65+
Capture keys per-event results by the canonical lowercase hyphenated form,
66+
so a uuid sent in any other form would never match its result.
4667
"""
47-
if isinstance(value, bool):
48-
return value
49-
if isinstance(value, str):
50-
normalized = value.strip().lower()
51-
if normalized in ("true", "1"):
52-
return True
53-
if normalized in ("false", "0"):
54-
return False
68+
if isinstance(value, UUID):
69+
return str(value)
70+
if not isinstance(value, str) or not _EVENT_UUID_PATTERN.fullmatch(value):
5571
return None
56-
if isinstance(value, (int, float)):
57-
return value != 0
58-
return None
59-
60-
61-
def _coerce_str(value: Any) -> Optional[str]:
62-
"""Accept only ``str`` (the backend's ``product_tour_id`` is ``Option<String>``)."""
63-
return value if isinstance(value, str) else None
64-
65-
66-
# Sentinel properties lifted into the typed `options` object: legacy property
67-
# key, the backend's field name, and the coercer enforcing its strict type
68-
# (wrong JSON types fail deserialization of the whole batch, so a value that
69-
# won't coerce is omitted). The coercer is stored directly to keep the dispatch
70-
# type-checked rather than keyed by a stringly-typed name.
71-
_OPTION_SENTINELS: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
72-
("$cookieless_mode", "cookieless_mode", _coerce_bool),
73-
("$ignore_sent_at", "disable_skew_correction", _coerce_bool),
74-
("$product_tour_id", "product_tour_id", _coerce_str),
75-
("$process_person_profile", "process_person_profile", _coerce_bool),
76-
)
72+
return str(UUID(value.lower()))
73+
74+
75+
def _event_options(value: Any) -> dict[str, Any]:
76+
"""Return a copy of a caller's ``options``, or ``{}`` when it is not a dict."""
77+
if value is None:
78+
return {}
79+
if not isinstance(value, dict):
80+
log.error(
81+
"options must be a dict, got %s. Sending the event without them.",
82+
type(value).__name__,
83+
)
84+
return {}
85+
return dict(value)
7786

7887

7988
def _v1_timestamp(timestamp: Any) -> str:
@@ -114,23 +123,24 @@ def _to_v1_event(msg: dict) -> dict:
114123
for key in _STRIP_FROM_PROPERTIES:
115124
properties.pop(key, None)
116125

117-
options: dict[str, Any] = {}
118-
for prop_key, wire_key, coercer in _OPTION_SENTINELS:
126+
options = _event_options(msg.get("options"))
127+
for prop_key, option_key in _LEGACY_OPTION_PROPERTIES:
119128
if prop_key not in properties:
120129
continue
121-
# Always removed from properties — these sentinels must never reach v1
122-
# backend properties — but only emitted as an option when coercible.
123-
coerced = coercer(properties.pop(prop_key))
124-
if coerced is not None:
125-
options[wire_key] = coerced
130+
legacy = properties.pop(prop_key)
131+
# A null option counts as unset, so the legacy value fills it.
132+
if options.get(option_key) is None:
133+
options[option_key] = legacy
126134

127135
top_level: dict[str, str] = {}
128136
for prop_key, field_name in _TOPLEVEL_SENTINELS:
129137
if prop_key not in properties:
130138
continue
131-
coerced_str = _coerce_str(properties.pop(prop_key))
132-
if coerced_str is not None:
133-
top_level[field_name] = coerced_str
139+
# Always removed. A non-string value would fail the whole batch, so it
140+
# is dropped.
141+
value = properties.pop(prop_key)
142+
if isinstance(value, str):
143+
top_level[field_name] = value
134144

135145
event = {
136146
"event": msg["event"],

0 commit comments

Comments
 (0)