Skip to content

Commit 89ec2d0

Browse files
committed
refactor!: split capture_v1 into capture_event and capture_send
Rename CaptureV1Error to CaptureError and export it from posthog. Event shaping moves to posthog.capture_event and transport to posthog.capture_send. No behavior change.
1 parent 660c865 commit 89ec2d0

19 files changed

Lines changed: 499 additions & 478 deletions

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ uv sync --extra dev --extra test
1818

1919
## CI-aligned checks
2020

21-
Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_v1.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:
21+
Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_send.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:
2222

2323
```bash
2424
ruff format --check .

‎posthog/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
OptionalSetArgs,
1111
)
1212
from posthog.capture_compression import CaptureCompression as CaptureCompression
13+
from posthog.capture_send import CaptureError as CaptureError
1314
from posthog.client import Client
1415
from posthog.tracing.span import Span as Span
1516
from posthog.async_client import AsyncClient as AsyncClient

‎posthog/_async_request.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from urllib.parse import quote
88

99
from .capture_compression import CaptureCompression
10-
from .capture_v1 import _parse_retry_after, _send_v1_batch
10+
from .capture_send import _parse_retry_after, _send_v1_batch
1111
from .request import (
1212
APIError,
1313
DatetimeSerializer,

‎posthog/ai/prompts.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from dataclasses import dataclass
1515
from typing import Any, Dict, List, Literal, Optional, Union, overload
1616

17-
from posthog.capture_v1 import _parse_retry_after
17+
from posthog.capture_send import _parse_retry_after
1818
from posthog.request import USER_AGENT, _get_session
1919
from posthog.utils import remove_trailing_slash
2020

‎posthog/capture_event.py‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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

Comments
 (0)