|
7 | 7 | the legacy queued-message shape in a few load-bearing ways that this module |
8 | 8 | encodes: |
9 | 9 |
|
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``. |
13 | 14 | - ``$set``/``$set_once`` have no top-level form in v1; the server reads them |
14 | 15 | from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at |
15 | 16 | the top level, so they are relocated into ``properties`` here. |
16 | 17 | - ``$lib``/``$lib_version`` are injected server-side from the required |
17 | 18 | ``PostHog-Sdk-Info`` header and are stripped from v1 properties. |
18 | 19 | """ |
19 | 20 |
|
20 | | -from collections.abc import Callable |
| 21 | +import logging |
| 22 | +import re |
21 | 23 | from datetime import datetime, timezone |
22 | 24 | from typing import Any, Optional |
| 25 | +from uuid import UUID |
23 | 26 |
|
24 | 27 | from posthog.utils import _normalize_timestamp |
25 | 28 |
|
| 29 | +log = logging.getLogger("posthog") |
| 30 | + |
26 | 31 | # Sentinel properties lifted to top-level string fields on the event. |
27 | 32 | _TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( |
28 | 33 | ("$session_id", "session_id"), |
|
35 | 40 | # Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). |
36 | 41 | _STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") |
37 | 42 |
|
| 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 | + |
38 | 61 |
|
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. |
41 | 64 |
|
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. |
46 | 67 | """ |
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): |
55 | 71 | 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) |
77 | 86 |
|
78 | 87 |
|
79 | 88 | def _v1_timestamp(timestamp: Any) -> str: |
@@ -114,23 +123,24 @@ def _to_v1_event(msg: dict) -> dict: |
114 | 123 | for key in _STRIP_FROM_PROPERTIES: |
115 | 124 | properties.pop(key, None) |
116 | 125 |
|
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: |
119 | 128 | if prop_key not in properties: |
120 | 129 | 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 |
126 | 134 |
|
127 | 135 | top_level: dict[str, str] = {} |
128 | 136 | for prop_key, field_name in _TOPLEVEL_SENTINELS: |
129 | 137 | if prop_key not in properties: |
130 | 138 | 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 |
134 | 144 |
|
135 | 145 | event = { |
136 | 146 | "event": msg["event"], |
|
0 commit comments