diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6625fec0..bb011dfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ uv sync --extra dev --extra test ## CI-aligned checks -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: +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: ```bash ruff format --check . diff --git a/posthog/__init__.py b/posthog/__init__.py index 9e28ec5b..5c3335e1 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -10,6 +10,7 @@ OptionalSetArgs, ) from posthog.capture_compression import CaptureCompression as CaptureCompression +from posthog.capture_send import CaptureError as CaptureError from posthog.client import Client from posthog.tracing.span import Span as Span from posthog.async_client import AsyncClient as AsyncClient diff --git a/posthog/_async_request.py b/posthog/_async_request.py index 15b218f7..9ddc7f7a 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -7,7 +7,7 @@ from urllib.parse import quote from .capture_compression import CaptureCompression -from .capture_v1 import _parse_retry_after, _send_v1_batch +from .capture_send import _parse_retry_after, _send_v1_batch from .request import ( APIError, DatetimeSerializer, diff --git a/posthog/ai/prompts.py b/posthog/ai/prompts.py index 2cbae9db..c6284156 100644 --- a/posthog/ai/prompts.py +++ b/posthog/ai/prompts.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Union, overload -from posthog.capture_v1 import _parse_retry_after +from posthog.capture_send import _parse_retry_after from posthog.request import USER_AGENT, _get_session from posthog.utils import remove_trailing_slash diff --git a/posthog/capture_event.py b/posthog/capture_event.py new file mode 100644 index 00000000..bc50a462 --- /dev/null +++ b/posthog/capture_event.py @@ -0,0 +1,166 @@ +"""Event shaping for the capture v1 wire protocol. + +Transforms a legacy-shaped queued message into a v1 wire event and assembles +the batch envelope. :mod:`posthog.capture_send` posts the result. + +The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from +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. +- ``$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. +- ``$lib``/``$lib_version`` are injected server-side from the required + ``PostHog-Sdk-Info`` header and are stripped from v1 properties. +""" + +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any, Optional + +from posthog.utils import _normalize_timestamp + +# Sentinel properties lifted to top-level string fields on the event. +_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( + ("$session_id", "session_id"), + ("$window_id", "window_id"), +) + +# Top-level legacy keys relocated into properties (v1 has no top-level form). +_RELOCATE_TO_PROPERTIES = ("$set", "$set_once") + +# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). +_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") + + +def _coerce_bool(value: Any) -> Optional[bool]: + """Coerce a sentinel value to ``bool`` using the backend's truthiness rules. + + 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. + """ + 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 + 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), +) + + +def _v1_timestamp(timestamp: Any) -> str: + """Return a UTC RFC3339 timestamp string. + + Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` + normalizes canonical datetimes), so that is passed through. A ``datetime`` + is normalized to UTC and serialized; a missing value defaults to now in UTC. + The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and + rejects naive timestamps. + """ + if timestamp is None: + return datetime.now(timezone.utc).isoformat() + return _normalize_timestamp(timestamp) + + +def _to_v1_event(msg: dict) -> dict: + """Transform a legacy-shaped queued message into a v1 wire event. + + Pure: the input ``msg`` is not mutated (a fresh ``properties`` dict is + built), so it remains safe to keep the original for retries or callbacks. + """ + properties = dict(msg.get("properties") or {}) + + # Relocate top-level $set/$set_once into properties; v1 has no top-level + # form. On the unusual collision where properties already carries the key, + # the properties value wins. + for key in _RELOCATE_TO_PROPERTIES: + top_val = msg.get(key) + if top_val is None: + continue + existing = properties.get(key) + if isinstance(top_val, dict) and isinstance(existing, dict): + properties[key] = {**top_val, **existing} + elif key not in properties: + properties[key] = top_val + + for key in _STRIP_FROM_PROPERTIES: + properties.pop(key, None) + + options: dict[str, Any] = {} + for prop_key, wire_key, coercer in _OPTION_SENTINELS: + 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 + + 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 + + event = { + "event": msg["event"], + "uuid": msg["uuid"], + "distinct_id": msg["distinct_id"], + "timestamp": _v1_timestamp(msg.get("timestamp")), + # Always a dict so it serializes as "{}" rather than null when empty. + "options": options, + "properties": properties, + } + event.update(top_level) + return event + + +def _build_v1_batch_body( + events: list[dict], + historical_migration: bool = False, + created_at: Optional[str] = None, +) -> dict: + """Assemble the v1 batch envelope. + + Carries no ``api_key`` (Bearer auth) and no ``sent_at``. + ``historical_migration`` is omitted when False (the server defaults it). + ``created_at`` defaults to now in UTC; :func:`_send_v1_batch` passes a value + hoisted once so it stays stable across retry attempts. + """ + body: dict[str, Any] = { + "created_at": created_at or datetime.now(timezone.utc).isoformat(), + "batch": events, + } + if historical_migration: + body["historical_migration"] = True + return body diff --git a/posthog/capture_v1.py b/posthog/capture_send.py similarity index 71% rename from posthog/capture_v1.py rename to posthog/capture_send.py index 438aaf01..862eae09 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_send.py @@ -1,23 +1,9 @@ -"""Serialization and transport for the Capture V1 wire protocol. +"""Transport for the capture v1 wire protocol. -This module owns everything specific to the capture v1 endpoints +This module sends batches to the capture v1 endpoints (``POST /i/v1/analytics/events`` and ``POST /i/v1/ai/events``, which share one -wire contract): the *transform* layer (legacy-shaped queued message -> v1 wire -event + batch envelope) and the *transport* layer (a single HTTP attempt, -response parsing, and the partial-retry send loop). - -The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from -the legacy ``/batch/`` 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. -- ``$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. -- ``$lib``/``$lib_version`` are injected server-side from the required - ``PostHog-Sdk-Info`` header and are stripped from v1 properties. +wire contract): a single HTTP attempt, response parsing, and the partial-retry +send loop. :mod:`posthog.capture_event` builds the events and batch envelope. The response is per-event: a 200 carries a ``results`` map keyed by event uuid, each tagged ``ok``/``warning`` (terminal-success), ``drop`` (terminal-failure), @@ -25,7 +11,7 @@ attempt, holding the ``PostHog-Request-Id`` and batch ``created_at`` stable across attempts while incrementing ``PostHog-Attempt``. ``ok``/``warning``/absent events succeed; ``drop`` and retry-exhaustion are carried on the -:class:`CaptureV1Error` raised on batch-level/terminal failure, so the consumer's +:class:`CaptureError` raised on batch-level/terminal failure, so the consumer's existing ``on_error(exc, batch)`` path surfaces them unchanged (no per-event logging of its own). @@ -37,16 +23,16 @@ import logging import time import zlib -from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from email.utils import parsedate_to_datetime from gzip import GzipFile from io import BytesIO -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from uuid import uuid4 from posthog.capture_compression import CaptureCompression, _zstandard +from posthog.capture_event import _build_v1_batch_body, _to_v1_event from posthog.request import ( DatetimeSerializer, USER_AGENT, @@ -54,7 +40,7 @@ _get_session, normalize_host, ) -from posthog.utils import _normalize_timestamp, remove_trailing_slash +from posthog.utils import remove_trailing_slash if TYPE_CHECKING: import requests @@ -64,7 +50,7 @@ # Only the error type is public API: it reaches user code through `on_error` # callbacks, so callers may want to catch/inspect it. Everything else is # submitter plumbing. -__all__ = ["CaptureV1Error"] +__all__ = ["CaptureError"] _CAPTURE_V1_PATH = "/i/v1/analytics/events" _CAPTURE_AI_V1_PATH = "/i/v1/ai/events" @@ -94,148 +80,6 @@ # unifies the default with posthog-go/posthog-rs (all 30s). _MAX_BACKOFF_SECONDS = 30 -# Sentinel properties lifted to top-level string fields on the event. -_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( - ("$session_id", "session_id"), - ("$window_id", "window_id"), -) - -# Top-level legacy keys relocated into properties (v1 has no top-level form). -_RELOCATE_TO_PROPERTIES = ("$set", "$set_once") - -# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). -_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") - - -def _coerce_bool(value: Any) -> Optional[bool]: - """Coerce a sentinel value to ``bool`` using the backend's truthiness rules. - - 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. - """ - 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 - 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), -) - - -def _v1_timestamp(timestamp: Any) -> str: - """Return a UTC RFC3339 timestamp string. - - Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` - normalizes canonical datetimes), so that is passed through. A ``datetime`` - is normalized to UTC and serialized; a missing value defaults to now in UTC. - The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and - rejects naive timestamps. - """ - if timestamp is None: - return datetime.now(timezone.utc).isoformat() - return _normalize_timestamp(timestamp) - - -def _to_v1_event(msg: dict) -> dict: - """Transform a legacy-shaped queued message into a v1 wire event. - - Pure: the input ``msg`` is not mutated (a fresh ``properties`` dict is - built), so it remains safe to keep the original for retries or callbacks. - """ - properties = dict(msg.get("properties") or {}) - - # Relocate top-level $set/$set_once into properties; v1 has no top-level - # form. On the unusual collision where properties already carries the key, - # the properties value wins. - for key in _RELOCATE_TO_PROPERTIES: - top_val = msg.get(key) - if top_val is None: - continue - existing = properties.get(key) - if isinstance(top_val, dict) and isinstance(existing, dict): - properties[key] = {**top_val, **existing} - elif key not in properties: - properties[key] = top_val - - for key in _STRIP_FROM_PROPERTIES: - properties.pop(key, None) - - options: dict[str, Any] = {} - for prop_key, wire_key, coercer in _OPTION_SENTINELS: - 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 - - 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 - - event = { - "event": msg["event"], - "uuid": msg["uuid"], - "distinct_id": msg["distinct_id"], - "timestamp": _v1_timestamp(msg.get("timestamp")), - # Always a dict so it serializes as "{}" rather than null when empty. - "options": options, - "properties": properties, - } - event.update(top_level) - return event - - -def _build_v1_batch_body( - events: list[dict], - historical_migration: bool = False, - created_at: Optional[str] = None, -) -> dict: - """Assemble the v1 batch envelope. - - Carries no ``api_key`` (Bearer auth) and no ``sent_at``. - ``historical_migration`` is omitted when False (the server defaults it). - ``created_at`` defaults to now in UTC; :func:`_send_v1_batch` passes a value - hoisted once so it stays stable across retry attempts. - """ - body: dict[str, Any] = { - "created_at": created_at or datetime.now(timezone.utc).isoformat(), - "batch": events, - } - if historical_migration: - body["historical_migration"] = True - return body - @dataclass class _V1EventResult: @@ -263,7 +107,7 @@ class _V1ParsedResponse: error_message: str = "" -class CaptureV1Error(APIError): +class CaptureError(APIError): """Batch-level failure of a capture-v1 send. Subclasses :class:`APIError` so the consumer's existing ``on_error`` handling @@ -485,11 +329,11 @@ def _send_v1_batch( tagged ``retry`` after each 2xx. ``ok``/``warning``/absent events succeed. A server-chosen ``drop`` is a terminal per-event rejection. Drops are - accumulated across attempts and surfaced via :class:`CaptureV1Error` even + accumulated across attempts and surfaced via :class:`CaptureError` even when the request itself was a 2xx (a success status is not full delivery) and even when a later attempt clears the outstanding retries — matching posthog-go (per-event failure callback) and posthog-rs (``on_error`` on a - 2xx with undelivered verdicts). Raises :class:`CaptureV1Error` on any drop, + 2xx with undelivered verdicts). Raises :class:`CaptureError` on any drop, batch-level terminal failure, or retry exhaustion — carrying the accumulated ``drops`` and any exhausted uuids — so the caller's ``on_error`` fires unchanged. A transport failure re-raises the underlying exception (drops @@ -543,7 +387,7 @@ def _send_v1_batch( if parsed.is_success: if parsed.malformed: - raise CaptureV1Error( + raise CaptureError( parsed.status_code, "capture v1 returned a success status with an unparseable body", request_id=request_id, @@ -574,7 +418,7 @@ def _send_v1_batch( # surface them via on_error even though the request was a 2xx — # a success status does not mean every event was delivered. if all_drops: - raise CaptureV1Error( + raise CaptureError( parsed.status_code, f"{len(all_drops)} event(s) dropped by the server", request_id=request_id, @@ -583,7 +427,7 @@ def _send_v1_batch( ) return if last_attempt: - raise CaptureV1Error( + raise CaptureError( parsed.status_code, f"{len(retry_uuids)} event(s) still pending retry after {attempt} attempt(s)", request_id=request_id, @@ -598,7 +442,7 @@ def _send_v1_batch( # Non-2xx. Retryable transient statuses back off; everything else # (400/401/402/413/415/429/...) is terminal. Any drops collected from a # prior 2xx attempt ride along so on_error still sees them. - v1_error = CaptureV1Error( + v1_error = CaptureError( parsed.status_code, parsed.error_message, retry_after=parsed.retry_after, diff --git a/posthog/client.py b/posthog/client.py index 083a6eb0..611fda2f 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -31,7 +31,7 @@ CaptureCompression, _resolve_capture_compression, ) -from posthog.capture_v1 import ( +from posthog.capture_send import ( _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, _send_v1_batch, diff --git a/posthog/consumer.py b/posthog/consumer.py index 0b15431a..daad58c3 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -6,7 +6,7 @@ from posthog._logging import _configure_posthog_logging from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _CAPTURE_V1_PATH, _send_v1_batch +from posthog.capture_send import _CAPTURE_V1_PATH, _send_v1_batch from posthog.request import ( USER_AGENT as _USER_AGENT, DatetimeSerializer, diff --git a/posthog/test/capture_helpers.py b/posthog/test/capture_helpers.py index 542d3e10..3ead56f5 100644 --- a/posthog/test/capture_helpers.py +++ b/posthog/test/capture_helpers.py @@ -1,8 +1,8 @@ """Intercept capture uploads at the batch submitter for client-level tests. Patching the submitter (not the HTTP layer) lets tests assert on the event -dicts the SDK built, before the wire encoding in ``capture_v1``. Wire shape is -covered by ``test_capture_v1``. +dicts the SDK built, before the wire encoding in ``capture_event``. Wire shape is +covered by ``test_capture_event``. """ import json diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index 6b8f4534..568f00fe 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -9,7 +9,7 @@ from posthog.capture_compression import CaptureCompression from posthog.client import Client from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE -from posthog.capture_v1 import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.version import VERSION from posthog.test.capture_helpers import patch_capture_send, sent_batch from posthog.test.test_utils import TEST_API_KEY diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py new file mode 100644 index 00000000..e3b48195 --- /dev/null +++ b/posthog/test/test_capture_event.py @@ -0,0 +1,276 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from parameterized import parameterized + +from posthog.capture_event import ( + _build_v1_batch_body, + _coerce_bool, + _coerce_str, + _to_v1_event, +) + + +def _legacy_msg(event="my_event", properties=None, **overrides) -> dict: + """Minimal legacy-shaped message as it looks coming off the queue.""" + msg = { + "event": event, + "uuid": "0190000000007000800000000000000a", + "distinct_id": "user-1", + "timestamp": "2026-06-27T12:00:00+00:00", + "type": "capture", + "properties": {"$lib": "posthog-python", "$lib_version": "9.9.9"}, + } + if properties is not None: + msg["properties"] = properties + msg.update(overrides) + return msg + + +class TestCoercion(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), + ] + ) + def test_coerce_bool(self, _name, value, expected) -> None: + self.assertIs(_coerce_bool(value), expected) + + @parameterized.expand( + [ + ("str", "tour-1", "tour-1"), + ("empty_str", "", ""), + ("int", 123, None), + ("bool", True, None), + ("none", None, None), + ] + ) + def test_coerce_str(self, _name, value, expected) -> None: + self.assertEqual(_coerce_str(value), expected) + + +class TestToV1Event(unittest.TestCase): + def test_required_fields_preserved(self) -> None: + event = _to_v1_event(_legacy_msg(event="signed_up")) + self.assertEqual(event["event"], "signed_up") + self.assertEqual(event["uuid"], "0190000000007000800000000000000a") + self.assertEqual(event["distinct_id"], "user-1") + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_strips_lib_and_lib_version(self) -> None: + event = _to_v1_event(_legacy_msg()) + self.assertNotIn("$lib", event["properties"]) + self.assertNotIn("$lib_version", event["properties"]) + + def test_options_empty_dict_when_no_sentinels(self) -> None: + event = _to_v1_event(_legacy_msg(properties={"plain": "value"})) + self.assertEqual(event["options"], {}) + self.assertEqual(event["properties"], {"plain": "value"}) + + def test_does_not_leak_non_wire_top_level_keys(self) -> None: + event = _to_v1_event(_legacy_msg()) + # `type` is legacy-only; the v1 event carries only documented fields. + self.assertEqual( + set(event), + {"event", "uuid", "distinct_id", "timestamp", "options", "properties"}, + ) + + def test_does_not_mutate_input(self) -> None: + msg = _legacy_msg( + properties={"$cookieless_mode": True, "$session_id": "s-1"}, + **{"$set": {"name": "Max"}}, + ) + original_properties = dict(msg["properties"]) + _to_v1_event(msg) + self.assertEqual(msg["properties"], original_properties) + 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, + ), + ( + "process_person_profile", + "$process_person_profile", + "process_person_profile", + "false", + False, + ), + ( + "product_tour_id", + "$product_tour_id", + "product_tour_id", + "tour-7", + "tour-7", + ), + ] + ) + def test_option_sentinels_lifted_renamed_and_coerced( + self, _name, prop_key, wire_key, raw, expected + ) -> None: + event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) + self.assertEqual(event["options"], {wire_key: expected}) + self.assertNotIn(prop_key, event["properties"]) + + @parameterized.expand( + [ + ("bad_bool", "$cookieless_mode", "maybe"), + ("bad_tour_id_int", "$product_tour_id", 123), + ] + ) + def test_option_sentinel_removed_but_omitted_on_bad_coercion( + self, _name, prop_key, raw + ) -> 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"], {}) + + @parameterized.expand( + [ + ("session_id", "$session_id", "session_id", "s-123"), + ("window_id", "$window_id", "window_id", "w-456"), + ] + ) + def test_top_level_string_sentinels(self, _name, prop_key, field_name, raw) -> None: + event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) + self.assertEqual(event[field_name], raw) + self.assertNotIn(prop_key, event["properties"]) + + def test_top_level_sentinel_omitted_but_removed_when_not_string(self) -> None: + event = _to_v1_event(_legacy_msg(properties={"$session_id": 42})) + self.assertNotIn("session_id", event) + self.assertNotIn("$session_id", event["properties"]) + + def test_all_sentinels_together(self) -> None: + event = _to_v1_event( + _legacy_msg( + properties={ + "$cookieless_mode": True, + "$ignore_sent_at": "1", + "$product_tour_id": "tour-x", + "$process_person_profile": 0, + "$session_id": "s-1", + "$window_id": "w-1", + "$geoip_disable": True, + "custom": "keep", + } + ) + ) + self.assertEqual( + event["options"], + { + "cookieless_mode": True, + "disable_skew_correction": True, + "product_tour_id": "tour-x", + "process_person_profile": False, + }, + ) + self.assertEqual(event["session_id"], "s-1") + self.assertEqual(event["window_id"], "w-1") + # Non-sentinel props (including $geoip_disable) are left intact. + self.assertEqual( + event["properties"], {"$geoip_disable": True, "custom": "keep"} + ) + + @parameterized.expand([("set", "$set"), ("set_once", "$set_once")]) + def test_top_level_set_relocated_into_properties(self, _name, key) -> None: + msg = _legacy_msg(properties={}, **{key: {"email": "a@b.com"}}) + event = _to_v1_event(msg) + self.assertEqual(event["properties"][key], {"email": "a@b.com"}) + self.assertNotIn(key, event) # not a top-level v1 field + + def test_top_level_set_merges_with_existing_properties_set(self) -> None: + # properties wins on key collision. + msg = _legacy_msg( + properties={"$set": {"a": "from_props", "b": "props_only"}}, + **{"$set": {"a": "from_top", "c": "top_only"}}, + ) + event = _to_v1_event(msg) + self.assertEqual( + event["properties"]["$set"], + {"a": "from_props", "b": "props_only", "c": "top_only"}, + ) + + def test_groups_left_in_properties(self) -> None: + event = _to_v1_event(_legacy_msg(properties={"$groups": {"company": "ph"}})) + self.assertEqual(event["properties"]["$groups"], {"company": "ph"}) + + def test_timestamp_naive_datetime_made_tz_aware(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp=datetime(2026, 6, 27, 12, 0, 0))) + parsed = datetime.fromisoformat(event["timestamp"]) + self.assertIsNotNone(parsed.tzinfo) + + def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event( + _legacy_msg( + timestamp=datetime( + 2026, + 6, + 27, + 17, + 45, + tzinfo=timezone(timedelta(hours=5, minutes=45)), + ) + ) + ) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_timestamp_none_defaults_to_utc_now(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp=None)) + parsed = datetime.fromisoformat(event["timestamp"]) + self.assertEqual(parsed.tzinfo, timezone.utc) + + +class TestBuildV1BatchBody(unittest.TestCase): + def test_envelope_shape_and_no_legacy_fields(self) -> None: + events = [{"event": "e"}] + body = _build_v1_batch_body(events) + self.assertEqual(body["batch"], events) + self.assertNotIn("api_key", body) + self.assertNotIn("sent_at", body) + + def test_created_at_is_tz_aware_rfc3339(self) -> None: + body = _build_v1_batch_body([]) + parsed = datetime.fromisoformat(body["created_at"]) + self.assertIsNotNone(parsed.tzinfo) + + def test_created_at_passthrough_used_verbatim(self) -> None: + # _send_v1_batch hoists created_at and passes it in so it stays stable + # across retry attempts. + body = _build_v1_batch_body([], created_at="2026-06-27T12:00:00+00:00") + self.assertEqual(body["created_at"], "2026-06-27T12:00:00+00:00") + + def test_historical_migration_omitted_when_false(self) -> None: + self.assertNotIn("historical_migration", _build_v1_batch_body([])) + + def test_historical_migration_present_when_true(self) -> None: + body = _build_v1_batch_body([], historical_migration=True) + self.assertIs(body["historical_migration"], True) diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_send.py similarity index 63% rename from posthog/test/test_capture_v1.py rename to posthog/test/test_capture_send.py index e11e74c7..637eee85 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_send.py @@ -1,7 +1,7 @@ import json import unittest import zlib -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from unittest import mock import zstandard @@ -9,7 +9,8 @@ from parameterized import parameterized from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import ( +from posthog.capture_event import _build_v1_batch_body, _to_v1_event +from posthog.capture_send import ( _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, _HEADER_ATTEMPT, @@ -17,15 +18,11 @@ _HEADER_REQUEST_TIMESTAMP, _HEADER_SDK_INFO, _MAX_BACKOFF_SECONDS, - CaptureV1Error, - _build_v1_batch_body, + CaptureError, _parse_v1_response, _post_v1, _send_v1_batch, - _to_v1_event, _backoff, - _coerce_bool, - _coerce_str, ) from posthog.request import USER_AGENT @@ -130,271 +127,6 @@ def _results_response(directives, headers=None): return _FakeResponse(200, json_body={"results": results}, headers=headers) -def _legacy_msg(event="my_event", properties=None, **overrides) -> dict: - """Minimal legacy-shaped message as it looks coming off the queue.""" - msg = { - "event": event, - "uuid": "0190000000007000800000000000000a", - "distinct_id": "user-1", - "timestamp": "2026-06-27T12:00:00+00:00", - "type": "capture", - "properties": {"$lib": "posthog-python", "$lib_version": "9.9.9"}, - } - if properties is not None: - msg["properties"] = properties - msg.update(overrides) - return msg - - -class TestCoercion(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), - ] - ) - def test_coerce_bool(self, _name, value, expected) -> None: - self.assertIs(_coerce_bool(value), expected) - - @parameterized.expand( - [ - ("str", "tour-1", "tour-1"), - ("empty_str", "", ""), - ("int", 123, None), - ("bool", True, None), - ("none", None, None), - ] - ) - def test_coerce_str(self, _name, value, expected) -> None: - self.assertEqual(_coerce_str(value), expected) - - -class TestToV1Event(unittest.TestCase): - def test_required_fields_preserved(self) -> None: - event = _to_v1_event(_legacy_msg(event="signed_up")) - self.assertEqual(event["event"], "signed_up") - self.assertEqual(event["uuid"], "0190000000007000800000000000000a") - self.assertEqual(event["distinct_id"], "user-1") - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_strips_lib_and_lib_version(self) -> None: - event = _to_v1_event(_legacy_msg()) - self.assertNotIn("$lib", event["properties"]) - self.assertNotIn("$lib_version", event["properties"]) - - def test_options_empty_dict_when_no_sentinels(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"plain": "value"})) - self.assertEqual(event["options"], {}) - self.assertEqual(event["properties"], {"plain": "value"}) - - def test_does_not_leak_non_wire_top_level_keys(self) -> None: - event = _to_v1_event(_legacy_msg()) - # `type` is legacy-only; the v1 event carries only documented fields. - self.assertEqual( - set(event), - {"event", "uuid", "distinct_id", "timestamp", "options", "properties"}, - ) - - def test_does_not_mutate_input(self) -> None: - msg = _legacy_msg( - properties={"$cookieless_mode": True, "$session_id": "s-1"}, - **{"$set": {"name": "Max"}}, - ) - original_properties = dict(msg["properties"]) - _to_v1_event(msg) - self.assertEqual(msg["properties"], original_properties) - 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, - ), - ( - "process_person_profile", - "$process_person_profile", - "process_person_profile", - "false", - False, - ), - ( - "product_tour_id", - "$product_tour_id", - "product_tour_id", - "tour-7", - "tour-7", - ), - ] - ) - def test_option_sentinels_lifted_renamed_and_coerced( - self, _name, prop_key, wire_key, raw, expected - ) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - self.assertEqual(event["options"], {wire_key: expected}) - self.assertNotIn(prop_key, event["properties"]) - - @parameterized.expand( - [ - ("bad_bool", "$cookieless_mode", "maybe"), - ("bad_tour_id_int", "$product_tour_id", 123), - ] - ) - def test_option_sentinel_removed_but_omitted_on_bad_coercion( - self, _name, prop_key, raw - ) -> 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"], {}) - - @parameterized.expand( - [ - ("session_id", "$session_id", "session_id", "s-123"), - ("window_id", "$window_id", "window_id", "w-456"), - ] - ) - def test_top_level_string_sentinels(self, _name, prop_key, field_name, raw) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - self.assertEqual(event[field_name], raw) - self.assertNotIn(prop_key, event["properties"]) - - def test_top_level_sentinel_omitted_but_removed_when_not_string(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"$session_id": 42})) - self.assertNotIn("session_id", event) - self.assertNotIn("$session_id", event["properties"]) - - def test_all_sentinels_together(self) -> None: - event = _to_v1_event( - _legacy_msg( - properties={ - "$cookieless_mode": True, - "$ignore_sent_at": "1", - "$product_tour_id": "tour-x", - "$process_person_profile": 0, - "$session_id": "s-1", - "$window_id": "w-1", - "$geoip_disable": True, - "custom": "keep", - } - ) - ) - self.assertEqual( - event["options"], - { - "cookieless_mode": True, - "disable_skew_correction": True, - "product_tour_id": "tour-x", - "process_person_profile": False, - }, - ) - self.assertEqual(event["session_id"], "s-1") - self.assertEqual(event["window_id"], "w-1") - # Non-sentinel props (including $geoip_disable) are left intact. - self.assertEqual( - event["properties"], {"$geoip_disable": True, "custom": "keep"} - ) - - @parameterized.expand([("set", "$set"), ("set_once", "$set_once")]) - def test_top_level_set_relocated_into_properties(self, _name, key) -> None: - msg = _legacy_msg(properties={}, **{key: {"email": "a@b.com"}}) - event = _to_v1_event(msg) - self.assertEqual(event["properties"][key], {"email": "a@b.com"}) - self.assertNotIn(key, event) # not a top-level v1 field - - def test_top_level_set_merges_with_existing_properties_set(self) -> None: - # properties wins on key collision. - msg = _legacy_msg( - properties={"$set": {"a": "from_props", "b": "props_only"}}, - **{"$set": {"a": "from_top", "c": "top_only"}}, - ) - event = _to_v1_event(msg) - self.assertEqual( - event["properties"]["$set"], - {"a": "from_props", "b": "props_only", "c": "top_only"}, - ) - - def test_groups_left_in_properties(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"$groups": {"company": "ph"}})) - self.assertEqual(event["properties"]["$groups"], {"company": "ph"}) - - def test_timestamp_naive_datetime_made_tz_aware(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp=datetime(2026, 6, 27, 12, 0, 0))) - parsed = datetime.fromisoformat(event["timestamp"]) - self.assertIsNotNone(parsed.tzinfo) - - def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: - event = _to_v1_event( - _legacy_msg( - timestamp=datetime( - 2026, - 6, - 27, - 17, - 45, - tzinfo=timezone(timedelta(hours=5, minutes=45)), - ) - ) - ) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_timestamp_none_defaults_to_utc_now(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp=None)) - parsed = datetime.fromisoformat(event["timestamp"]) - self.assertEqual(parsed.tzinfo, timezone.utc) - - -class TestBuildV1BatchBody(unittest.TestCase): - def test_envelope_shape_and_no_legacy_fields(self) -> None: - events = [{"event": "e"}] - body = _build_v1_batch_body(events) - self.assertEqual(body["batch"], events) - self.assertNotIn("api_key", body) - self.assertNotIn("sent_at", body) - - def test_created_at_is_tz_aware_rfc3339(self) -> None: - body = _build_v1_batch_body([]) - parsed = datetime.fromisoformat(body["created_at"]) - self.assertIsNotNone(parsed.tzinfo) - - def test_created_at_passthrough_used_verbatim(self) -> None: - # _send_v1_batch hoists created_at and passes it in so it stays stable - # across retry attempts. - body = _build_v1_batch_body([], created_at="2026-06-27T12:00:00+00:00") - self.assertEqual(body["created_at"], "2026-06-27T12:00:00+00:00") - - def test_historical_migration_omitted_when_false(self) -> None: - self.assertNotIn("historical_migration", _build_v1_batch_body([])) - - def test_historical_migration_present_when_true(self) -> None: - body = _build_v1_batch_body([], historical_migration=True) - self.assertIs(body["historical_migration"], True) - - class TestPostV1(unittest.TestCase): def _post(self, response, **kwargs): session = _RecordingSession(response) @@ -472,7 +204,7 @@ def test_zstd_sets_encoding_header_and_emits_standard_frame(self) -> None: self.assertEqual(len(body["batch"]), 1) def test_zstd_without_package_raises_actionable_error(self) -> None: - with mock.patch("posthog.capture_v1._zstandard", None): + with mock.patch("posthog.capture_send._zstandard", None): with self.assertRaises(ValueError) as ctx: self._post(_results_response({}), compression=CaptureCompression.ZSTD) self.assertIn("posthog[zstd]", str(ctx.exception)) @@ -527,20 +259,20 @@ class TestSendV1Batch(unittest.TestCase): """Drives ``_send_v1_batch`` with a stubbed ``_post_v1`` and no real sleeps.""" def setUp(self) -> None: - sleep_patch = mock.patch("posthog.capture_v1.time.sleep") + sleep_patch = mock.patch("posthog.capture_send.time.sleep") self.sleep = sleep_patch.start() self.addCleanup(sleep_patch.stop) def _run(self, batch, outcomes, **kwargs): stub = _PostV1Stub(outcomes) - with mock.patch("posthog.capture_v1._post_v1", stub): + with mock.patch("posthog.capture_send._post_v1", stub): _send_v1_batch("phc_key", "https://app.posthog.com", batch, **kwargs) return stub def _run_expecting_error(self, batch, outcomes, **kwargs): stub = _PostV1Stub(outcomes) - with mock.patch("posthog.capture_v1._post_v1", stub): - with self.assertRaises(CaptureV1Error) as ctx: + with mock.patch("posthog.capture_send._post_v1", stub): + with self.assertRaises(CaptureError) as ctx: _send_v1_batch("phc_key", "https://app.posthog.com", batch, **kwargs) return stub, ctx.exception @@ -708,7 +440,7 @@ def test_transport_error_then_success(self) -> None: def test_transport_error_exhausted_reraises_original(self) -> None: stub = _PostV1Stub([ConnectionError("boom"), ConnectionError("boom")]) - with mock.patch("posthog.capture_v1._post_v1", stub): + with mock.patch("posthog.capture_send._post_v1", stub): with self.assertRaises(ConnectionError): _send_v1_batch( "phc_key", "https://app.posthog.com", [_msg("u-1")], max_retries=1 @@ -718,7 +450,7 @@ def test_transport_error_exhausted_reraises_original(self) -> None: def test_negative_max_retries_still_attempts_delivery_once(self) -> None: stub = _PostV1Stub([_results_response({"u-1": "ok"})]) - with mock.patch("posthog.capture_v1._post_v1", stub): + with mock.patch("posthog.capture_send._post_v1", stub): _send_v1_batch( "phc_key", "https://app.posthog.com", [_msg("u-1")], max_retries=-1 ) @@ -764,6 +496,6 @@ class TestBackoff(unittest.TestCase): ] ) def test_backoff(self, _name, attempt_index, retry_after, expected) -> None: - with mock.patch("posthog.capture_v1.time.sleep") as sleep: + with mock.patch("posthog.capture_send.time.sleep") as sleep: _backoff(attempt_index, retry_after) sleep.assert_called_once_with(expected) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 10474a25..142740ad 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -18,7 +18,7 @@ import pytest from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _CAPTURE_V1_PATH +from posthog.capture_send import _CAPTURE_V1_PATH from posthog.client import Client from posthog.contexts import get_context_session_id, new_context, set_context_session from posthog.request import APIError, GetResponse @@ -4671,7 +4671,7 @@ def test_negative_max_retries_still_attempts_delivery_once(self, _name, sync_mod client = None with mock.patch( - "posthog.capture_v1._post_v1", return_value=response + "posthog.capture_send._post_v1", return_value=response ) as v1_post: try: client = Client( diff --git a/posthog/test/test_consumer.py b/posthog/test/test_consumer.py index 92cd9d88..2274c690 100644 --- a/posthog/test/test_consumer.py +++ b/posthog/test/test_consumer.py @@ -12,7 +12,7 @@ from Queue import Queue from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.consumer import MAX_MSG_SIZE, Consumer, _DrainSignal from posthog.test.capture_helpers import patch_capture_send, sent_batch from posthog.test.logging_helpers import capture_message_only_logs diff --git a/posthog/test/test_server_payload_snapshots.py b/posthog/test/test_server_payload_snapshots.py index 80e03415..71f437ed 100644 --- a/posthog/test/test_server_payload_snapshots.py +++ b/posthog/test/test_server_payload_snapshots.py @@ -117,7 +117,7 @@ def _event_family_request(): with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.capture_v1._get_session", return_value=session), + mock.patch("posthog.capture_send._get_session", return_value=session), mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), ): client = Client( @@ -182,7 +182,7 @@ def _exception_request(): with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.capture_v1._get_session", return_value=session), + mock.patch("posthog.capture_send._get_session", return_value=session), mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), mock.patch("posthog.client._get_current_otel_span_properties", return_value={}), ): diff --git a/posthog/tracing/_export.py b/posthog/tracing/_export.py index e68385f7..3a3e4b39 100644 --- a/posthog/tracing/_export.py +++ b/posthog/tracing/_export.py @@ -12,7 +12,7 @@ import time from typing import Any, Callable, List, Optional, Tuple -from ..capture_v1 import _MAX_BACKOFF_SECONDS +from ..capture_send import _MAX_BACKOFF_SECONDS from ._config import ResolvedTracesConfig from ._drops import DropLog from ._limits import truncate_attributes diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index f412a211..c6165d97 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -7,6 +7,7 @@ alias posthog.AsyncClient -> posthog.async_client.AsyncClient alias posthog.AsyncPosthog -> posthog.async_client.AsyncPosthog alias posthog.BeforeSendCallback -> posthog.types.BeforeSendCallback alias posthog.CaptureCompression -> posthog.capture_compression.CaptureCompression +alias posthog.CaptureError -> posthog.capture_send.CaptureError alias posthog.Client -> posthog.client.Client alias posthog.DEFAULT_CODE_VARIABLES_DETECT_SECRETS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_DETECT_SECRETS alias posthog.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -689,11 +690,11 @@ attribute posthog.capture_compression.CaptureCompression.GZIP = 'gzip' attribute posthog.capture_compression.CaptureCompression.NONE = 'none' attribute posthog.capture_compression.CaptureCompression.ZSTD = 'zstd' attribute posthog.capture_exception_code_variables = False +attribute posthog.capture_send.CaptureError.attempts = attempts +attribute posthog.capture_send.CaptureError.drops = drops or [] +attribute posthog.capture_send.CaptureError.request_id = request_id +attribute posthog.capture_send.CaptureError.retry_exhausted = retry_exhausted or [] attribute posthog.capture_trace_context = False -attribute posthog.capture_v1.CaptureV1Error.attempts = attempts -attribute posthog.capture_v1.CaptureV1Error.drops = drops or [] -attribute posthog.capture_v1.CaptureV1Error.request_id = request_id -attribute posthog.capture_v1.CaptureV1Error.retry_exhausted = retry_exhausted or [] attribute posthog.client.Client.api_key = (project_api_key or '').strip() attribute posthog.client.Client.capture_compression = _resolve_capture_compression(capture_compression) attribute posthog.client.Client.capture_exception_code_variables = capture_exception_code_variables @@ -1163,7 +1164,7 @@ class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] class posthog.async_client.AsyncPosthog class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) class posthog.capture_compression.CaptureCompression -class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) +class posthog.capture_send.CaptureError(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) class posthog.client.Client(project_api_key: str, host=None, *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, retries=10, timeout=15, historical_migration=False, endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) @@ -1741,7 +1742,8 @@ module posthog.args module posthog.async_client module posthog.bucketed_rate_limiter module posthog.capture_compression -module posthog.capture_v1 +module posthog.capture_event +module posthog.capture_send module posthog.client module posthog.consumer module posthog.contexts diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index b663193c..be2e1905 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -15,8 +15,8 @@ from posthog import Client from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _CAPTURE_V1_PATH -from posthog.capture_v1 import _post_v1 as original_post_v1 +from posthog.capture_send import _CAPTURE_V1_PATH +from posthog.capture_send import _post_v1 as original_post_v1 from posthog.request import USER_AGENT from posthog.version import VERSION @@ -212,9 +212,9 @@ def patched_post_v1( # Patch the capture-v1 submitter. `_send_v1_batch` resolves `_post_v1` as a module # global at call time, so patching it here covers both the async consumer and the # sync client paths. -import posthog.capture_v1 # noqa: E402 +import posthog.capture_send # noqa: E402 -posthog.capture_v1._post_v1 = patched_post_v1 +posthog.capture_send._post_v1 = patched_post_v1 @app.route("/health", methods=["GET"]) diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 2cd484a5..1e06c462 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -8,7 +8,7 @@ import pytest -import posthog.capture_v1 +import posthog.capture_send import posthog.client import posthog.consumer import posthog.request @@ -20,7 +20,7 @@ def adapter(monkeypatch): # Importing the adapter installs transport instrumentation. Restore it after # every test so collecting these tests alongside SDK tests is safe. for module, name in [ - (posthog.capture_v1, "_post_v1"), + (posthog.capture_send, "_post_v1"), ]: monkeypatch.setattr(module, name, getattr(module, name)) spec = importlib.util.spec_from_file_location(