|
| 1 | +"""Event shaping for the capture v1 wire protocol. |
| 2 | +
|
| 3 | +Transforms a legacy-shaped queued message into a v1 wire event and assembles |
| 4 | +the batch envelope. :mod:`posthog.capture_send` posts the result. |
| 5 | +
|
| 6 | +The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from |
| 7 | +the legacy queued-message shape in a few load-bearing ways that this module |
| 8 | +encodes: |
| 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. |
| 13 | +- ``$set``/``$set_once`` have no top-level form in v1; the server reads them |
| 14 | + from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at |
| 15 | + the top level, so they are relocated into ``properties`` here. |
| 16 | +- ``$lib``/``$lib_version`` are injected server-side from the required |
| 17 | + ``PostHog-Sdk-Info`` header and are stripped from v1 properties. |
| 18 | +""" |
| 19 | + |
| 20 | +from collections.abc import Callable |
| 21 | +from datetime import datetime, timezone |
| 22 | +from typing import Any, Optional |
| 23 | + |
| 24 | +from posthog.utils import _normalize_timestamp |
| 25 | + |
| 26 | +# Sentinel properties lifted to top-level string fields on the event. |
| 27 | +_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( |
| 28 | + ("$session_id", "session_id"), |
| 29 | + ("$window_id", "window_id"), |
| 30 | +) |
| 31 | + |
| 32 | +# Top-level legacy keys relocated into properties (v1 has no top-level form). |
| 33 | +_RELOCATE_TO_PROPERTIES = ("$set", "$set_once") |
| 34 | + |
| 35 | +# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). |
| 36 | +_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") |
| 37 | + |
| 38 | + |
| 39 | +def _coerce_bool(value: Any) -> Optional[bool]: |
| 40 | + """Coerce a sentinel value to ``bool`` using the backend's truthiness rules. |
| 41 | +
|
| 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. |
| 46 | + """ |
| 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 |
| 55 | + 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 | +) |
| 77 | + |
| 78 | + |
| 79 | +def _v1_timestamp(timestamp: Any) -> str: |
| 80 | + """Return a UTC RFC3339 timestamp string. |
| 81 | +
|
| 82 | + Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` |
| 83 | + normalizes canonical datetimes), so that is passed through. A ``datetime`` |
| 84 | + is normalized to UTC and serialized; a missing value defaults to now in UTC. |
| 85 | + The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and |
| 86 | + rejects naive timestamps. |
| 87 | + """ |
| 88 | + if timestamp is None: |
| 89 | + return datetime.now(timezone.utc).isoformat() |
| 90 | + return _normalize_timestamp(timestamp) |
| 91 | + |
| 92 | + |
| 93 | +def _to_v1_event(msg: dict) -> dict: |
| 94 | + """Transform a legacy-shaped queued message into a v1 wire event. |
| 95 | +
|
| 96 | + Pure: the input ``msg`` is not mutated (a fresh ``properties`` dict is |
| 97 | + built), so it remains safe to keep the original for retries or callbacks. |
| 98 | + """ |
| 99 | + properties = dict(msg.get("properties") or {}) |
| 100 | + |
| 101 | + # Relocate top-level $set/$set_once into properties; v1 has no top-level |
| 102 | + # form. On the unusual collision where properties already carries the key, |
| 103 | + # the properties value wins. |
| 104 | + for key in _RELOCATE_TO_PROPERTIES: |
| 105 | + top_val = msg.get(key) |
| 106 | + if top_val is None: |
| 107 | + continue |
| 108 | + existing = properties.get(key) |
| 109 | + if isinstance(top_val, dict) and isinstance(existing, dict): |
| 110 | + properties[key] = {**top_val, **existing} |
| 111 | + elif key not in properties: |
| 112 | + properties[key] = top_val |
| 113 | + |
| 114 | + for key in _STRIP_FROM_PROPERTIES: |
| 115 | + properties.pop(key, None) |
| 116 | + |
| 117 | + options: dict[str, Any] = {} |
| 118 | + for prop_key, wire_key, coercer in _OPTION_SENTINELS: |
| 119 | + if prop_key not in properties: |
| 120 | + 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 |
| 126 | + |
| 127 | + top_level: dict[str, str] = {} |
| 128 | + for prop_key, field_name in _TOPLEVEL_SENTINELS: |
| 129 | + if prop_key not in properties: |
| 130 | + continue |
| 131 | + coerced_str = _coerce_str(properties.pop(prop_key)) |
| 132 | + if coerced_str is not None: |
| 133 | + top_level[field_name] = coerced_str |
| 134 | + |
| 135 | + event = { |
| 136 | + "event": msg["event"], |
| 137 | + "uuid": msg["uuid"], |
| 138 | + "distinct_id": msg["distinct_id"], |
| 139 | + "timestamp": _v1_timestamp(msg.get("timestamp")), |
| 140 | + # Always a dict so it serializes as "{}" rather than null when empty. |
| 141 | + "options": options, |
| 142 | + "properties": properties, |
| 143 | + } |
| 144 | + event.update(top_level) |
| 145 | + return event |
| 146 | + |
| 147 | + |
| 148 | +def _build_v1_batch_body( |
| 149 | + events: list[dict], |
| 150 | + historical_migration: bool = False, |
| 151 | + created_at: Optional[str] = None, |
| 152 | +) -> dict: |
| 153 | + """Assemble the v1 batch envelope. |
| 154 | +
|
| 155 | + Carries no ``api_key`` (Bearer auth) and no ``sent_at``. |
| 156 | + ``historical_migration`` is omitted when False (the server defaults it). |
| 157 | + ``created_at`` defaults to now in UTC; :func:`_send_v1_batch` passes a value |
| 158 | + hoisted once so it stays stable across retry attempts. |
| 159 | + """ |
| 160 | + body: dict[str, Any] = { |
| 161 | + "created_at": created_at or datetime.now(timezone.utc).isoformat(), |
| 162 | + "batch": events, |
| 163 | + } |
| 164 | + if historical_migration: |
| 165 | + body["historical_migration"] = True |
| 166 | + return body |
0 commit comments