diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index 3891e105c..55cfc8ebe 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -28,21 +28,11 @@ jobs: run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30 compliance: - name: PostHog SDK compliance tests (capture v0) + name: PostHog SDK compliance tests uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0 with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." test-harness-version: "1.1.1" continue-on-error: false - report-name: "sdk-compliance-report-v0" - - compliance-v1: - name: PostHog SDK compliance tests (capture v1) - uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0 - with: - adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1" - adapter-context: "." - test-harness-version: "1.1.1" - continue-on-error: false - report-name: "sdk-compliance-report-v1" + report-name: "sdk-compliance-report" diff --git a/.sampo/changesets/capture-v1-major.md b/.sampo/changesets/capture-v1-major.md new file mode 100644 index 000000000..9271aee97 --- /dev/null +++ b/.sampo/changesets/capture-v1-major.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: major +--- + +Capture v1 is the only capture path. Events and AI events send to the capture v1 endpoints, and the legacy v0 capture path is removed. See the migration guide for breaking changes. diff --git a/AGENTS.md b/AGENTS.md index 745e91ad7..4d67e9efc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Follow [Public API changes](./CONTRIBUTING.md#public-api-changes). As an agent, Before changing capture configuration, serialization, routing, or retries, read the relevant implementation and tests. -Preserve v0 defaults/compatibility; strictly typed v1 options and `$set`/`$set_once` relocation; v1-only compression (zlib-wrapped deflate, optional zstd); partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`. +Capture v1 is the only capture protocol (`capture` posts to `/i/v1/analytics/events`, `capture_ai` to `/i/v1/ai/events`); strictly typed v1 options and `$set`/`$set_once` relocation; compression (gzip, zlib-wrapped deflate, optional zstd, default none), set per lane by `capture_compression` and `capture_ai_compression`; partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`. ## Mirror and build safety diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6625fec0c..bb011dfb1 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 5d6ae1193..71e4493ba 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -10,7 +10,7 @@ OptionalSetArgs, ) from posthog.capture_compression import CaptureCompression as CaptureCompression -from posthog.capture_mode import CaptureMode as CaptureMode +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 @@ -313,8 +313,11 @@ def get_tags() -> Dict[str, Any]: disabled no-op global client. host: PostHog ingestion host. Defaults to the US ingestion endpoint when not set. - on_error: Optional callback invoked by background consumers when event upload - fails. Keep it short and non-blocking. Lifecycle methods can be called + on_error: Optional callback ``(error, batch)`` invoked when event upload + fails: by background consumers, or on the calling thread in ``sync_mode``. + Capture failures arrive as ``CaptureError``. Without it, each failed batch + logs one aggregate line. A capture that fails inside the callback logs that + line instead of calling it again. Keep it short and non-blocking. Lifecycle methods can be called directly and will be deferred, but the callback must not wait for another thread or task that calls ``flush()``, ``join()``, or ``shutdown()``. debug: Enable verbose SDK logging and re-raise errors from public APIs. @@ -424,9 +427,6 @@ def get_tags() -> Dict[str, Any]: # We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation. enable_local_evaluation = True # type: bool flag_definition_cache_provider = None # type: Optional[FlagDefinitionCacheProvider] -# Capture wire protocol for the global client. None defers to POSTHOG_CAPTURE_MODE -# then CaptureMode.V0. See posthog.capture_mode.CaptureMode. -capture_mode = None # type: Optional[CaptureMode] # Routes AI SDK wrapper events through the dedicated AI capture lane, skips # truncation, and passes media unredacted. `privacy_mode` always wins. enable_full_ai_capture = False # type: bool @@ -1423,7 +1423,6 @@ def setup() -> Client: exception_autocapture_bucket_size=exception_autocapture_bucket_size, exception_autocapture_refill_rate=exception_autocapture_refill_rate, exception_autocapture_refill_interval_seconds=exception_autocapture_refill_interval_seconds, - capture_mode=capture_mode, ) # Always set in case user changes it. Preserve Client's auto-disabled state diff --git a/posthog/_async_consumer.py b/posthog/_async_consumer.py index 04eeb082b..0ae74b995 100644 --- a/posthog/_async_consumer.py +++ b/posthog/_async_consumer.py @@ -9,11 +9,11 @@ from dataclasses import dataclass from typing import Any, Optional -from ._async_request import async_batch_post, async_send_v1_batch +from ._async_request import async_send_v1_batch from .capture_compression import CaptureCompression -from .capture_mode import CaptureMode +from .capture_send import _CAPTURE_V1_PATH, _capture_loss_message from .consumer import BATCH_SIZE_LIMIT, MAX_MSG_SIZE -from .request import APIError, DatetimeSerializer, EVENTS_ENDPOINT +from .request import DatetimeSerializer _STOP = object() _PROCESSING_EVENT = contextvars.ContextVar( @@ -48,6 +48,33 @@ async def _invoke_callback(callback, *args): return result +# True while an SDK-invoked `on_error` runs, so a capture that fails inside the +# callback logs instead of re-entering it. +_IN_ON_ERROR: contextvars.ContextVar[bool] = contextvars.ContextVar( + "posthog_in_on_error", default=False +) + + +async def _report_capture_failure( + on_error: Optional[Callable[..., Any]], + log: logging.Logger, + error: Exception, + batch: list[dict[str, Any]], + endpoint: str, +) -> None: + """Hand a failed send to `on_error`, or log one aggregate line without one.""" + if on_error is None or _IN_ON_ERROR.get(): + log.error(_capture_loss_message(error, max(1, len(batch)), endpoint)) + return + token = _IN_ON_ERROR.set(True) + try: + await _invoke_callback(on_error, error, batch) + except Exception as callback_error: + log.error("on_error handler failed (%s)", type(callback_error).__name__) + finally: + _IN_ON_ERROR.reset(token) + + async def _serialized_event_size(event: dict[str, Any]) -> int: serialized = await asyncio.to_thread(json.dumps, event, cls=DatetimeSerializer) return len(serialized.encode()) @@ -68,13 +95,10 @@ def __init__( process_event: Callable[[dict[str, Any]], Awaitable[Optional[dict[str, Any]]]], flush_at: int, flush_interval: float, - gzip: bool, retries: int, timeout: int, historical_migration: bool, - capture_mode: CaptureMode, capture_compression: CaptureCompression, - http_client: Optional[Any], ) -> None: self.queue = queue self.api_key = api_key @@ -83,13 +107,10 @@ def __init__( self.process_event = process_event self.flush_at = flush_at self.flush_interval = flush_interval - self.gzip = gzip self.retries = max(0, retries) self.timeout = timeout self.historical_migration = historical_migration - self.capture_mode = capture_mode self.capture_compression = capture_compression - self.http_client = http_client self._carryover: Optional[tuple[dict[str, Any], int]] = None self._flush_event = asyncio.Event() @@ -149,18 +170,9 @@ async def upload(self, batch: list[dict[str, Any]]) -> None: try: await self.request(batch) except Exception as error: - self.log.error( - "async capture upload failed (%s, status=%s)", - type(error).__name__, - getattr(error, "status", None), + await _report_capture_failure( + self.on_error, self.log, error, batch, _CAPTURE_V1_PATH ) - if self.on_error: - try: - await _invoke_callback(self.on_error, error, batch) - except Exception as callback_error: - self.log.error( - "on_error handler failed (%s)", type(callback_error).__name__ - ) finally: for _ in batch: self.queue.task_done() @@ -238,50 +250,12 @@ async def next(self) -> tuple[list[dict[str, Any]], bool]: return items, stop async def request(self, batch: list[dict[str, Any]]) -> None: - if self.capture_mode == CaptureMode.V1: - await async_send_v1_batch( - self.api_key, - self.host, - batch, - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.retries, - historical_migration=self.historical_migration, - ) - return - - last_error: Optional[Exception] = None - for attempt in range(self.retries + 1): - try: - await async_batch_post( - self.api_key, - self.host, - batch=batch, - path=EVENTS_ENDPOINT, - gzip=self.gzip, - timeout=self.timeout, - historical_migration=self.historical_migration, - client=self.http_client, - ) - return - except Exception as error: - last_error = error - if not self._is_retryable(error) or attempt >= self.retries: - raise - retry_after = getattr(error, "retry_after", None) - delay = max( - min(2**attempt, 30), - min(retry_after, 30) if retry_after and retry_after > 0 else 0, - ) - await asyncio.sleep(delay) - - if last_error is not None: # pragma: no cover - loop always raises first - raise last_error - - @staticmethod - def _is_retryable(error: Exception) -> bool: - if not isinstance(error, APIError): - return True - if not isinstance(error.status, int): - return False - return not (400 <= error.status < 500 and error.status not in (408, 429)) + await async_send_v1_batch( + self.api_key, + self.host, + batch, + compression=self.capture_compression, + timeout=self.timeout, + max_retries=self.retries, + historical_migration=self.historical_migration, + ) diff --git a/posthog/_async_request.py b/posthog/_async_request.py index ae750de9a..9ddc7f7a2 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -2,16 +2,12 @@ import asyncio import json -import logging -import zlib from datetime import datetime, timezone -from gzip import GzipFile -from io import BytesIO from typing import Any, Optional -from urllib.parse import quote, urljoin, urlsplit +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, @@ -41,54 +37,6 @@ def _build_client(host: Optional[str] = None): return httpx_module.AsyncClient(base_url=base_url, follow_redirects=False) -def _serialize_v0_body( - api_key: str, gzip_enabled: bool, body: dict[str, Any] -) -> tuple[str | bytes, dict[str, str]]: - payload = { - **body, - "sent_at": datetime.now(tz=timezone.utc).isoformat(), - "api_key": api_key, - } - serialized = json.dumps(payload, cls=DatetimeSerializer) - data: str | bytes = serialized - headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} - - if gzip_enabled: - try: - buf = BytesIO() - with GzipFile(fileobj=buf, mode="w") as gz: - gz.write(serialized.encode("utf-8")) - data = buf.getvalue() - headers["Content-Encoding"] = "gzip" - except (OSError, zlib.error) as exc: - logging.getLogger("posthog").warning( - "failed to gzip async request body, sending uncompressed: %s", exc - ) - - return data, headers - - -def _origin(url: str) -> tuple[str, str, Optional[int]]: - parsed = urlsplit(url) - port = parsed.port - if port is None: - port = 443 if parsed.scheme.lower() == "https" else 80 - return parsed.scheme.lower(), (parsed.hostname or "").lower(), port - - -def _same_origin_redirect_url( - base_url: str, current_url: str, location: str -) -> Optional[str]: - target = urlsplit(urljoin(current_url, location)) - if _origin(target.geturl()) != _origin(base_url): - return None - return ( - urlsplit(base_url) - ._replace(path=target.path or "/", query=target.query, fragment="") - .geturl() - ) - - def _serialize_flags_body( project_api_key: str, body: dict[str, Any] ) -> tuple[str, dict[str, str]]: @@ -196,64 +144,6 @@ async def async_remote_config( await http_client.aclose() -async def async_batch_post( - api_key: str, - host: Optional[str], - *, - batch: list[dict[str, Any]], - path: str, - gzip: bool = False, - timeout: int = 15, - historical_migration: bool = False, - client: Optional[Any] = None, -) -> None: - """Post one legacy capture batch without blocking the event loop.""" - if not path.startswith("/") or "://" in path: - raise ValueError("async capture paths must be relative") - - data, headers = await asyncio.to_thread( - _serialize_v0_body, - api_key, - gzip, - { - "batch": batch, - "historical_migration": historical_migration, - }, - ) - - owns_client = client is None - http_client = client or _build_client(host) - try: - logging.getLogger("posthog").debug("making async capture request") - base_url = remove_trailing_slash(normalize_host(host)) - # Absolute URLs avoid reapplying an HTTPX base_url path on redirects. - request_url = f"{base_url}{path}" - for redirect_count in range(6): - response = await http_client.post( - request_url, content=data, headers=headers, timeout=timeout - ) - if response.status_code not in (307, 308): - _process_response(response) - return - - location = response.headers.get("Location") or response.headers.get( - "location" - ) - redirect_url = ( - _same_origin_redirect_url(base_url, request_url, location) - if location - else None - ) - if redirect_url is None: - raise APIError(400, "Cross-origin or invalid redirect blocked") - if redirect_count >= 5: - raise APIError(400, "Too many capture redirects") - request_url = redirect_url - finally: - if owns_client: - await http_client.aclose() - - async def async_send_v1_batch( api_key: str, host: Optional[str], diff --git a/posthog/ai/prompts.py b/posthog/ai/prompts.py index 2cbae9db4..c62841561 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/async_client.py b/posthog/async_client.py index 3f429e81a..37dfd2294 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -11,7 +11,7 @@ import weakref from datetime import datetime, timezone from typing import Any, Dict, Mapping, Optional, Union -from uuid import UUID, uuid4 +from uuid import UUID from typing_extensions import Unpack @@ -19,6 +19,7 @@ _STOP, _AsyncConsumer, _invoke_callback, + _report_capture_failure, _is_processing_event, _QueuedEvent, _run_outside_processing_event, @@ -26,7 +27,6 @@ ) from ._async_request import ( _build_client, - _require_httpx, async_flags as _async_flags, async_remote_config as _async_remote_config, ) @@ -35,7 +35,7 @@ CaptureCompression, _resolve_capture_compression, ) -from .capture_mode import CaptureMode, _resolve_capture_mode +from .capture_send import _CAPTURE_V1_PATH from .client import ( MAX_DICT_SIZE as _MAX_DICT_SIZE, _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, @@ -78,7 +78,13 @@ from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host from .types import FlagMetadata, FlagValue, normalize_flags_response -from .utils import SizeLimitedDict, _normalize_timestamp, clean, system_context +from .utils import ( + SizeLimitedDict, + _normalize_timestamp, + _uuid7, + clean, + system_context, +) from .version import VERSION __all__ = ["AsyncClient", "AsyncPosthog"] @@ -98,13 +104,13 @@ def __init__( self, project_api_key: str, host: Optional[str] = None, + *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, - gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, @@ -122,7 +128,6 @@ def __init__( code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, - capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, @@ -141,7 +146,6 @@ def __init__( self.debug = debug self.send = send self.on_error = on_error - self.gzip = gzip self.max_retries = max(0, max_retries) self.timeout = timeout self.disabled = disabled or not self.api_key @@ -150,10 +154,7 @@ def __init__( self.historical_migration = historical_migration self.super_properties = super_properties self._release_id = _resolve_release_id() - self.capture_mode = _resolve_capture_mode(capture_mode) - self.capture_compression = _resolve_capture_compression( - capture_compression, gzip_fallback=gzip - ) + self.capture_compression = _resolve_capture_compression(capture_compression) self.capture_trace_context = capture_trace_context if personal_api_key is not None and secret_key is None: warnings.warn( @@ -310,9 +311,6 @@ def _get_http_client(self): return self._http_client def _new_consumer(self) -> _AsyncConsumer: - http_client = ( - self._get_http_client() if self.capture_mode == CaptureMode.V0 else None - ) return _AsyncConsumer( self._queue, self.api_key, @@ -321,19 +319,12 @@ def _new_consumer(self) -> _AsyncConsumer: process_event=self._process_event, flush_at=self._flush_at, flush_interval=self._flush_interval, - gzip=self.gzip, retries=self.max_retries, timeout=self.timeout, historical_migration=self.historical_migration, - capture_mode=self.capture_mode, capture_compression=self.capture_compression, - http_client=http_client, ) - def _validate_transport_available(self) -> None: - if self.capture_mode == CaptureMode.V0: - _require_httpx() - def _ensure_workers_started(self) -> None: if self.disabled or not self.send or self._closed or self._worker_tasks: return @@ -397,7 +388,7 @@ def _normalize_uuid(self, msg: dict[str, Any]) -> str: msg["uuid"] = normalized return normalized - normalized = str(uuid4()) + normalized = str(_uuid7()) msg["uuid"] = normalized return normalized @@ -523,7 +514,6 @@ def capture( if not self.send: return sent_uuid - self._validate_transport_available() if not self._enqueue_prepared_event(prepared): return None self.log.debug("queued async event %s", event) @@ -594,20 +584,11 @@ async def capture_immediate( await consumer.request(error_batch) return sent_uuid except Exception as error: - if self.on_error: - try: - await _invoke_callback(self.on_error, error, error_batch) - except Exception as callback_error: - self.log.error( - "on_error handler failed (%s)", type(callback_error).__name__ - ) + await _report_capture_failure( + self.on_error, self.log, error, error_batch, _CAPTURE_V1_PATH + ) if self.debug: raise - self.log.error( - "Immediate async capture failed (%s, status=%s)", - type(error).__name__, - getattr(error, "status", None), - ) return None finally: remaining_calls = self._immediate_callers[current] - 1 @@ -745,7 +726,6 @@ def _enqueue_built_event( return None if not self.send: return sent_uuid - self._validate_transport_available() if not self._enqueue_prepared_event(prepared): return None return sent_uuid diff --git a/posthog/capture_compression.py b/posthog/capture_compression.py index 659c12fab..257c80c88 100644 --- a/posthog/capture_compression.py +++ b/posthog/capture_compression.py @@ -19,10 +19,9 @@ class CaptureCompression(str, Enum): - """Selects the request-body compression for capture-v1 uploads. + """Selects the request-body compression for capture uploads. - Only honored when ``capture_mode`` is ``V1``; the legacy ``/batch/`` path - keeps using its own ``gzip`` flag. ``NONE`` sends the body uncompressed. + ``NONE`` sends the body uncompressed. ``GZIP`` and ``DEFLATE`` (zlib, RFC 1950) are both stdlib / zero-dependency; ``ZSTD`` is faster and compresses better but needs the optional zstandard package (``pip install posthog[zstd]``) until stdlib support lands in @@ -55,6 +54,7 @@ def _zstd_available() -> bool: def _coerce_explicit( value: Union[CaptureCompression, str], + name: str = "capture_compression", ) -> CaptureCompression: """Normalize an explicitly-supplied compression to a ``CaptureCompression``. @@ -69,22 +69,19 @@ def _coerce_explicit( if resolved is not None: return resolved raise ValueError( - f"invalid capture_compression {value!r}; expected a CaptureCompression " + f"invalid {name} {value!r}; expected a CaptureCompression " f"or one of {sorted(_ALIASES)}" ) def _resolve_capture_compression( capture_compression: Optional[Union[CaptureCompression, str]] = None, - *, - gzip_fallback: bool = False, ) -> CaptureCompression: - """Resolve the effective v1 compression. + """Resolve the effective capture compression. Precedence: explicit ``capture_compression`` argument > - ``POSTHOG_CAPTURE_COMPRESSION`` env var > the legacy ``gzip`` flag - (``GZIP`` when set) > ``NONE``. An unrecognized env value logs a warning and - falls back to the ``gzip`` flag, so a typo never silently changes encoding. + ``POSTHOG_CAPTURE_COMPRESSION`` env var > ``NONE``. An unrecognized env + value logs a warning and falls back to ``NONE``. ``ZSTD`` requires the optional zstandard package: explicitly requesting it without the package raises ``ValueError`` (programming error, fail loud), @@ -100,7 +97,7 @@ def _resolve_capture_compression( ) return resolved - fallback = CaptureCompression.GZIP if gzip_fallback else CaptureCompression.NONE + fallback = CaptureCompression.NONE raw = os.environ.get(CAPTURE_COMPRESSION_ENV_VAR) if raw is None or raw.strip() == "": @@ -126,3 +123,22 @@ def _resolve_capture_compression( ) return fallback return env_resolved + + +def _resolve_capture_ai_compression( + capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, +) -> CaptureCompression: + """Resolve the AI lane's request-body compression. + + Explicit argument only, defaulting to ``NONE``. ``POSTHOG_CAPTURE_COMPRESSION`` + does not apply, so changing analytics compression never changes AI uploads. + """ + if capture_ai_compression is None: + return CaptureCompression.NONE + resolved = _coerce_explicit(capture_ai_compression, "capture_ai_compression") + if resolved is CaptureCompression.ZSTD and not _zstd_available(): + raise ValueError( + "capture_ai_compression 'zstd' requires the zstandard package; " + "install posthog[zstd]" + ) + return resolved diff --git a/posthog/capture_event.py b/posthog/capture_event.py new file mode 100644 index 000000000..bc50a4628 --- /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_mode.py b/posthog/capture_mode.py deleted file mode 100644 index 7ea3696f2..000000000 --- a/posthog/capture_mode.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import os -from enum import Enum -from typing import Optional, Union - -__all__ = ["CAPTURE_MODE_ENV_VAR", "CaptureMode"] - -log = logging.getLogger("posthog") - -CAPTURE_MODE_ENV_VAR = "POSTHOG_CAPTURE_MODE" - - -class CaptureMode(str, Enum): - """Selects the capture wire protocol used for event ingestion. - - ``V0`` is the legacy ``POST /batch/`` endpoint and the default, so upgrading - is transparent to existing callers. ``V1`` opts into - ``POST /i/v1/analytics/events`` (Bearer auth, per-event results, partial - retry). Inheriting from ``str`` keeps the members directly comparable to and - serializable as their ``"v0"`` / ``"v1"`` values. - """ - - V0 = "v0" - V1 = "v1" - - -# Accepted spellings for both the explicit kwarg and the env var. Aliases mirror -# the posthog-go naming (``legacy`` / ``analytics_v1``) so the two SDKs are -# configured with the same vocabulary. -_ALIASES: dict[str, CaptureMode] = { - "v0": CaptureMode.V0, - "legacy": CaptureMode.V0, - "v1": CaptureMode.V1, - "analytics_v1": CaptureMode.V1, -} - - -def _coerce_explicit(value: Union[CaptureMode, str]) -> CaptureMode: - """Normalize an explicitly-supplied capture mode to a ``CaptureMode``. - - Accepts a ``CaptureMode`` or one of the string aliases. An explicit but - unrecognized value is a programming error, so it raises ``ValueError`` rather - than silently defaulting (unlike the env var, which is operator-supplied and - defaults defensively). - """ - if isinstance(value, CaptureMode): - return value - if isinstance(value, str): - resolved = _ALIASES.get(value.strip().lower()) - if resolved is not None: - return resolved - raise ValueError( - f"invalid capture_mode {value!r}; expected a CaptureMode or one of " - f"{sorted(_ALIASES)}" - ) - - -def _resolve_capture_mode( - capture_mode: Optional[Union[CaptureMode, str]] = None, -) -> CaptureMode: - """Resolve the effective capture mode. - - Precedence: explicit ``capture_mode`` argument > ``POSTHOG_CAPTURE_MODE`` env - var > ``CaptureMode.V0``. An unrecognized env value logs a warning and falls - back to ``V0`` so a typo never silently flips the wire protocol. - """ - if capture_mode is not None: - return _coerce_explicit(capture_mode) - - raw = os.environ.get(CAPTURE_MODE_ENV_VAR) - if raw is None or raw.strip() == "": - return CaptureMode.V0 - - resolved = _ALIASES.get(raw.strip().lower()) - if resolved is None: - log.warning( - "Unrecognized %s=%r; falling back to %s. Expected one of %s.", - CAPTURE_MODE_ENV_VAR, - raw, - CaptureMode.V0.value, - sorted(_ALIASES), - ) - return CaptureMode.V0 - return resolved diff --git a/posthog/capture_v1.py b/posthog/capture_send.py similarity index 62% rename from posthog/capture_v1.py rename to posthog/capture_send.py index 478175484..f254f165d 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_send.py @@ -1,22 +1,9 @@ -"""Serialization and transport for the Capture V1 wire protocol. - -This module owns everything specific to ``POST /i/v1/analytics/events``: 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. +"""Transport for the capture v1 wire protocol. + +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): 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), @@ -24,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). @@ -36,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 uuid import uuid4 +from typing import TYPE_CHECKING, Optional +from uuid import UUID 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, @@ -53,19 +40,20 @@ _get_session, normalize_host, ) -from posthog.utils import _normalize_timestamp, remove_trailing_slash +from posthog.utils import _uuid7, remove_trailing_slash if TYPE_CHECKING: import requests log = logging.getLogger("posthog") -# 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 +# Only the error types are public API: they reach user code through `on_error` +# callbacks, so callers may want to catch/inspect them. Everything else is # submitter plumbing. -__all__ = ["CaptureV1Error"] +__all__ = ["CaptureError", "CaptureEventResult"] _CAPTURE_V1_PATH = "/i/v1/analytics/events" +_CAPTURE_AI_V1_PATH = "/i/v1/ai/events" # Required request/response headers for the v1 endpoint. Defined here as the # single source of truth; the transport layer builds requests from them. @@ -92,152 +80,19 @@ # 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), -) +# First retry delay; it doubles per attempt up to `_MAX_BACKOFF_SECONDS`. Matches +# posthog-go. +_BACKOFF_BASE_SECONDS = 0.1 -def _v1_timestamp(timestamp: Any) -> str: - """Return a UTC RFC3339 timestamp string. +@dataclass(frozen=True) +class CaptureEventResult: + """One event's verdict from a 2xx capture response. - 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. + ``result`` is ``ok``, ``warning``, ``drop`` or ``retry``, or a value newer + than this SDK, which counts as success. ``details`` is the server's reason + tag, such as ``billing_limit_exceeded``. """ - 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: - """A single event's directive from a 2xx ``results`` map.""" result: Optional[str] details: Optional[str] = None @@ -256,17 +111,22 @@ class _V1ParsedResponse: status_code: int is_success: bool retry_after: Optional[float] = None - results: Optional[dict[str, _V1EventResult]] = None + results: Optional[dict[str, CaptureEventResult]] = None malformed: bool = False error_message: str = "" -class CaptureV1Error(APIError): - """Batch-level failure of a capture-v1 send. +class CaptureError(APIError): + """A capture batch that was not fully delivered. - Subclasses :class:`APIError` so the consumer's existing ``on_error`` handling - (which already inspects ``status``/``retry_after``) keeps working; the extra - fields carry v1 specifics for richer logging/callbacks. + Passed to ``on_error`` with the batch. ``status`` is the HTTP status of the + last attempt, or ``0`` when no response arrived (``__cause__`` holds the + transport error). A 2xx ``status`` means the request succeeded but some + events were dropped or ran out of retries. + + ``event_results`` maps each event uuid to its last verdict from a 2xx + response. Events missing from it never got a verdict, because every + attempt that reached them failed as a whole request. """ def __init__( @@ -274,25 +134,101 @@ def __init__( status: int | str, message: str, *, + endpoint: 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, + event_results: Optional[dict[str, CaptureEventResult]] = None, ): super().__init__(status, message, retry_after=retry_after) + # Capture path the batch was sent to, so one on_error handler can tell + # the analytics and AI lanes apart. + self.endpoint = endpoint self.request_id = request_id self.attempts = attempts # uuids the server told us to retry but we never delivered (exhausted). self.retry_exhausted = retry_exhausted or [] # (uuid, details) pairs the server told us to drop on a 2xx response. self.drops = drops or [] + self.event_results = event_results or {} + + def verdict_summary(self) -> str: + """Count the undelivered events by verdict and reason, for example + ``drop/billing_limit_exceeded=2, retry/not_persisted=1``. + + Counts the events in ``drops`` and ``retry_exhausted``: events the + server dropped, and events still pending retry after the last 2xx + response. A retry verdict followed by a failed request, for example a + 503 on the last attempt, is not counted; ``event_results`` keeps it. + Empty when no event is counted. + """ + counts: dict[str, int] = {} + failed = [uid for uid, _ in self.drops] + self.retry_exhausted + for uid in failed: + verdict = self.event_results.get(uid) + if verdict is None or not verdict.result: + continue + tag = verdict.result + if verdict.details: + tag = f"{tag}/{verdict.details}" + counts[tag] = counts.get(tag, 0) + 1 + return ", ".join(f"{tag}={n}" for tag, n in sorted(counts.items())) def _is_success_status(status: int) -> bool: return 200 <= status < 300 +def _canonical_uuid(value: str) -> str: + """Return ``value`` in the lowercase hyphenated form capture keys results by. + + ``before_send`` can set any uuid form capture parses. A value that does not + parse is returned unchanged; capture rejects its whole batch anyway. + """ + try: + return str(UUID(value)) + except (TypeError, ValueError, AttributeError): + return value + + +def _undelivered_count(error: Exception, batch_size: int) -> int: + if not isinstance(error, CaptureError): + return batch_size + delivered = sum( + 1 + for r in error.event_results.values() + if r.result not in (_RESULT_DROP, _RESULT_RETRY) + ) + return max(0, batch_size - delivered) + + +def _capture_loss_message(error: Exception, batch_size: int, endpoint: str) -> str: + """One aggregate line for a failed batch, for callers with no ``on_error``. + + It never names individual events, includes payloads, or repeats the server's + error text: any of them may carry sensitive content, and per-event lines + scale with event volume. + """ + count = _undelivered_count(error, batch_size) + if isinstance(error, CaptureError): + endpoint = error.endpoint + if isinstance(error.status, int) and _is_success_status(error.status): + detail = f"{len(error.drops)} dropped, {len(error.retry_exhausted)} out of retries" + summary = error.verdict_summary() + if summary: + detail = f"{detail} ({summary})" + return f"{count} event(s) not persisted by {endpoint}: {detail}" + detail = type(error).__name__ + if error.__cause__ is not None: + detail = f"{detail} from {type(error.__cause__).__name__}" + status = getattr(error, "status", None) + if status is not None: + detail = f"{detail} (status={status})" + return f"{count} event(s) not persisted by {endpoint}: {detail}" + + def _parse_retry_after(header_value: Optional[str]) -> Optional[float]: """Parse a ``Retry-After`` header (delta-seconds or HTTP-date) to seconds.""" if not header_value: @@ -350,8 +286,9 @@ def _post_v1( timeout: int = 15, sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, + path: str = _CAPTURE_V1_PATH, ) -> "requests.Response": - """Perform a single ``POST /i/v1/analytics/events`` attempt. + """Perform a single capture v1 ``POST`` to ``path``. Bearer-authed (no ``api_key`` in the body) with the required v1 headers. ``attempt`` (1-based) and the stable ``request_id`` are echoed via @@ -361,7 +298,7 @@ def _post_v1( the caller. """ trimmed_host = remove_trailing_slash(normalize_host(host)) - url = trimmed_host + _CAPTURE_V1_PATH + url = trimmed_host + path data = json.dumps(batch_body, cls=DatetimeSerializer) headers = { "Content-Type": "application/json", @@ -392,7 +329,7 @@ def _parse_v1_response(res: "requests.Response") -> _V1ParsedResponse: payload = res.json() raw_results = payload["results"] results = { - uid: _V1EventResult( + uid: CaptureEventResult( result=(r or {}).get("result"), details=(r or {}).get("details"), ) @@ -432,7 +369,7 @@ def _backoff(attempt_index: int, retry_after: Optional[float]) -> None: :data:`_MAX_BACKOFF_SECONDS`, so both sides share one ceiling and a hostile/buggy header can't park the consumer thread. """ - configured = min(2**attempt_index, _MAX_BACKOFF_SECONDS) + configured = min(_BACKOFF_BASE_SECONDS * 2**attempt_index, _MAX_BACKOFF_SECONDS) clamped_retry_after = ( min(retry_after, _MAX_BACKOFF_SECONDS) if retry_after and retry_after > 0 else 0 ) @@ -440,7 +377,7 @@ def _backoff(attempt_index: int, retry_after: Optional[float]) -> None: def _log_result_summary( - request_id: str, attempt: int, results: dict[str, _V1EventResult] + request_id: str, attempt: int, results: dict[str, CaptureEventResult] ) -> None: tally = {_RESULT_OK: 0, _RESULT_WARNING: 0, _RESULT_DROP: 0, _RESULT_RETRY: 0} other = 0 @@ -473,39 +410,63 @@ def _send_v1_batch( historical_migration: bool = False, sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, + path: str = _CAPTURE_V1_PATH, ) -> None: - """Deliver ``batch`` to the v1 endpoint with partial retry. + """Deliver ``batch`` to the v1 endpoint at ``path`` with partial retry. - The v1 sibling of ``Consumer._send``: it loops up to ``max_retries + 1`` - attempts, but unlike v0 it shrinks the batch to only the events the server - tagged ``retry`` after each 2xx. ``ok``/``warning``/absent events succeed. + Loops up to ``max_retries + 1`` attempts. After each 2xx it resends only + the events the server tagged ``retry``. ``ok``/``warning`` events, events + with an unrecognized verdict, and events absent from ``results`` succeed + (matching posthog-go and posthog-rs). Results are matched by canonical + uuid, because capture keys them that way whatever form was sent. 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, - 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 - collected on an earlier attempt are still tallied in the DEBUG summary). + 2xx with undelivered verdicts). Raises :class:`CaptureError` on any drop, + batch-level terminal failure, retry exhaustion, or transport failure + (``status`` 0, chained to the transport error), carrying the endpoint, the + accumulated ``drops``, any exhausted uuids, and every verdict seen so far, + so the caller's ``on_error`` gets the full picture. ``request_id`` and the batch ``created_at`` are stable across attempts; ``PostHog-Attempt`` increments. Negative ``max_retries`` values are treated as zero, so delivery is always attempted at least once. """ max_retries = max(0, max_retries) - request_id = str(uuid4()) + request_id = str(_uuid7()) # Hoisted once so the batch envelope is byte-identical across retry attempts # (only the events list shrinks and the attempt header increments). created_at = datetime.now(timezone.utc).isoformat() pending_events = [_to_v1_event(m) for m in batch] - pending_uuids = [e["uuid"] for e in pending_events] - last_exc: Optional[Exception] = None + pending_uuids = [_canonical_uuid(e["uuid"]) for e in pending_events] # (uuid, details) for every event the server dropped, across all attempts. # Accumulated (not per-attempt) so a drop seen early is not lost when a # later attempt succeeds or clears the outstanding retries. all_drops: list[tuple[str, Optional[str]]] = [] + # Latest 2xx verdict per uuid, across all attempts. + event_results: dict[str, CaptureEventResult] = {} + + def capture_error( + status: int, + message: str, + *, + attempts: int, + retry_after: Optional[float] = None, + retry_exhausted: Optional[list[str]] = None, + ) -> CaptureError: + return CaptureError( + status, + message, + endpoint=path, + retry_after=retry_after, + request_id=request_id, + attempts=attempts, + retry_exhausted=retry_exhausted, + drops=all_drops, + event_results=event_results, + ) for attempt_index in range(max_retries + 1): attempt = attempt_index + 1 @@ -525,12 +486,14 @@ def _send_v1_batch( timeout=timeout, sdk_info=sdk_info, session=session, + path=path, ) except Exception as e: - # Transport-level failure (connection/timeout): retry like v0 does. - last_exc = e + # Transport-level failure (connection/timeout): retryable. if last_attempt: - raise + raise capture_error( + 0, f"{type(e).__name__}: {e}", attempts=attempt + ) from e _backoff(attempt_index, None) continue @@ -538,12 +501,10 @@ def _send_v1_batch( if parsed.is_success: if parsed.malformed: - raise CaptureV1Error( + raise capture_error( parsed.status_code, "capture v1 returned a success status with an unparseable body", - request_id=request_id, attempts=attempt, - drops=all_drops, ) results = parsed.results or {} _log_result_summary(request_id, attempt, results) @@ -553,8 +514,11 @@ def _send_v1_batch( for event, uid in zip(pending_events, pending_uuids): directive = results.get(uid) if directive is None: - # Absent from the map: treated as accepted (matches posthog-rs). + # Absent from the map: treated as accepted (matches + # posthog-go and posthog-rs). Capture answers every event + # of a 2xx, so this only happens if it breaks that contract. continue + event_results[uid] = directive if directive.result == _RESULT_RETRY: retry_events.append(event) retry_uuids.append(uid) @@ -569,22 +533,18 @@ 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 capture_error( parsed.status_code, f"{len(all_drops)} event(s) dropped by the server", - request_id=request_id, attempts=attempt, - drops=all_drops, ) return if last_attempt: - raise CaptureV1Error( + raise capture_error( parsed.status_code, f"{len(retry_uuids)} event(s) still pending retry after {attempt} attempt(s)", - request_id=request_id, attempts=attempt, retry_exhausted=retry_uuids, - drops=all_drops, ) pending_events, pending_uuids = retry_events, retry_uuids _backoff(attempt_index, parsed.retry_after) @@ -593,22 +553,13 @@ 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 = capture_error( parsed.status_code, parsed.error_message, retry_after=parsed.retry_after, - request_id=request_id, attempts=attempt, - drops=all_drops, ) - if parsed.status_code in _RETRYABLE_STATUSES: - last_exc = v1_error - if last_attempt: - raise v1_error + if parsed.status_code in _RETRYABLE_STATUSES and not last_attempt: _backoff(attempt_index, parsed.retry_after) continue raise v1_error - - # Unreachable in practice (every branch returns or continues). - if last_exc: - raise last_exc diff --git a/posthog/client.py b/posthog/client.py index 2bb16c492..a0af85319 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2,7 +2,9 @@ import hashlib as _hashlib import inspect import json +from contextlib import contextmanager import logging +import math import os import sys import threading @@ -29,10 +31,15 @@ from posthog.tracing._span import inert_span as _inert_span from posthog.capture_compression import ( CaptureCompression, + _resolve_capture_ai_compression, _resolve_capture_compression, ) -from posthog.capture_mode import CaptureMode, _resolve_capture_mode -from posthog.capture_v1 import _send_v1_batch +from posthog.capture_send import ( + _CAPTURE_AI_V1_PATH, + _CAPTURE_V1_PATH, + _capture_loss_message, + _send_v1_batch, +) from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE, Consumer, _DrainSignal from posthog.contexts import ( _get_current_context, @@ -85,16 +92,14 @@ from posthog.poller import Poller from posthog.release_id import _resolve_release_id from posthog.request import ( - AI_EVENTS_ENDPOINT, - EVENTS_ENDPOINT, USER_AGENT as _USER_AGENT, APIError, + DatetimeSerializer as _DatetimeSerializer, QuotaLimitError, RequestsConnectionError, RequestsTimeout, _get as _get_with_identity, _remote_config as _remote_config_with_identity, - batch_post, determine_server_host, flags, get, @@ -124,6 +129,7 @@ SizeLimitedDict, clean, _normalize_timestamp, + _uuid7, guess_timezone as guess_timezone, system_context, ) @@ -141,6 +147,9 @@ # overrun it by up to the client's request timeout. _TRACES_SHUTDOWN_FLUSH_SECONDS = 30.0 _atexit_deadline: Optional[float] = None +# Marks a thread that is running a sync-mode `on_error`, so a capture that fails +# inside the callback logs instead of re-entering it. +_on_error_state = threading.local() _atexit_deadline_lock = threading.Lock() @@ -222,6 +231,28 @@ def _get_atexit_deadline() -> float: return _atexit_deadline +def _positive_config_value( + name: str, value, *, integer: bool = False, maximum: Optional[int] = None +): + """Return ``value`` if it is finite, positive and no larger than ``maximum``. + + Bad lane config is a programming error, so it raises instead of falling + back to a default. + """ + allowed = (int,) if integer else (int, float) + if ( + isinstance(value, bool) + or not isinstance(value, allowed) + or not math.isfinite(value) + or value <= 0 + ): + kind = "integer" if integer else "number" + raise ValueError(f"{name} must be a positive {kind}, got {value!r}") + if maximum is not None and value > maximum: + raise ValueError(f"{name} must be at most {maximum}, got {value!r}") + return value + + def get_identity_state(passed) -> tuple[str, bool]: """Returns the distinct id to use, and whether this is a personless event or not""" stringified = stringify_id(passed) @@ -246,14 +277,13 @@ def _stringify_event_uuid(value) -> str: ) try: - UUID(stringified) + # Canonical form, because capture keys per-event results by it. + return str(UUID(stringified)) except ValueError: raise ValueError( f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance." ) from None - return stringified - def add_context_tags(properties): properties = properties or {} @@ -374,13 +404,11 @@ def __init__( send, flush_at, flush_interval, - gzip, max_retries, timeout, historical_migration, endpoint, max_msg_size, - capture_mode, capture_compression, sdk_info, eager_start, @@ -392,13 +420,11 @@ def __init__( self.send = send self.flush_at = flush_at self.flush_interval = flush_interval - self.gzip = gzip self.max_retries = max_retries self.timeout = timeout self.historical_migration = historical_migration self.endpoint = endpoint self.max_msg_size = max_msg_size - self.capture_mode = capture_mode self.capture_compression = capture_compression self.sdk_info = sdk_info self._max_queue_size = max_queue_size @@ -427,13 +453,11 @@ def _start_locked(self) -> None: on_error=self.on_error, flush_at=self.flush_at, flush_interval=self.flush_interval, - gzip=self.gzip, retries=self.max_retries, timeout=self.timeout, historical_migration=self.historical_migration, endpoint=self.endpoint, max_msg_size=self.max_msg_size, - capture_mode=self.capture_mode, capture_compression=self.capture_compression, ) consumer._sdk_info = self.sdk_info @@ -671,13 +695,13 @@ def __init__( self, project_api_key: str, host=None, + *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, - gzip=False, max_retries=3, sync_mode=False, timeout=15, @@ -709,13 +733,14 @@ def __init__( 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_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_max_queue_size: int = 1000, + capture_ai_timeout: float = 30, + capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, - # Appended rather than grouped with the other `capture_*` options so - # existing positional arguments keep their slots. capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, @@ -731,17 +756,21 @@ def __init__( the corresponding ingestion host. debug: Enable verbose SDK logging and re-raise errors from public API methods. - max_queue_size: Maximum number of events buffered before upload. + max_queue_size: Maximum number of analytics events buffered before + upload. AI events use ``capture_ai_max_queue_size``. send: If False, queueing succeeds but events are not sent. - on_error: Optional callback invoked by background consumers when an - upload fails. Keep it short and non-blocking. Calling lifecycle + on_error: Optional callback ``(error, batch)`` invoked when an upload + fails: by background consumers, or on the calling thread in + ``sync_mode``. Capture failures arrive as ``CaptureError``. + Without it, each failed batch logs one aggregate line. A capture + that fails inside the callback logs that line instead of calling + it again. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback. flush_at: Number of queued events that triggers a batch upload. flush_interval: Maximum seconds a background consumer waits before flushing a partial batch. - gzip: Whether to gzip event upload payloads. max_retries: Number of upload retries. Values below 0 are treated as 0. sync_mode: If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in @@ -840,16 +869,26 @@ def __init__( interval for each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. - capture_mode: Capture wire protocol to use. Defaults to - ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` - (or pass the string ``"v1"``) to opt into - ``/i/v1/analytics/events``. When omitted, the - ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``. - capture_compression: Request-body compression for capture-v1 uploads - (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` - or ``DEFLATE`` (or the strings ``"gzip"``/``"deflate"``). When - omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, - then the legacy ``gzip`` flag, then no compression. + capture_compression: Request-body compression for ``capture()`` + uploads. ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the + strings ``"gzip"``/``"deflate"``). When omitted, the + ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then no + compression. + capture_ai_compression: Request-body compression for + ``capture_ai()`` uploads, set independently of + ``capture_compression``. Defaults to no compression, and the + env var does not apply. ``CaptureCompression.ZSTD`` suits large + AI payloads. + capture_ai_max_queue_size: Maximum number of AI events buffered + before upload. Defaults to 1000, lower than ``max_queue_size`` + because AI events are much larger. + capture_ai_timeout: Seconds allowed for one AI upload request. + Defaults to 30, longer than ``timeout`` because AI batches are + much larger. + capture_ai_max_event_bytes: Largest serialized AI event the SDK + sends; a larger one is dropped with an error log. Defaults to + the AI endpoint's ceiling plus envelope headroom, and may only + be lowered. Examples: ```python @@ -890,7 +929,6 @@ def __init__( self.raw_host = normalize_host(host) self.host = determine_server_host(host) self._duplicate_client_registry_key: Optional[tuple[str, str]] = None - self.gzip = gzip self.timeout = timeout self.max_retries = max(0, max_retries) self._feature_flags: Optional[list[Any]] = ( @@ -950,17 +988,24 @@ def __init__( ) self.is_server = is_server self.historical_migration = historical_migration - # Selects the capture wire protocol (V0 legacy `/batch/` vs V1 - # `/i/v1/analytics/events`). Resolved here so the env-var fallback is - # applied once; V0 is the default and keeps upgrades transparent. - self.capture_mode = _resolve_capture_mode(capture_mode) self._library_id = "posthog-python" self._library_version = VERSION self._sdk_info = f"{self._library_id}/{self._library_version}" - # v1-only request compression; falls back to the legacy `gzip` flag when - # neither the kwarg nor POSTHOG_CAPTURE_COMPRESSION is set. - self.capture_compression = _resolve_capture_compression( - capture_compression, gzip_fallback=gzip + self.capture_compression = _resolve_capture_compression(capture_compression) + self.capture_ai_compression = _resolve_capture_ai_compression( + capture_ai_compression + ) + capture_ai_max_queue_size = _positive_config_value( + "capture_ai_max_queue_size", capture_ai_max_queue_size, integer=True + ) + capture_ai_timeout = _positive_config_value( + "capture_ai_timeout", capture_ai_timeout + ) + capture_ai_max_event_bytes = _positive_config_value( + "capture_ai_max_event_bytes", + capture_ai_max_event_bytes, + integer=True, + maximum=AI_MAX_MSG_SIZE, ) self.super_properties = super_properties # Release id from POSTHOG_RELEASE_ID, attached to every event. Resolved @@ -1071,39 +1116,34 @@ def __init__( api_key=self.api_key, host=self.host, on_error=on_error, - max_queue_size=max_queue_size, thread_count=thread, send=send, flush_at=flush_at, flush_interval=flush_interval, - gzip=gzip, max_retries=self.max_retries, - timeout=timeout, historical_migration=historical_migration, sdk_info=self._sdk_info, ) self._analytics_lane = _Lane( name="analytics", **lane_defaults, - endpoint=EVENTS_ENDPOINT, + max_queue_size=max_queue_size, + timeout=timeout, + endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, - capture_mode=self.capture_mode, capture_compression=self.capture_compression, eager_start=not sync_mode, ) - # The AI lane is pinned to the v0 submitter: the AI endpoint has no v1 - # form, and this keeps multi-MB AI events away from capture v1's - # smaller caps. The `capture_compression` pin is inert on v0 — its wire - # compression is the `gzip` flag, inherited from client config. Lazy - # start, so the many clients that never emit AI events pay for no - # extra threads. + # A separate endpoint keeps multi-MB AI events off the analytics caps. + # Lazy start, so clients that never send AI events pay for no threads. self._ai_lane = _Lane( name="ai", **lane_defaults, - endpoint=AI_EVENTS_ENDPOINT, - max_msg_size=AI_MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, - capture_compression=CaptureCompression.NONE, + max_queue_size=capture_ai_max_queue_size, + timeout=capture_ai_timeout, + endpoint=_CAPTURE_AI_V1_PATH, + max_msg_size=capture_ai_max_event_bytes, + capture_compression=self.capture_ai_compression, eager_start=False, ) self._lanes = [self._analytics_lane, self._ai_lane] @@ -2339,7 +2379,26 @@ def _normalize_event_uuid(self, msg): if "uuid" not in msg: # Always send a uuid, so we can always return one - msg["uuid"] = stringify_id(uuid4()) + msg["uuid"] = str(_uuid7()) + + def _report_capture_failure( + self, error: Exception, batch: list[dict], endpoint: str + ) -> None: + """Hand a failed sync-mode send to `on_error`, like the queued path does. + + Without a handler, or when the failed capture ran inside `on_error`, + log one aggregate line instead, so the loss stays visible without recursion. + """ + if not self.on_error or getattr(_on_error_state, "active", False): + self.log.error(_capture_loss_message(error, len(batch), endpoint)) + return + _on_error_state.active = True + try: + self.on_error(error, batch) + except Exception as callback_error: + self.log.error("on_error handler failed: %s", callback_error) + finally: + _on_error_state.active = False def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): # type: (...) -> Optional[str] @@ -2437,35 +2496,46 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): if self.sync_mode: self.log.debug("enqueued with blocking %s.", msg["event"]) - def send_sync() -> None: - # Sync mode bypasses the lane's queue but keeps its wire config: - # the AI lane is pinned to v0, so its events post to the AI - # endpoint regardless of `capture_mode`. - if lane.capture_mode == CaptureMode.V1: - _send_v1_batch( - self.api_key, - self.host, - [msg], - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.max_retries, - historical_migration=self.historical_migration, - sdk_info=self._sdk_info, - ) - return + try: + event_size = len(json.dumps(msg, cls=_DatetimeSerializer).encode()) + except Exception: + self.log.error("Unable to serialize event for sizing, dropping.") + return None + if event_size > lane.max_msg_size: + # Log only name and size: AI events may carry unredacted + # multimodal payloads that must not leak into logs. + self.log.error( + "Event %s (%d bytes) exceeds the %dKiB limit for %s, dropping.", + msg["event"], + event_size, + lane.max_msg_size // 1024, + lane.endpoint, + ) + return None - batch_post( + def send_sync() -> None: + # Sync mode bypasses the lane's queue but keeps its wire config, + # so AI events still post to the AI endpoint. + _send_v1_batch( self.api_key, self.host, - gzip=self.gzip, - timeout=self.timeout, - batch=[msg], + [msg], + compression=lane.capture_compression, + timeout=lane.timeout, + max_retries=self.max_retries, historical_migration=self.historical_migration, + sdk_info=self._sdk_info, path=lane.endpoint, - **self._request_identity_kwargs(), ) - if lane.run_sync_if_open(send_sync): + try: + admitted = lane.run_sync_if_open(send_sync) + except Exception as e: + self._report_capture_failure(e, [msg], lane.endpoint) + if self.debug: + raise + return None + if admitted: return sent_uuid self.log.warning( "%s lane received event %s after shutdown, dropping it", @@ -2689,13 +2759,14 @@ def flush(self, timeout_seconds: Optional[float] = 10) -> None: # Spans drain with events: serverless handlers call flush(), not # shutdown(), and leaving spans on their own timer would lose them. span_flush = self._start_span_flush(timeout_seconds) - if timeout_seconds is None: - for lane in self._lanes: - lane.flush(None) - else: - deadline = time.monotonic() + timeout_seconds - for lane in self._lanes: - lane.flush(max(0.0, deadline - time.monotonic())) + with self._drain_lanes_together(): + if timeout_seconds is None: + for lane in self._lanes: + lane.flush(None) + else: + deadline = time.monotonic() + timeout_seconds + for lane in self._lanes: + lane.flush(max(0.0, deadline - time.monotonic())) if span_flush is not None: # The last span request is bounded only by the request # timeout, so the wait is not. @@ -2832,7 +2903,36 @@ def _run_lifecycle_cleanup( self.log.exception(log_message) errors.append(error) + @contextmanager + def _drain_lanes_together(self): + """Signal every lane to drain before waiting on any of them. + + Lanes then drain in parallel under one budget. Otherwise a lane keeps + batching on its normal cadence while the client waits on the lane before it. + """ + signals: list[_DrainSignal] = [] + try: + for lane in self._lanes: + signal = lane._drain_signal + signal.request() + signals.append(signal) + yield + finally: + for signal in signals: + signal.complete() + def _flush_or_discard_queues(self, errors: list[Exception]) -> None: + try: + with self._drain_lanes_together(): + self._flush_or_discard_each_lane(errors) + return + except Exception as error: + self.log.exception("Failed to signal lane drains during lifecycle cleanup") + errors.append(error) + # Each lane's flush signals its own drain, so lanes still drain one by one. + self._flush_or_discard_each_lane(errors) + + def _flush_or_discard_each_lane(self, errors: list[Exception]) -> None: for lane in self._lanes: try: if any(consumer.is_alive() for consumer in lane.consumers): diff --git a/posthog/consumer.py b/posthog/consumer.py index c604b9756..46c8c6abb 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -6,14 +6,14 @@ from posthog._logging import _configure_posthog_logging from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode -from posthog.capture_v1 import _backoff, _send_v1_batch +from posthog.capture_send import ( + _CAPTURE_V1_PATH, + _capture_loss_message, + _send_v1_batch, +) from posthog.request import ( - EVENTS_ENDPOINT, USER_AGENT as _USER_AGENT, - APIError, DatetimeSerializer, - batch_post, ) from queue import Empty @@ -21,13 +21,18 @@ MAX_MSG_SIZE = 900 * 1024 # 900KiB per event -# AI events carry LLM inputs/outputs and post to a dedicated endpoint whose -# pipeline accepts larger messages than analytics ingestion, so the AI lane -# grants a higher per-event ceiling. `next()` appends an item before checking -# BATCH_SIZE_LIMIT, so worst-case request body is BATCH_SIZE_LIMIT + -# AI_MAX_MSG_SIZE (~13MiB) — keep that sum under the 20MiB server body cap. -AI_MAX_MSG_SIZE = 8 * 1024 * 1024 # 8MiB per event - +# The AI endpoint's per-event ceiling. The endpoint applies it to the +# serialized properties alone. +AI_MAX_PROPERTIES_SIZE = 8 * 1024 * 1024 +# The local guard measures the whole event, not only its properties, so it +# allows this much more to keep events at the endpoint's ceiling. +AI_ENVELOPE_HEADROOM = 64 * 1024 +# The AI lane's per-event guard, and the upper bound for +# `capture_ai_max_event_bytes`. +AI_MAX_MSG_SIZE = AI_MAX_PROPERTIES_SIZE + AI_ENVELOPE_HEADROOM + +# A batch closes before it appends an event that would take it past this, so +# a request carries at most this much event data, or one larger event alone. # The maximum request body size is currently 20MiB, let's be conservative # in case we want to lower it in the future. BATCH_SIZE_LIMIT = 5 * 1024 * 1024 @@ -109,13 +114,11 @@ def __init__( host=None, on_error=None, flush_interval=5.0, - gzip=False, - retries=10, + retries=3, timeout=15, historical_migration=False, - endpoint=EVENTS_ENDPOINT, + endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, ): """Create a consumer thread.""" @@ -128,10 +131,8 @@ def __init__( self.host = host self.on_error = on_error self.queue = queue - self.gzip = gzip self.endpoint = endpoint self.max_msg_size = max_msg_size - self.capture_mode = capture_mode self.capture_compression = capture_compression self._sdk_info = _USER_AGENT self._drain_signal: Optional[_DrainSignal] = None @@ -180,13 +181,14 @@ def upload(self): self.request(batch) success = True except Exception as e: - self.log.error("error uploading: %s", e) success = False if self.on_error: try: self.on_error(e, batch) - except Exception as e: - self.log.error("on_error handler failed: %s", e) + except Exception as callback_error: + self.log.error("on_error handler failed: %s", callback_error) + else: + self.log.error(_capture_loss_message(e, len(batch), self.endpoint)) finally: # mark items as acknowledged from queue for item in batch: @@ -268,6 +270,11 @@ def next(self): queue.task_done() pending_items -= 1 continue + if items and total_size + item_size > BATCH_SIZE_LIMIT: + self._return_to_queue_head(item) + pending_items -= 1 + self.log.debug("hit batch size limit (size: %d)", total_size) + break items.append(item) total_size += item_size if total_size >= BATCH_SIZE_LIMIT: @@ -287,67 +294,29 @@ def next(self): return items - def request(self, batch): - """Upload the batch via the wire protocol selected by `capture_mode`. + def _return_to_queue_head(self, item) -> None: + """Put a dequeued event back at the head of the queue for the next batch. - V1 uses the partial-retry submitter (which posts to its own path); V0 - posts the batch to this consumer's `endpoint`. + The event stays counted in ``unfinished_tasks``, because it was never + marked done. Keeping it in the queue, not in the consumer, means a + stop, a discard or a fork accounts for it like any other queued event. """ - if self.capture_mode == CaptureMode.V1: - _send_v1_batch( - self.api_key, - self.host, - batch, - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.retries, - historical_migration=self.historical_migration, - sdk_info=self._sdk_info, - ) - return - self._send(batch, self.endpoint) - - def _send(self, batch, path): - """Attempt to upload a single batch to `path`, retrying before raising an error""" - - def is_retryable(exc): - if isinstance(exc, APIError): - # retry on server errors and client errors - # with 408 (request timeout) or 429 (rate limited), - # don't retry on other client errors - if isinstance(exc.status, int): - return not ( - (400 <= exc.status < 500) and exc.status not in (408, 429) - ) - return False - else: - # retry on all other errors (eg. network) - return True + queue = self.queue + with queue.not_empty: + queue.queue.appendleft(item) + queue.not_empty.notify() - last_exc = None - for attempt in range(self.retries + 1): - try: - batch_post( - self.api_key, - self.host, - gzip=self.gzip, - timeout=self.timeout, - batch=batch, - historical_migration=self.historical_migration, - path=path, - **( - {"_user_agent": self._sdk_info} - if self._sdk_info != _USER_AGENT - else {} - ), - ) - return - except Exception as e: - last_exc = e - if not is_retryable(e): - raise - if attempt < self.retries: - _backoff(attempt, getattr(e, "retry_after", None)) - - if last_exc: - raise last_exc + def request(self, batch): + """Upload the batch to this consumer's `endpoint` with the capture v1 + partial-retry submitter.""" + _send_v1_batch( + self.api_key, + self.host, + batch, + compression=self.capture_compression, + timeout=self.timeout, + max_retries=self.retries, + historical_migration=self.historical_migration, + sdk_info=self._sdk_info, + path=self.endpoint, + ) diff --git a/posthog/request.py b/posthog/request.py index 76df1fdc9..2f68fc38e 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -3,11 +3,8 @@ import re import socket import time -import zlib from dataclasses import dataclass from datetime import date, datetime, timezone -from gzip import GzipFile -from io import BytesIO from typing import Any, List, Optional, Tuple, Union, cast import requests @@ -216,7 +213,6 @@ def post( api_key: str, host: Optional[str] = None, path: Optional[str] = None, - gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs, @@ -229,7 +225,7 @@ def post( trimmed_host = remove_trailing_slash(normalize_host(host)) url = trimmed_host + cast(str, path) body["api_key"] = api_key - data: str | bytes = json.dumps(body, cls=DatetimeSerializer) + data = json.dumps(body, cls=DatetimeSerializer) if log.isEnabledFor(logging.DEBUG): log.debug( "making request: %s to url: %s", @@ -237,17 +233,6 @@ def post( url, ) headers = {"Content-Type": "application/json", "User-Agent": user_agent} - if gzip: - try: - buf = BytesIO() - with GzipFile(fileobj=buf, mode="w") as gz: - # 'data' was produced by json.dumps(), - # whose default encoding is utf-8. - gz.write(cast(str, data).encode("utf-8")) - data = buf.getvalue() - headers["Content-Encoding"] = "gzip" - except (OSError, zlib.error) as exc: - log.warning("failed to gzip request body, sending uncompressed: %s", exc) res = (session or _get_session()).post( url, data=data, headers=headers, timeout=timeout @@ -314,7 +299,6 @@ def _feature_flags_retry_delay(failed_attempt: int) -> float: def flags( api_key: str, host: Optional[str] = None, - gzip: bool = False, timeout: int = 15, max_retries: int = 1, **kwargs, @@ -330,7 +314,6 @@ def flags( api_key, host, "/flags/?v=2", - gzip, timeout, session=_get_flags_session(), _user_agent=user_agent, @@ -382,25 +365,6 @@ def _remote_config( return response.data -EVENTS_ENDPOINT = "/batch/" -AI_EVENTS_ENDPOINT = "/i/v0/ai/batch/" - - -def batch_post( - api_key: str, - host: Optional[str] = None, - gzip: bool = False, - timeout: int = 15, - path: str = EVENTS_ENDPOINT, - **kwargs, -) -> requests.Response: - """Post the `kwargs` to the batch API endpoint for events""" - res = post(api_key, host, path, gzip, timeout, **kwargs) - return _process_response( - res, success_message="data uploaded successfully", return_json=False - ) - - def get( api_key: str, url: str, diff --git a/posthog/test/capture_helpers.py b/posthog/test/capture_helpers.py new file mode 100644 index 000000000..3ead56f5e --- /dev/null +++ b/posthog/test/capture_helpers.py @@ -0,0 +1,58 @@ +"""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_event``. Wire shape is +covered by ``test_capture_event``. +""" + +import json +from unittest import mock + +from requests import Response + +_SUBMITTER = "_send_v1_batch" + + +def offline_v1_post(url: str, data=None, **kwargs) -> Response: + """Stand-in for ``requests.Session.post`` that accepts every v1 event. + + For subprocess tests with no server. Prints the uncompressed request body, + because the SDK never logs payloads, and answers ``ok`` for each event. + """ + print(f"capture request body: {data}", flush=True) # noqa: T201 + events = json.loads(data)["batch"] + response = Response() + response.status_code = 200 + response._content = json.dumps( + {"results": {event["uuid"]: {"result": "ok"} for event in events}} + ).encode() + return response + + +def patch_capture_send(site: str = "client", **kwargs) -> "mock._patch": + """Patch the submitter where ``posthog.`` imported it. + + ``site="client"`` sees ``sync_mode`` uploads; ``site="consumer"`` sees + background consumer uploads. + """ + return mock.patch(f"posthog.{site}.{_SUBMITTER}", **kwargs) + + +def patch_async_capture_send(**kwargs) -> "mock._patch": + """Patch the submitter the ``AsyncPosthog`` consumer awaits.""" + return mock.patch("posthog._async_consumer.async_send_v1_batch", **kwargs) + + +def sent_batch(send_mock: mock.Mock, call_index: int = -1) -> list[dict]: + """Return the event batch from one recorded upload (default: the last).""" + call = send_mock.call_args_list[call_index] + return call.args[2] if len(call.args) > 2 else call.kwargs["batch"] + + +def sent_events(send_mock: mock.Mock) -> list[dict]: + """Return every event uploaded through ``send_mock``, in send order.""" + return [ + event + for index in range(len(send_mock.call_args_list)) + for event in sent_batch(send_mock, index) + ] diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 6ed662e81..8820a156a 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -9,7 +9,6 @@ import pytest from mcp.types import CallToolResult, ServerResult, TextContent, Tool -from posthog.capture_mode import CaptureMode from posthog.mcp import ( PostHogMCP, PreparedToolCall, @@ -134,20 +133,8 @@ def before_send(event): ) -def test_mcp_library_identity_reaches_capture_v0_header(): - response = mock.Mock(status_code=200) - client = PostHogMCP("phc_test", sync_mode=True) - - with mock.patch("posthog.request._session.post", return_value=response) as post: - client.capture("$mcp_custom") - - assert post.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - - def test_mcp_library_identity_reaches_capture_v1_header(): - client = PostHogMCP("phc_test", sync_mode=True, capture_mode=CaptureMode.V1) + client = PostHogMCP("phc_test", sync_mode=True) with mock.patch("posthog.client._send_v1_batch") as send: client.capture("$mcp_custom") diff --git a/posthog/test/snapshots/legacy_event_family.json b/posthog/test/snapshots/event_family.json similarity index 77% rename from posthog/test/snapshots/legacy_event_family.json rename to posthog/test/snapshots/event_family.json index 4e33c6bd8..0d72f4cf5 100644 --- a/posthog/test/snapshots/legacy_event_family.json +++ b/posthog/test/snapshots/event_family.json @@ -1,18 +1,16 @@ { "body": { - "api_key": "phc_snapshot_project", "batch": [ { "distinct_id": "user-123", "event": "order completed", + "options": {}, "properties": { "$geoip_disable": true, "$groups": { "company": "company-456" }, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "$os": "", "$os_distro": "", "$os_version": "", @@ -34,17 +32,16 @@ "uuid": "00000000-0000-4000-8000-000000000001" }, { - "$set": { - "email": "person@example.com", - "plan": "pro" - }, "distinct_id": "user-123", "event": "$set", + "options": {}, "properties": { "$geoip_disable": true, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "" + "$set": { + "email": "person@example.com", + "plan": "pro" + } }, "timestamp": "2026-01-02T03:04:05+00:00", "uuid": "00000000-0000-4000-8000-000000000002" @@ -52,11 +49,10 @@ { "distinct_id": "anonymous-789", "event": "$create_alias", + "options": {}, "properties": { "$geoip_disable": true, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "alias": "user-123", "distinct_id": "anonymous-789" }, @@ -66,6 +62,7 @@ { "distinct_id": "user-123", "event": "$groupidentify", + "options": {}, "properties": { "$geoip_disable": true, "$group_key": "company-456", @@ -74,21 +71,23 @@ "name": "Example Corp" }, "$group_type": "company", - "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "" + "$is_server": true }, "timestamp": "2026-01-02T03:04:05+00:00", "uuid": "00000000-0000-4000-8000-000000000004" } ], - "historical_migration": false, - "sent_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05+00:00" }, "headers": { + "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", + "PostHog-Attempt": "1", + "PostHog-Request-Id": "", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, "timeout": 15, - "url": "https://example.posthog.test/batch/" + "url": "https://example.posthog.test/i/v1/analytics/events" } diff --git a/posthog/test/snapshots/exception_event.json b/posthog/test/snapshots/exception_event.json index b971c7afe..59c72d814 100644 --- a/posthog/test/snapshots/exception_event.json +++ b/posthog/test/snapshots/exception_event.json @@ -1,10 +1,10 @@ { "body": { - "api_key": "phc_snapshot_project", "batch": [ { "distinct_id": "user-123", "event": "$exception", + "options": {}, "properties": { "$exception_list": [ { @@ -53,7 +53,7 @@ "", "def _exception_request():", " session = mock.MagicMock()", - " session.post.return_value = _successful_response()" + " session.post.side_effect = _capture_ok_response" ], "pre_context": [ "", @@ -113,8 +113,6 @@ "company": "company-456" }, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "$os": "", "$os_distro": "", "$os_version": "", @@ -127,13 +125,17 @@ "uuid": "00000000-0000-4000-8000-000000000005" } ], - "historical_migration": false, - "sent_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05+00:00" }, "headers": { + "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", + "PostHog-Attempt": "1", + "PostHog-Request-Id": "", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, "timeout": 15, - "url": "https://example.posthog.test/batch/" + "url": "https://example.posthog.test/i/v1/analytics/events" } diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index 3ab2f9f87..2234de1b3 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -1,23 +1,27 @@ +import os import threading import unittest import uuid from unittest import mock +from parameterized import parameterized + import posthog from posthog.ai.utils import _capture_ai_event, finalize_ai_content, with_privacy_mode -from posthog.capture_mode import CaptureMode +from posthog.capture_compression import CAPTURE_COMPRESSION_ENV_VAR, CaptureCompression from posthog.client import Client -from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE -from posthog.request import AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT +from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE +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 -def _events_by_path(mock_post): +def _events_by_path(mock_send): by_path: dict[str, list] = {} - for call in mock_post.call_args_list: - by_path.setdefault(call.kwargs["path"], []).extend(call.kwargs["batch"]) + for index, call in enumerate(mock_send.call_args_list): + by_path.setdefault(call.kwargs["path"], []).extend(sent_batch(mock_send, index)) return by_path @@ -29,24 +33,24 @@ def _client(self, **kwargs): def test_capture_ai_and_capture_ride_separate_lanes(self): client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("button_clicked", distinct_id="d") client.capture_ai("$ai_generation", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {EVENTS_ENDPOINT, AI_EVENTS_ENDPOINT}) + self.assertEqual(set(by_path), {_CAPTURE_V1_PATH, _CAPTURE_AI_V1_PATH}) self.assertEqual( - [e["event"] for e in by_path[EVENTS_ENDPOINT]], ["button_clicked"] + [e["event"] for e in by_path[_CAPTURE_V1_PATH]], ["button_clicked"] ) self.assertEqual( - [e["event"] for e in by_path[AI_EVENTS_ENDPOINT]], ["$ai_generation"] + [e["event"] for e in by_path[_CAPTURE_AI_V1_PATH]], ["$ai_generation"] ) - for call in mock_post.call_args_list: - events = {e["event"] for e in call.kwargs["batch"]} + for index, call in enumerate(mock_post.call_args_list): + events = {e["event"] for e in sent_batch(mock_post, index)} expected = ( {"$ai_generation"} - if call.kwargs["path"] == AI_EVENTS_ENDPOINT + if call.kwargs["path"] == _CAPTURE_AI_V1_PATH else {"button_clicked"} ) self.assertEqual(events, expected) @@ -55,13 +59,13 @@ def test_capture_does_not_reroute_ai_named_events(self): # The two-lane rule: `capture()` never special-cases AI events, no # matter their name. Only `capture_ai()` reaches the AI lane. client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("$ai_generation", distinct_id="d") client.flush() self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [EVENTS_ENDPOINT], + [_CAPTURE_V1_PATH], ) def test_capture_ai_returns_event_uuid_like_capture(self): @@ -71,21 +75,21 @@ def test_capture_ai_returns_event_uuid_like_capture(self): def test_sync_mode_capture_ai_posts_single_event_batch_to_ai_endpoint(self): client = Client(TEST_API_KEY, sync_mode=True) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client.capture_ai("$ai_generation", distinct_id="d") mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(mock_post.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + batch = sent_batch(mock_post) self.assertEqual([e["event"] for e in batch], ["$ai_generation"]) def test_multimodal_client_routes_wrapper_captures_to_ai_lane(self): client = self._client(enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {AI_EVENTS_ENDPOINT}) + self.assertEqual(set(by_path), {_CAPTURE_AI_V1_PATH}) def test_disabled_client_never_starts_ai_lane(self): client = Client(TEST_API_KEY, disabled=True) @@ -113,7 +117,6 @@ def test_analytics_consumers_keep_todays_parameters(self): thread=2, flush_at=7, flush_interval=0.5, - gzip=True, max_retries=4, timeout=9, historical_migration=True, @@ -122,29 +125,27 @@ def test_analytics_consumers_keep_todays_parameters(self): self.assertEqual(len(consumers), 2) for consumer in consumers: self.assertIs(consumer.queue, client.queue) - self.assertEqual(consumer.endpoint, EVENTS_ENDPOINT) + self.assertEqual(consumer.endpoint, _CAPTURE_V1_PATH) self.assertEqual(consumer.max_msg_size, MAX_MSG_SIZE) self.assertEqual(consumer.flush_at, 7) self.assertEqual(consumer.flush_interval, 0.5) - self.assertTrue(consumer.gzip) self.assertEqual(consumer.retries, 4) self.assertEqual(consumer.timeout, 9) self.assertTrue(consumer.historical_migration) - self.assertEqual(consumer.capture_mode, client.capture_mode) self.assertEqual(consumer.capture_compression, client.capture_compression) client.join() def test_analytics_traffic_posts_to_single_endpoint(self): client = Client(TEST_API_KEY, flush_interval=0.05) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("event_a", distinct_id="d") client.capture("event_b", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {EVENTS_ENDPOINT}) + self.assertEqual(set(by_path), {_CAPTURE_V1_PATH}) self.assertEqual( - sorted(e["event"] for e in by_path[EVENTS_ENDPOINT]), + sorted(e["event"] for e in by_path[_CAPTURE_V1_PATH]), ["event_a", "event_b"], ) client.join() @@ -152,9 +153,9 @@ def test_analytics_traffic_posts_to_single_endpoint(self): def test_sync_mode_analytics_path_unchanged(self): client = Client(TEST_API_KEY, sync_mode=True) self.assertIsNone(client.consumers) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client.capture("button_clicked", distinct_id="d") - self.assertEqual(mock_post.call_args.kwargs["path"], EVENTS_ENDPOINT) + self.assertEqual(mock_post.call_args.kwargs["path"], _CAPTURE_V1_PATH) class TestLaneSizeCaps(unittest.TestCase): @@ -176,14 +177,45 @@ def test_ai_lane_accepts_multi_megabyte_events(self): batch = consumer.next() self.assertEqual([e["event"] for e in batch], ["$ai_generation"]) - def test_ai_lane_drops_events_over_its_cap(self): - client = self._client() + @parameterized.expand( + [ + ("properties_at_endpoint_ceiling", {}, AI_MAX_PROPERTIES_SIZE, True), + ("over_guard", {}, AI_MAX_MSG_SIZE, False), + ( + "over_lowered_cap", + {"capture_ai_max_event_bytes": 1024 * 1024}, + 2 * 1024 * 1024, + False, + ), + ] + ) + def test_ai_lane_size_guard(self, _name, config, payload_bytes, accepted): + client = Client(TEST_API_KEY, send=False, flush_interval=0.05, **config) client._ai_lane.start() consumer = client._ai_lane.consumers[0] - client._ai_lane.queue.put(self._sized_event("$ai_generation", AI_MAX_MSG_SIZE)) - self.assertEqual(consumer.next(), []) + client._ai_lane.queue.put(self._sized_event("$ai_generation", payload_bytes)) + self.assertEqual( + [e["event"] for e in consumer.next()], + ["$ai_generation"] if accepted else [], + ) self.assertTrue(client._ai_lane.queue.empty()) + def test_sync_mode_ai_event_over_cap_is_not_sent(self): + client = Client( + TEST_API_KEY, sync_mode=True, capture_ai_max_event_bytes=1024 * 1024 + ) + with patch_capture_send("client") as mock_send: + with self.assertLogs("posthog", level="ERROR") as logs: + result = client.capture_ai( + "$ai_generation", + distinct_id="d", + properties={"p": "x" * (2 * 1024 * 1024)}, + ) + + self.assertIsNone(result) + mock_send.assert_not_called() + self.assertIn("exceeds the 1024KiB limit", "\n".join(logs.output)) + def test_analytics_lane_rejects_events_over_900kib(self): client = self._client() consumer = client.consumers[0] @@ -192,48 +224,152 @@ def test_analytics_lane_rejects_events_over_900kib(self): self.assertTrue(client.queue.empty()) -class TestAiLaneV0Pinned(unittest.TestCase): - """The AI endpoint has no v1 form: the AI lane ignores `capture_mode=v1`.""" +class TestAiLaneWireConfig(unittest.TestCase): + """The AI lane has its own endpoint, compression, timeout, queue and size + guard, independent of the analytics lane's settings.""" - def test_ai_lane_consumers_pin_v0_and_ai_endpoint(self): - client = Client(TEST_API_KEY, send=False, capture_mode="v1", thread=2) + @parameterized.expand( + [ + ("defaults", {}, CaptureCompression.NONE, 30, 1000, AI_MAX_MSG_SIZE), + ( + "configured", + { + "capture_ai_compression": "zstd", + "capture_ai_timeout": 45, + "capture_ai_max_queue_size": 50, + "capture_ai_max_event_bytes": 1024 * 1024, + }, + CaptureCompression.ZSTD, + 45, + 50, + 1024 * 1024, + ), + ] + ) + def test_ai_lane_consumers_use_ai_config( + self, _name, config, compression, timeout, queue_size, max_event_bytes + ): + with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): + client = Client( + TEST_API_KEY, + send=False, + capture_compression="gzip", + timeout=9, + max_queue_size=77, + thread=2, + **config, + ) client._ai_lane.start() + self.assertEqual(client._ai_lane.queue.maxsize, queue_size) self.assertEqual(len(client._ai_lane.consumers), 2) for consumer in client._ai_lane.consumers: self.assertIs(consumer.queue, client._ai_lane.queue) - self.assertEqual(consumer.endpoint, AI_EVENTS_ENDPOINT) - self.assertEqual(consumer.max_msg_size, AI_MAX_MSG_SIZE) - self.assertEqual(consumer.capture_mode, CaptureMode.V0) - - def test_async_ai_events_use_v0_even_with_capture_mode_v1(self): - client = Client(TEST_API_KEY, capture_mode="v1", flush_interval=0.05) - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + self.assertEqual(consumer.endpoint, _CAPTURE_AI_V1_PATH) + self.assertEqual(consumer.max_msg_size, max_event_bytes) + self.assertEqual(consumer.capture_compression, compression) + self.assertEqual(consumer.timeout, timeout) + self.assertEqual(client.queue.maxsize, 77) + for consumer in client._analytics_lane.consumers: + self.assertEqual(consumer.capture_compression, CaptureCompression.GZIP) + self.assertEqual(consumer.timeout, 9) + client.join() + + @parameterized.expand( + [ + ( + "event_bytes_over_ceiling", + "capture_ai_max_event_bytes", + AI_MAX_MSG_SIZE + 1, + ), + ("event_bytes_zero", "capture_ai_max_event_bytes", 0), + ("event_bytes_float", "capture_ai_max_event_bytes", 1024.5), + ("event_bytes_bool", "capture_ai_max_event_bytes", True), + ("queue_size_negative", "capture_ai_max_queue_size", -1), + ("queue_size_string", "capture_ai_max_queue_size", "100"), + ("timeout_zero", "capture_ai_timeout", 0), + ("timeout_nan", "capture_ai_timeout", float("nan")), + ("timeout_inf", "capture_ai_timeout", float("inf")), + ("compression_unknown", "capture_ai_compression", "br"), + ] + ) + def test_invalid_ai_config_raises(self, _name, field, value): + with self.assertRaisesRegex(ValueError, field): + Client(TEST_API_KEY, send=False, **{field: value}) + + def test_async_lanes_keep_separate_path_and_compression(self): + client = Client(TEST_API_KEY, capture_compression="gzip", flush_interval=0.05) + with patch_capture_send("consumer") as mock_send: client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") client.flush() - mock_v1.assert_called() + sends = { + call.kwargs["path"]: call.kwargs["compression"] + for call in mock_send.call_args_list + } self.assertEqual( - [call.kwargs["path"] for call in mock_post.call_args_list], - [AI_EVENTS_ENDPOINT], + sends, + { + _CAPTURE_AI_V1_PATH: CaptureCompression.NONE, + _CAPTURE_V1_PATH: CaptureCompression.GZIP, + }, + ) + self.assertEqual( + _events_by_path(mock_send)[_CAPTURE_AI_V1_PATH][0]["event"], + "$ai_generation", ) client.join() - def test_sync_ai_events_use_v0_even_with_capture_mode_v1(self): - client = Client(TEST_API_KEY, sync_mode=True, capture_mode="v1") - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): + def test_sync_lanes_keep_separate_path_compression_and_timeout(self): + client = Client( + TEST_API_KEY, + sync_mode=True, + capture_compression="gzip", + capture_ai_compression="deflate", + timeout=9, + capture_ai_timeout=45, + ) + with patch_capture_send("client") as mock_send: client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - mock_v1.assert_called_once() + self.assertEqual( + [ + ( + call.kwargs["path"], + call.kwargs["compression"], + call.kwargs["timeout"], + ) + for call in mock_send.call_args_list + ], + [ + (_CAPTURE_AI_V1_PATH, CaptureCompression.DEFLATE, 45), + (_CAPTURE_V1_PATH, CaptureCompression.GZIP, 9), + ], + ) + + def test_flush_drains_ai_lane_while_waiting_on_analytics(self): + release_analytics = threading.Event() + ai_sent = threading.Event() + + def send(api_key, host, batch, **kwargs): + if kwargs["path"] == _CAPTURE_AI_V1_PATH: + ai_sent.set() + else: + release_analytics.wait(5) + + client = Client(TEST_API_KEY, flush_interval=30) + with patch_capture_send("consumer", side_effect=send): + client.capture("button_clicked", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") + flusher = threading.Thread(target=client.flush, args=(10,)) + flusher.start() + try: + self.assertTrue(ai_sent.wait(2)) + finally: + release_analytics.set() + flusher.join(5) + client.join() class TestAiLaneLazyStart(unittest.TestCase): @@ -241,12 +377,12 @@ def test_no_ai_consumers_until_first_capture_ai(self): client = Client(TEST_API_KEY, flush_interval=0.05) self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture("button_clicked", distinct_id="d") client.flush() self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture_ai("$ai_generation", distinct_id="d") self.assertEqual(len(client._ai_lane.consumers), 1) self.assertTrue(client._ai_lane.consumers[0].is_alive()) @@ -262,7 +398,7 @@ def fire(): client.capture_ai("$ai_generation", distinct_id="d") threads = [threading.Thread(target=fire) for _ in range(8)] - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): for thread in threads: thread.start() for thread in threads: @@ -285,7 +421,7 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): client = Client( TEST_API_KEY, flush_interval=0.05, enable_local_evaluation=False ) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) @@ -305,14 +441,14 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): self.assertTrue(client._analytics_lane.consumers[0].is_alive()) self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("button_clicked", distinct_id="d") client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) self.assertEqual( - set(_events_by_path(mock_post)), {EVENTS_ENDPOINT, AI_EVENTS_ENDPOINT} + set(_events_by_path(mock_post)), {_CAPTURE_V1_PATH, _CAPTURE_AI_V1_PATH} ) client.join() @@ -332,7 +468,7 @@ class TestCaptureAiEventHelper(unittest.TestCase): def test_opted_in_routes_through_ai_lane(self): client = Client(TEST_API_KEY, flush_interval=0.05, enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event( client, "$ai_generation", @@ -343,19 +479,19 @@ def test_opted_in_routes_through_ai_lane(self): self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [AI_EVENTS_ENDPOINT], + [_CAPTURE_AI_V1_PATH], ) client.join() def test_default_keeps_capture_path(self): client = Client(TEST_API_KEY, flush_interval=0.05) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client.flush() self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [EVENTS_ENDPOINT], + [_CAPTURE_V1_PATH], ) self.assertEqual(client._ai_lane.consumers, []) client.join() @@ -437,7 +573,7 @@ class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): def test_late_ai_capture_after_shutdown_starts_nothing_and_sends_nothing(self): client = Client(TEST_API_KEY, enable_full_ai_capture=True, flush_interval=0.05) client.shutdown() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client._ai_lane.queue.join() self.assertEqual(client._ai_lane.consumers, []) @@ -499,10 +635,10 @@ def _client(self, **kwargs): def test_new_flag_routes_wrapper_captures_to_ai_lane(self): client = self._client(enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client.flush() - self.assertEqual(set(_events_by_path(mock_post)), {AI_EVENTS_ENDPOINT}) + self.assertEqual(set(_events_by_path(mock_post)), {_CAPTURE_AI_V1_PATH}) def test_deprecated_kwargs_map_to_new_flag(self): for kwargs in ({"_use_ai_lane": True}, {"_enable_multimodal_capture": True}): @@ -570,24 +706,24 @@ def _client(self, **kwargs): def test_returned_uuid_matches_the_wire_event_uuid(self): client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], returned_uuid) def test_supplied_uuid_is_preserved_end_to_end(self): client = self._client() supplied_uuid = str(uuid.uuid4()) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai( "$ai_generation", distinct_id="d", uuid=supplied_uuid ) client.flush() self.assertEqual(returned_uuid, supplied_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], supplied_uuid) def test_returned_uuid_reflects_before_send_replacement(self): @@ -598,12 +734,12 @@ def replace_uuid(event): return event client = self._client(before_send=replace_uuid) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(returned_uuid, replacement_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], replacement_uuid) def test_returned_uuid_is_regenerated_when_before_send_removes_it(self): @@ -612,12 +748,12 @@ def drop_uuid(event): return event client = self._client(before_send=drop_uuid) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertIsNotNone(returned_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], returned_uuid) diff --git a/posthog/test/test_async_client.py b/posthog/test/test_async_client.py index fcb6e2298..02ce2ce4c 100644 --- a/posthog/test/test_async_client.py +++ b/posthog/test/test_async_client.py @@ -7,10 +7,11 @@ import sys import threading from unittest import mock +from uuid import UUID import pytest -from posthog import AsyncClient, AsyncPosthog, CaptureCompression, CaptureMode +from posthog import AsyncClient, AsyncPosthog, CaptureCompression from posthog.consumer import MAX_MSG_SIZE from posthog.contexts import ( new_context, @@ -21,6 +22,7 @@ set_code_variables_mask_url_credentials_context, ) from posthog.request import APIError +from posthog.test.capture_helpers import patch_async_capture_send @pytest.mark.asyncio @@ -45,17 +47,17 @@ async def test_async_posthog_is_the_customer_facing_async_client(): async def test_capture_is_a_synchronous_queue_write_and_flushes(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog("test-key", flush_at=100, flush_interval=30) as client: event_uuid = client.capture( "async event", distinct_id="user-1", properties={"plan": "pro"}, ) - assert isinstance(event_uuid, str) + assert UUID(event_uuid).version == 7 await client.flush(timeout_seconds=1) assert len(batches) == 1 @@ -76,16 +78,15 @@ def test_capture_from_worker_thread_wakes_loop_bound_queue(): import threading from unittest import mock from posthog import AsyncPosthog +from posthog.test.capture_helpers import patch_async_capture_send async def main(): delivered = [] - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=batch_post - ): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_interval=30) client._ensure_workers_started() while not client._queue._getters: @@ -162,10 +163,10 @@ async def before_send(event): event["properties"]["from_before_send"] = True return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog( "test-key", before_send=before_send, flush_interval=30 ) as client: @@ -185,10 +186,10 @@ def before_send(event): event["properties"]["request_id"] = request_id.get() return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send, flush_interval=30) token = request_id.set("request-A") client.capture("event-A", distinct_id="user-1") @@ -217,7 +218,7 @@ def before_send(event): callback_thread = threading.get_ident() return event - with mock.patch("posthog._async_consumer.async_batch_post", new=mock.AsyncMock()): + with patch_async_capture_send(new=mock.AsyncMock()): client = AsyncPosthog("test-key", before_send=before_send) result = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() @@ -231,9 +232,7 @@ async def test_capture_drops_event_when_before_send_raises(): async def before_send(_event): raise RuntimeError("callback failed") - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: async with AsyncPosthog( "test-key", before_send=before_send, flush_interval=0.01 ) as client: @@ -241,18 +240,18 @@ async def before_send(_event): await client.flush(timeout_seconds=1) assert accepted_uuid is not None - batch_post.assert_not_awaited() + send_batch.assert_not_awaited() @pytest.mark.asyncio async def test_capture_immediate_waits_for_delivery(): delivered = asyncio.Event() - async def batch_post(*args, **kwargs): - assert kwargs["batch"][0]["event"] == "immediate event" + async def send_batch(api_key, host, batch, **kwargs): + assert batch[0]["event"] == "immediate event" delivered.set() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") event_uuid = await client.capture_immediate( "immediate event", distinct_id="user-1" @@ -271,10 +270,10 @@ async def before_send(event): event["properties"]["processed"] = True return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send) result = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() @@ -289,15 +288,13 @@ def before_send(event): event["properties"]["user_input"] = "x" * MAX_MSG_SIZE return event - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: client = AsyncPosthog("test-key", before_send=before_send) result = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() assert result is None - batch_post.assert_not_awaited() + send_batch.assert_not_awaited() @pytest.mark.asyncio @@ -310,7 +307,6 @@ async def test_capture_immediate_uses_capture_v1_without_building_httpx_client() ): client = AsyncPosthog( "test-key", - capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.GZIP, ) event_uuid = await client.capture_immediate("event", distinct_id="user-1") @@ -323,23 +319,6 @@ async def test_capture_immediate_uses_capture_v1_without_building_httpx_client() assert send_v1.await_args.args[2][0]["uuid"] == event_uuid -@pytest.mark.asyncio -async def test_missing_async_extra_does_not_accept_undeliverable_events(): - with mock.patch( - "posthog.async_client._require_httpx", - side_effect=RuntimeError("install posthog[async]"), - ): - client = AsyncPosthog("test-key") - assert client.capture("event", distinct_id="user-1") is None - assert ( - client.set(distinct_id="user-1", properties={"email": "a@example.com"}) - is None - ) - assert client._pending_queue_items() == 0 - assert client._worker_tasks == [] - await client.shutdown() - - @pytest.mark.asyncio async def test_send_false_accepts_without_starting_workers_or_transport(): with mock.patch("posthog.async_client._build_client") as build_client: @@ -382,10 +361,10 @@ async def test_identify_methods_enqueue_events( ): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog("test-key", flush_interval=30) as client: method = getattr(client, method_name) assert method(**method_kwargs) is not None @@ -409,12 +388,12 @@ async def test_capture_after_shutdown_is_dropped_without_restarting_workers(): async def test_batch_size_overflow_event_is_sent_in_the_next_batch(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch("posthog._async_consumer.BATCH_SIZE_LIMIT", 800), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("test-key", flush_at=10, flush_interval=30) client.capture("first", distinct_id="user-1", properties={"value": "a" * 400}) @@ -440,10 +419,10 @@ async def before_send(event): await allow_slow_callback.wait() return event - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog( "test-key", thread=2, @@ -469,12 +448,12 @@ async def test_shutdown_waits_for_an_in_flight_batch_instead_of_cancelling_it(): allow_upload = asyncio.Event() delivered = [] - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - delivered.extend(kwargs["batch"]) + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_at=1) client.capture("event", distinct_id="user-1") await upload_started.wait() @@ -498,9 +477,7 @@ async def before_send(event): callback_finished.set() return event - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: client = AsyncPosthog("test-key", before_send=before_send, flush_at=1) client.capture("event", distinct_id="user-1") await asyncio.wait_for(callback_finished.wait(), timeout=1) @@ -511,7 +488,7 @@ async def wait_until_closed(): await asyncio.wait_for(wait_until_closed(), timeout=1) - batch_post.assert_awaited_once() + send_batch.assert_awaited_once() assert client.capture("after shutdown", distinct_id="user-1") is None @@ -526,10 +503,10 @@ async def before_send(event): await allow_callback.wait() return event - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send, flush_at=1) client.capture("event", distinct_id="user-1") await callback_started.wait() @@ -563,7 +540,7 @@ async def test_shutdown_waits_for_immediate_operation_not_its_long_lived_caller( allow_upload = asyncio.Event() shutdown_task = None - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() @@ -573,7 +550,7 @@ async def capture_then_await_shutdown(client): assert shutdown_task is not None await shutdown_task - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") caller = asyncio.create_task(capture_then_await_shutdown(client)) await upload_started.wait() @@ -587,11 +564,11 @@ async def test_shutdown_waits_for_in_flight_immediate_capture(): upload_started = asyncio.Event() allow_upload = asyncio.Event() - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") capture = asyncio.create_task( client.capture_immediate("event", distinct_id="user-1") @@ -615,16 +592,17 @@ async def test_reuses_and_closes_instance_owned_http_client(): "posthog.async_client._build_client", return_value=http_client ) as build, mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post, + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value={"flags": {}}), + ) as flags, ): client = AsyncPosthog("test-key") - await client.capture_immediate("first", distinct_id="user-1") - await client.capture_immediate("second", distinct_id="user-1") + await client._get_flags_decision("user-1") + await client._get_flags_decision("user-2") await client.shutdown() build.assert_called_once_with(client.host) - assert [call.kwargs["client"] for call in batch_post.await_args_list] == [ + assert [call.kwargs["client"] for call in flags.await_args_list] == [ http_client, http_client, ] @@ -636,13 +614,11 @@ def test_capture_before_loop_starts_is_flushed_when_loop_runs(): client = AsyncPosthog("test-key", flush_interval=30) assert client.capture("event", distinct_id="user-1") is not None - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) async def flush_and_close(): - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=batch_post - ): + with patch_async_capture_send(side_effect=send_batch): await client.shutdown() asyncio.run(flush_and_close()) @@ -699,7 +675,7 @@ async def test_capture_exception_uses_context_code_variable_settings(): @pytest.mark.asyncio async def test_queued_payload_is_not_written_to_debug_logs(caplog): caplog.set_level(logging.DEBUG, logger="posthog") - with mock.patch("posthog._async_consumer.async_batch_post", new=mock.AsyncMock()): + with patch_async_capture_send(new=mock.AsyncMock()): async with AsyncPosthog("test-key", flush_interval=30) as client: client.capture( "event", @@ -720,8 +696,7 @@ def on_error(error, batch): nonlocal callback_thread callback_thread = threading.get_ident() - with mock.patch( - "posthog._async_consumer.async_batch_post", + with patch_async_capture_send( side_effect=APIError(400, "failed"), ): client = AsyncPosthog("test-key", on_error=on_error, max_retries=0) @@ -732,15 +707,41 @@ def on_error(error, batch): assert callback_thread != threading.get_ident() +@pytest.mark.asyncio +@pytest.mark.parametrize("immediate", [False, True]) +async def test_capture_failure_inside_on_error_logs_instead_of_recursing( + caplog, immediate +): + calls = [] + client: AsyncPosthog + + async def on_error(error, batch): + calls.append(batch[0]["event"]) + await client.capture_immediate("from_callback", distinct_id="user-1") + + with patch_async_capture_send(side_effect=APIError(503, "unavailable")): + client = AsyncPosthog("test-key", on_error=on_error, flush_at=1, max_retries=0) + if immediate: + await client.capture_immediate("event", distinct_id="user-1") + else: + client.capture("event", distinct_id="user-1") + await client.flush(timeout_seconds=1) + await client.shutdown() + + assert calls == ["event"] + assert ( + "1 event(s) not persisted by /i/v1/analytics/events: APIError (status=503)" + in caplog.text + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("immediate", [False, True]) async def test_failed_capture_does_not_log_server_response_detail(caplog, immediate): caplog.set_level(logging.DEBUG, logger="posthog") server_error = APIError(400, "password=server-secret") - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=server_error - ): + with patch_async_capture_send(side_effect=server_error): client = AsyncPosthog("test-key", flush_at=1, max_retries=0) if immediate: await client.capture_immediate("event", distinct_id="user-1") @@ -759,11 +760,11 @@ async def test_flush_timeout_reports_unfinished_items(caplog): upload_started = asyncio.Event() allow_upload = asyncio.Event() - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_at=1) client.capture("event", distinct_id="user-1") await upload_started.wait() diff --git a/posthog/test/test_async_consumer.py b/posthog/test/test_async_consumer.py index 9bb3f682f..4290a9419 100644 --- a/posthog/test/test_async_consumer.py +++ b/posthog/test/test_async_consumer.py @@ -1,17 +1,12 @@ from __future__ import annotations import asyncio -import json from unittest import mock -import httpx import pytest -from freezegun import freeze_time from posthog._async_consumer import _AsyncConsumer from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode -from posthog.request import APIError def make_consumer(*, retries: int) -> _AsyncConsumer: @@ -23,137 +18,13 @@ def make_consumer(*, retries: int) -> _AsyncConsumer: process_event=mock.AsyncMock(side_effect=lambda event: event), flush_at=100, flush_interval=1, - gzip=False, retries=retries, timeout=3, historical_migration=False, - capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, - http_client=mock.Mock(), ) -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("failures", "retry_after", "expected_delays"), - [ - (1, None, [1]), - (2, None, [1, 2]), - (2, 5, [5, 5]), - ], -) -async def test_request_retries_transient_failures_until_success( - failures, retry_after, expected_delays -): - error = APIError(503, "temporary", retry_after=retry_after) - consumer = make_consumer(retries=failures) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=[error] * failures + [None]), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - ): - await consumer.request([{"event": "test"}]) - - assert batch_post.await_count == failures + 1 - assert [call.args[0] for call in sleep.await_args_list] == expected_delays - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("retry_after", "expected_delay"), - [ - ("Tue, 08 Sep 2026 00:00:10 GMT", 10), - ("Tue, 08 Sep 2026 00:01:00 GMT", 30), - ("Mon, 07 Sep 2026 23:59:59 GMT", 1), - ("5", 5), - ("0", 1), - ("-1", 1), - ("invalid", 1), - (None, 1), - ], -) -async def test_request_honors_retry_after_from_http_response( - retry_after, expected_delay -): - headers = {"Retry-After": retry_after} if retry_after is not None else {} - responses = [ - httpx.Response(503, headers=headers, json={"detail": "temporary"}), - httpx.Response(200, json={"ok": True}), - ] - requests = [] - - def handle_request(request): - requests.append(request) - return responses.pop(0) - - consumer = make_consumer(retries=1) - batch = [{"event": "test", "distinct_id": "test-user"}] - async with httpx.AsyncClient( - base_url="https://example.com", transport=httpx.MockTransport(handle_request) - ) as client: - consumer.http_client = client - with ( - freeze_time("2026-09-08 00:00:00", real_asyncio=True), - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - ): - await consumer.request(batch) - - sleep.assert_awaited_once_with(expected_delay) - assert len(requests) == 2 - assert [json.loads(request.content)["batch"] for request in requests] == [ - batch, - batch, - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status", [400, 401, 413]) -async def test_request_does_not_retry_terminal_client_errors(status): - consumer = make_consumer(retries=3) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=APIError(status, "terminal")), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - pytest.raises(APIError), - ): - await consumer.request([{"event": "test"}]) - - batch_post.assert_awaited_once() - sleep.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_request_stops_after_configured_retry_limit(): - consumer = make_consumer(retries=2) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=APIError(503, "temporary")), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - pytest.raises(APIError), - ): - await consumer.request([{"event": "test"}]) - - assert batch_post.await_count == 3 - assert [call.args[0] for call in sleep.await_args_list] == [1, 2] - - @pytest.mark.asyncio @pytest.mark.parametrize("run_worker", [False, True], ids=["wait", "worker"]) async def test_get_or_flush_cancels_waiters_on_cancellation(run_worker): diff --git a/posthog/test/test_async_feature_flags.py b/posthog/test/test_async_feature_flags.py index 75c8b207e..da35bf17d 100644 --- a/posthog/test/test_async_feature_flags.py +++ b/posthog/test/test_async_feature_flags.py @@ -11,6 +11,7 @@ from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES from posthog.contexts import new_context, set_context_device_id from posthog.request import APIError +from posthog.test.capture_helpers import patch_async_capture_send def flags_response(*, minimal=False): @@ -138,15 +139,15 @@ async def test_empty_flag_keys_skips_remote_request(): async def test_snapshot_access_captures_one_flag_called_event_and_attaches_flags(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch( "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=flags_response()), ), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("project-key", flush_interval=30) snapshot = await client.evaluate_flags("user-1") @@ -171,15 +172,15 @@ async def batch_post(*args, **kwargs): async def test_minimal_flag_called_event_uses_strict_property_allowlist(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch( "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=flags_response(minimal=True)), ), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("project-key", flush_interval=30) snapshot = await client.evaluate_flags("user-1", flag_keys=["beta"]) diff --git a/posthog/test/test_async_request.py b/posthog/test/test_async_request.py index d95da855a..f269db258 100644 --- a/posthog/test/test_async_request.py +++ b/posthog/test/test_async_request.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import json import logging import subprocess @@ -13,7 +12,6 @@ from posthog._async_request import ( _build_client, _process_response, - async_batch_post, async_flags, async_remote_config, ) @@ -84,148 +82,6 @@ def test_build_client_scopes_requests_to_host_without_following_redirects(): ) -@pytest.mark.asyncio -async def test_async_batch_post_uses_configured_host_and_sanitized_logs(caplog): - caplog.set_level(logging.DEBUG, logger="posthog") - client = FakeAsyncClient() - - await async_batch_post( - "test-secret-key", - "https://example.com", - batch=[{"properties": {"password": "super-secret"}}], - path="/batch/", - client=client, - ) - - assert client.calls[0][1] == ("https://example.com/batch/",) - assert "super-secret" not in caplog.text - assert "test-secret-key" not in caplog.text - assert "https://example.com" not in caplog.text - - -@pytest.mark.asyncio -async def test_async_batch_post_follows_same_origin_temporary_redirect(): - client = FakeAsyncClient( - [ - FakeResponse(307, headers={"Location": "/redirected-batch/"}), - FakeResponse(200), - ] - ) - - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - client=client, - ) - - assert [call[1] for call in client.calls] == [ - ("https://example.com/batch/",), - ("https://example.com/redirected-batch/",), - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status", [307, 308]) -@pytest.mark.parametrize( - "host", ["https://example.com/proxy", "https://example.com/proxy/"] -) -@pytest.mark.parametrize( - ("location", "redirected_path"), - [ - ("/proxy/redirected-batch/", "/proxy/redirected-batch/"), - ("https://example.com/proxy/redirected-batch/", "/proxy/redirected-batch/"), - ("/redirected-batch/", "/redirected-batch/"), - ("../redirected-batch/", "/proxy/redirected-batch/"), - ("?accepted=1", "/proxy/batch/?accepted=1"), - ], -) -async def test_async_batch_post_redirects_with_host_path_prefix( - status, host, location, redirected_path -): - requests = [] - responses = [ - httpx.Response(status, headers={"Location": location}), - httpx.Response(status, headers={"Location": "?attempt=2"}), - httpx.Response(200), - ] - - def handle_request(request): - requests.append(request) - return responses.pop(0) - - batch = [{"event": "test", "distinct_id": "test-user"}] - async with httpx.AsyncClient( - base_url=host, transport=httpx.MockTransport(handle_request) - ) as client: - await async_batch_post( - "test-key", host, batch=batch, path="/batch/", client=client - ) - - assert [str(request.url) for request in requests] == [ - "https://example.com/proxy/batch/", - f"https://example.com{redirected_path}", - f"https://example.com{redirected_path.split('?')[0]}?attempt=2", - ] - assert all(request.method == "POST" for request in requests) - assert all(request.content == requests[0].content for request in requests) - assert json.loads(requests[0].content)["batch"] == batch - - -@pytest.mark.asyncio -async def test_async_batch_post_rejects_cross_origin_temporary_redirect(): - client = FakeAsyncClient( - FakeResponse( - 307, - headers={"Location": "https://attacker.example/redirected-batch/"}, - ) - ) - - with pytest.raises(APIError): - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - client=client, - ) - - assert len(client.calls) == 1 - - -@pytest.mark.asyncio -async def test_async_batch_post_serializes_off_event_loop(): - client = FakeAsyncClient() - real_to_thread = asyncio.to_thread - - with mock.patch( - "posthog._async_request.asyncio.to_thread", wraps=real_to_thread - ) as to_thread: - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - gzip=True, - client=client, - ) - - to_thread.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_async_batch_post_rejects_absolute_request_path(): - with pytest.raises(ValueError, match="relative"): - await async_batch_post( - "test-key", - "https://example.com", - batch=[], - path="https://attacker.example/batch/", - client=FakeAsyncClient(), - ) - - @pytest.mark.asyncio async def test_async_flags_sends_v2_request_payload(): client = FakeAsyncClient(FakeResponse(200, {"flags": {}})) diff --git a/posthog/test/test_before_send.py b/posthog/test/test_before_send.py index ee048290e..3d7441d97 100644 --- a/posthog/test/test_before_send.py +++ b/posthog/test/test_before_send.py @@ -6,14 +6,19 @@ from posthog.client import Client from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import ( + patch_capture_send, + sent_batch, + sent_events, +) class TestClient(unittest.TestCase): @classmethod def setUpClass(cls): # This ensures no real HTTP POST requests are made - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() @@ -42,7 +47,7 @@ def my_before_send(event): event["properties"]["processed_by_before_send"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -57,7 +62,7 @@ def my_before_send(event): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual( @@ -75,7 +80,7 @@ def replace_uuid(event): event["uuid"] = replacement_uuid return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -87,7 +92,7 @@ def replace_uuid(event): self.assertEqual(msg_uuid, replacement_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["uuid"], replacement_uuid) @@ -98,7 +103,7 @@ def remove_uuid(event): del event["uuid"] return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -110,7 +115,7 @@ def remove_uuid(event): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["uuid"], msg_uuid) @@ -122,7 +127,7 @@ def drop_test_events(event): return None return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -140,7 +145,7 @@ def drop_test_events(event): # Check the enqueued message mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["event"], "keep_me") @@ -152,7 +157,7 @@ def buggy_before_send(event): event["uuid"] = "invalid" raise ValueError("Oops!") - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -187,7 +192,7 @@ def buggy_before_send(event): ) try: with ( - mock.patch("posthog.consumer.batch_post") as mock_post, + patch_capture_send("consumer") as mock_post, mock.patch.object( client._analytics_lane, "enqueue", @@ -210,7 +215,7 @@ def add_unsupported_value(event): event["properties"]["marker"] = marker return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, before_send=add_unsupported_value, @@ -218,12 +223,12 @@ def add_unsupported_value(event): ) self.assertIsNotNone(client.capture("recleaned", distinct_id="user1")) - sent_event = mock_post.call_args.kwargs["batch"][0] + sent_event = sent_batch(mock_post)[0] self.assertIsNone(sent_event["properties"]["marker"]) def test_before_send_callback_non_dict_output_drops_event(self): with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch("posthog.client.Client.log.exception") as mock_log, ): client = Client( @@ -251,7 +256,7 @@ def add_invalid_mapping_key(event): flush_interval=0.01, ) with ( - mock.patch("posthog.consumer.batch_post") as mock_post, + patch_capture_send("consumer") as mock_post, self.assertLogs("posthog", level="ERROR") as logs, ): client.capture("malformed", distinct_id="user1") @@ -259,8 +264,7 @@ def add_invalid_mapping_key(event): client.shutdown() mock_post.assert_called_once() - sent_batch = mock_post.call_args.kwargs["batch"] - self.assertEqual([event["event"] for event in sent_batch], ["valid"]) + self.assertEqual([event["event"] for event in sent_batch(mock_post)], ["valid"]) self.assertEqual(client.queue.unfinished_tasks, 0) self.assertTrue(all(not consumer.is_alive() for consumer in client.consumers)) self.assertNotIn("private-key", "\n".join(logs.output)) @@ -275,7 +279,7 @@ def add_marker(event): event["properties"]["marked"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -293,14 +297,12 @@ def add_marker(event): # Check all events were marked self.assertEqual(mock_post.call_count, 2) - for call in mock_post.call_args_list: - batch_data = call[1]["batch"] - enqueued_msg = batch_data[0] - self.assertTrue(enqueued_msg["properties"]["marked"]) + for event in sent_events(mock_post): + self.assertTrue(event["properties"]["marked"]) def test_before_send_callback_disabled_when_none(self): """Test that client works normally when before_send is None.""" - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -312,7 +314,7 @@ def test_before_send_callback_disabled_when_none(self): # Check the event was sent normally mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["event"], "normal_event") @@ -336,7 +338,7 @@ def scrub_pii(event): return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -357,7 +359,7 @@ def scrub_pii(event): # Check the enqueued message was scrubbed mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com") @@ -379,7 +381,7 @@ def my_before_send(event): event["properties"]["module_level_before_send"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: posthog.api_key = FAKE_TEST_API_KEY posthog.before_send = my_before_send posthog.sync_mode = True @@ -390,7 +392,7 @@ def my_before_send(event): self.assertIs(posthog.default_client.before_send, my_before_send) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertTrue(enqueued_msg["properties"]["module_level_before_send"]) @@ -399,7 +401,7 @@ def my_before_send(event): event["properties"]["updated_after_init"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: posthog.api_key = FAKE_TEST_API_KEY posthog.sync_mode = True @@ -413,8 +415,8 @@ def my_before_send(event): self.assertIs(posthog.default_client.before_send, my_before_send) self.assertEqual(mock_post.call_count, 2) - first_batch = mock_post.call_args_list[0][1]["batch"] - second_batch = mock_post.call_args_list[1][1]["batch"] + first_batch = sent_batch(mock_post, 0) + second_batch = sent_batch(mock_post, 1) self.assertNotIn("updated_after_init", first_batch[0]["properties"]) self.assertTrue(second_batch[0]["properties"]["updated_after_init"]) diff --git a/posthog/test/test_capture_compression.py b/posthog/test/test_capture_compression.py index a4ce0154f..cb9376572 100644 --- a/posthog/test/test_capture_compression.py +++ b/posthog/test/test_capture_compression.py @@ -22,15 +22,9 @@ def setUp(self) -> None: self.addCleanup(patcher.stop) os.environ.pop(CAPTURE_COMPRESSION_ENV_VAR, None) - def test_defaults_to_none_with_no_kwarg_env_or_gzip(self) -> None: + def test_defaults_to_none_with_no_kwarg_or_env(self) -> None: self.assertIs(_resolve_capture_compression(None), CaptureCompression.NONE) - def test_gzip_fallback_used_when_nothing_else_set(self) -> None: - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, - ) - @parameterized.expand( [ ("enum_gzip", CaptureCompression.GZIP, CaptureCompression.GZIP), @@ -48,12 +42,9 @@ def test_gzip_fallback_used_when_nothing_else_set(self) -> None: def test_explicit_kwarg_takes_precedence_and_coerces( self, _name, kwarg, expected ) -> None: - # Env names a different value and gzip_fallback is on, so each row proves - # the explicit kwarg wins over both lower-precedence sources. + # Env names a different value, so each row proves the explicit kwarg wins. with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): - self.assertIs( - _resolve_capture_compression(kwarg, gzip_fallback=True), expected - ) + self.assertIs(_resolve_capture_compression(kwarg), expected) def test_invalid_kwarg_raises_even_with_valid_env(self) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "gzip"}): @@ -80,28 +71,16 @@ def test_env_var_resolution(self, _name, env_value, expected) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: env_value}): self.assertIs(_resolve_capture_compression(None), expected) - def test_env_var_takes_precedence_over_gzip_fallback(self) -> None: - with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.DEFLATE, - ) - @parameterized.expand([("empty", ""), ("whitespace", " ")]) - def test_blank_env_var_falls_through_to_fallback(self, _name, env_value) -> None: + def test_blank_env_var_falls_through_to_none(self, _name, env_value) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: env_value}): self.assertIs(_resolve_capture_compression(None), CaptureCompression.NONE) - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, - ) - def test_unrecognized_env_var_warns_and_uses_fallback(self) -> None: + def test_unrecognized_env_var_warns_and_uses_none(self) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "bogus"}): with capture_message_only_logs() as stream: self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, + _resolve_capture_compression(None), CaptureCompression.NONE ) self.assertIn("bogus", stream.getvalue()) @@ -112,13 +91,12 @@ def test_explicit_zstd_without_package_raises(self, _name, kwarg) -> None: _resolve_capture_compression(kwarg) self.assertIn("posthog[zstd]", str(ctx.exception)) - def test_env_zstd_without_package_warns_and_uses_fallback(self) -> None: + def test_env_zstd_without_package_warns_and_uses_none(self) -> None: with mock.patch("posthog.capture_compression._zstandard", None): with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "zstd"}): with capture_message_only_logs() as stream: self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, + _resolve_capture_compression(None), CaptureCompression.NONE ) self.assertIn("posthog[zstd]", stream.getvalue()) @@ -134,10 +112,6 @@ def test_client_defaults_to_none(self) -> None: client = Client(TEST_API_KEY, sync_mode=True) self.assertIs(client.capture_compression, CaptureCompression.NONE) - def test_client_gzip_flag_falls_back_to_gzip(self) -> None: - client = Client(TEST_API_KEY, sync_mode=True, gzip=True) - self.assertIs(client.capture_compression, CaptureCompression.GZIP) - @parameterized.expand( [ ("enum_deflate", CaptureCompression.DEFLATE, CaptureCompression.DEFLATE), @@ -145,11 +119,8 @@ def test_client_gzip_flag_falls_back_to_gzip(self) -> None: ("str_none", "none", CaptureCompression.NONE), ] ) - def test_client_kwarg_overrides_gzip_flag(self, _name, kwarg, expected) -> None: - # Even with the legacy gzip flag on, the explicit kwarg wins. - client = Client( - TEST_API_KEY, sync_mode=True, gzip=True, capture_compression=kwarg - ) + def test_client_kwarg_sets_compression(self, _name, kwarg, expected) -> None: + client = Client(TEST_API_KEY, sync_mode=True, capture_compression=kwarg) self.assertIs(client.capture_compression, expected) def test_client_propagates_to_consumers(self) -> None: diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py new file mode 100644 index 000000000..e3b48195c --- /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_mode.py b/posthog/test/test_capture_mode.py deleted file mode 100644 index 473e1a886..000000000 --- a/posthog/test/test_capture_mode.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import unittest -from unittest import mock - -from parameterized import parameterized - -from posthog.capture_mode import ( - CAPTURE_MODE_ENV_VAR, - CaptureMode, - _resolve_capture_mode, -) -from posthog.client import Client -from posthog.consumer import Consumer -from posthog.test.logging_helpers import capture_message_only_logs -from posthog.test.test_utils import TEST_API_KEY - - -class TestResolveCaptureMode(unittest.TestCase): - def test_defaults_to_v0_with_no_kwarg_and_no_env(self) -> None: - with mock.patch.dict(os.environ, {}, clear=False): - os.environ.pop(CAPTURE_MODE_ENV_VAR, None) - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - - @parameterized.expand( - [ - # (name, kwarg, expected, opposite_env): the env always names the - # mode the kwarg must override, so every row proves the kwarg wins. - ("enum_v0", CaptureMode.V0, CaptureMode.V0, "v1"), - ("enum_v1", CaptureMode.V1, CaptureMode.V1, "v0"), - ("str_v0", "v0", CaptureMode.V0, "v1"), - ("str_v1", "v1", CaptureMode.V1, "v0"), - ("str_legacy_alias", "legacy", CaptureMode.V0, "v1"), - ("str_analytics_v1_alias", "analytics_v1", CaptureMode.V1, "v0"), - ("str_upper_and_padded", " V1 ", CaptureMode.V1, "v0"), - ] - ) - def test_explicit_kwarg_takes_precedence_and_coerces( - self, _name, kwarg, expected, opposite_env - ) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: opposite_env}): - self.assertIs(_resolve_capture_mode(kwarg), expected) - - def test_invalid_kwarg_raises_even_with_valid_env(self) -> None: - # The kwarg path is consulted before the env, so an invalid kwarg raises - # rather than silently falling back to a valid env value. - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: "v1"}): - with self.assertRaises(ValueError): - _resolve_capture_mode("bogus") - - @parameterized.expand( - [ - ("v0", "v0", CaptureMode.V0), - ("legacy", "legacy", CaptureMode.V0), - ("v1", "v1", CaptureMode.V1), - ("analytics_v1", "analytics_v1", CaptureMode.V1), - ("uppercase", "V1", CaptureMode.V1), - ("padded", " v1 ", CaptureMode.V1), - ] - ) - def test_env_var_resolution(self, _name, env_value, expected) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: env_value}): - self.assertIs(_resolve_capture_mode(None), expected) - - @parameterized.expand([("empty", ""), ("whitespace", " ")]) - def test_blank_env_var_defaults_to_v0(self, _name, env_value) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: env_value}): - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - - def test_unrecognized_env_var_warns_and_defaults_to_v0(self) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: "bogus"}): - with capture_message_only_logs() as stream: - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - self.assertIn("bogus", stream.getvalue()) - - @parameterized.expand([("bad_str", "bogus"), ("wrong_type", 1)]) - def test_invalid_explicit_kwarg_raises(self, _name, value) -> None: - with self.assertRaises(ValueError): - _resolve_capture_mode(value) - - -class TestCaptureModePlumbing(unittest.TestCase): - def test_client_resolves_and_stores_default_v0(self) -> None: - with mock.patch.dict(os.environ, {}, clear=False): - os.environ.pop(CAPTURE_MODE_ENV_VAR, None) - client = Client(TEST_API_KEY, sync_mode=True) - self.assertIs(client.capture_mode, CaptureMode.V0) - - @parameterized.expand( - [ - ("enum_v1", CaptureMode.V1, CaptureMode.V1), - ("str_v1", "v1", CaptureMode.V1), - ("enum_v0", CaptureMode.V0, CaptureMode.V0), - ] - ) - def test_client_kwarg_sets_mode(self, _name, kwarg, expected) -> None: - client = Client(TEST_API_KEY, sync_mode=True, capture_mode=kwarg) - self.assertIs(client.capture_mode, expected) - - def test_client_propagates_mode_to_consumers(self) -> None: - # Async (non-sync) client builds Consumer threads; assert each carries - # the resolved mode. - client = Client(TEST_API_KEY, capture_mode=CaptureMode.V1, send=False, thread=2) - self.assertEqual(len(client.consumers), 2) - for consumer in client.consumers: - self.assertIs(consumer.capture_mode, CaptureMode.V1) - - def test_consumer_defaults_to_v0(self) -> None: - consumer = Consumer(None, TEST_API_KEY) - self.assertIs(consumer.capture_mode, CaptureMode.V0) diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_send.py similarity index 61% rename from posthog/test/test_capture_v1.py rename to posthog/test/test_capture_send.py index f4809d501..1f8c95062 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_send.py @@ -1,30 +1,31 @@ import json import unittest import zlib -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from unittest import mock +from uuid import UUID import zstandard 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, _HEADER_REQUEST_ID, _HEADER_REQUEST_TIMESTAMP, _HEADER_SDK_INFO, _MAX_BACKOFF_SECONDS, - CaptureV1Error, - _build_v1_batch_body, + CaptureError, + CaptureEventResult, + _capture_loss_message, _parse_v1_response, _post_v1, _send_v1_batch, - _to_v1_event, _backoff, - _coerce_bool, - _coerce_str, ) from posthog.request import USER_AGENT @@ -84,9 +85,11 @@ def __call__( timeout=15, sdk_info=USER_AGENT, session=None, + path=_CAPTURE_V1_PATH, ): self.calls.append( { + "path": path, "attempt": attempt, "request_id": request_id, "compression": compression, @@ -127,271 +130,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) @@ -410,6 +148,8 @@ def _post(self, response, **kwargs): def test_url_uses_v1_path_and_trims_host(self) -> None: call = self._post(_results_response({})) self.assertEqual(call["url"], "https://app.posthog.com" + _CAPTURE_V1_PATH) + call = self._post(_results_response({}), path=_CAPTURE_AI_V1_PATH) + self.assertEqual(call["url"], "https://app.posthog.com" + _CAPTURE_AI_V1_PATH) def test_required_headers_present(self) -> None: headers = self._post(_results_response({}))["headers"] @@ -467,7 +207,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)) @@ -522,20 +262,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 @@ -580,18 +320,21 @@ def test_request_id_and_created_at_stable_attempt_increments(self) -> None: ], ) self.assertEqual(stub.calls[0]["request_id"], stub.calls[1]["request_id"]) + self.assertEqual(UUID(stub.calls[0]["request_id"]).version, 7) # created_at is hoisted once, so the envelope timestamp is identical # across retry attempts (only the attempt header increments). self.assertEqual(stub.calls[0]["created_at"], stub.calls[1]["created_at"]) self.assertEqual([c["attempt"] for c in stub.calls], [1, 2]) - def test_compression_forwarded_to_post_v1(self) -> None: + def test_compression_and_path_forwarded_to_post_v1(self) -> None: stub = self._run( [_msg("u-1")], [_results_response({"u-1": "ok"})], compression=CaptureCompression.DEFLATE, + path=_CAPTURE_AI_V1_PATH, ) self.assertEqual(stub.calls[0]["compression"], CaptureCompression.DEFLATE) + self.assertEqual(stub.calls[0]["path"], _CAPTURE_AI_V1_PATH) def test_drop_on_2xx_surfaces_via_error(self) -> None: # A server-chosen drop is terminal: even on an all-ok-otherwise 2xx with @@ -644,18 +387,56 @@ def test_retry_exhausted_raises_with_uuids(self) -> None: def test_retry_exhausted_carries_earlier_drops(self) -> None: # A drop seen on attempt 1 rides along on the retry-exhaustion error. - batch = [_msg("u-drop"), _msg("u-retry")] + batch = [_msg("u-ok"), _msg("u-drop"), _msg("u-retry")] stub, exc = self._run_expecting_error( batch, [ - _results_response({"u-drop": ("drop", "billing"), "u-retry": "retry"}), - _results_response({"u-retry": "retry"}), + _results_response( + { + "u-ok": "ok", + "u-drop": ("drop", "billing"), + "u-retry": "retry", + } + ), + _results_response({"u-retry": ("retry", "not_persisted")}), ], max_retries=1, ) self.assertEqual(len(stub.calls), 2) + self.assertEqual(exc.endpoint, _CAPTURE_V1_PATH) self.assertEqual(exc.retry_exhausted, ["u-retry"]) self.assertEqual(exc.drops, [("u-drop", "billing")]) + self.assertEqual( + exc.event_results, + { + "u-ok": CaptureEventResult("ok"), + "u-drop": CaptureEventResult("drop", "billing"), + "u-retry": CaptureEventResult("retry", "not_persisted"), + }, + ) + self.assertEqual(exc.verdict_summary(), "drop/billing=1, retry/not_persisted=1") + + @parameterized.expand( + [ + ("uppercase", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"), + ("no_hyphens", "aaaaaaaaaaaa4aaa8aaaaaaaaaaaaaaa"), + ("braced", "{aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa}"), + ("urn", "urn:uuid:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ] + ) + def test_results_match_non_canonical_uuid(self, _name, sent_uuid) -> None: + # Capture parses any of these forms but keys results canonically, so a + # verdict for a uuid `before_send` rewrote must still reach its event. + canonical = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + stub, exc = self._run_expecting_error( + [_msg(sent_uuid)], + [ + _results_response({canonical: "retry"}), + _results_response({canonical: ("drop", "billing")}), + ], + ) + self.assertEqual(len(stub.calls), 2) + self.assertEqual(exc.drops, [(canonical, "billing")]) def test_malformed_2xx_is_terminal(self) -> None: stub, exc = self._run_expecting_error( @@ -699,19 +480,27 @@ def test_transport_error_then_success(self) -> None: ) self.assertEqual(len(stub.calls), 2) - def test_transport_error_exhausted_reraises_original(self) -> None: + def test_transport_error_exhausted_raises_capture_error(self) -> None: stub = _PostV1Stub([ConnectionError("boom"), ConnectionError("boom")]) - with mock.patch("posthog.capture_v1._post_v1", stub): - with self.assertRaises(ConnectionError): + with mock.patch("posthog.capture_send._post_v1", stub): + with self.assertRaises(CaptureError) as ctx: _send_v1_batch( - "phc_key", "https://app.posthog.com", [_msg("u-1")], max_retries=1 + "phc_key", + "https://app.posthog.com", + [_msg("u-1")], + max_retries=1, + path=_CAPTURE_AI_V1_PATH, ) self.assertEqual(len(stub.calls), 2) + self.assertEqual(ctx.exception.status, 0) + self.assertEqual(ctx.exception.endpoint, _CAPTURE_AI_V1_PATH) + self.assertEqual(ctx.exception.attempts, 2) + self.assertIsInstance(ctx.exception.__cause__, ConnectionError) 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 ) @@ -722,20 +511,69 @@ def test_negative_max_retries_still_attempts_delivery_once(self) -> None: def test_small_retry_after_does_not_shorten_backoff(self) -> None: # A Retry-After smaller than the configured backoff must not make the # client retry earlier than its own schedule (Retry-After is a minimum). - # attempt_index=1 -> configured backoff 2s; Retry-After 0.5s is ignored. + # attempt_index=1 -> configured backoff 0.2s; Retry-After 0.1s is ignored. stub = self._run( [_msg("u-1")], [ _results_response({"u-1": "retry"}), - _results_response({"u-1": "retry"}, headers={"Retry-After": "0.5"}), + _results_response({"u-1": "retry"}, headers={"Retry-After": "0.1"}), _results_response({"u-1": "ok"}), ], max_retries=3, ) self.assertEqual(len(stub.calls), 3) - # First backoff (attempt_index 0) waits 1s; second (attempt_index 1) - # keeps the 2s configured backoff rather than the smaller 0.5s header. - self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [1, 2]) + # First backoff (attempt_index 0) waits 0.1s; second (attempt_index 1) + # keeps the 0.2s configured backoff rather than the smaller 0.1s header. + self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [0.1, 0.2]) + + +class TestCaptureLossMessage(unittest.TestCase): + @parameterized.expand( + [ + ( + "partial_2xx", + CaptureError( + 200, + "2 event(s) not delivered", + endpoint=_CAPTURE_V1_PATH, + drops=[("u-drop", "billing")], + retry_exhausted=["u-retry"], + event_results={ + "u-ok": CaptureEventResult("ok"), + "u-drop": CaptureEventResult("drop", "billing"), + "u-retry": CaptureEventResult("retry", "not_persisted"), + }, + ), + 3, + "2 event(s) not persisted by /i/v1/analytics/events: 1 dropped, " + "1 out of retries (drop/billing=1, retry/not_persisted=1)", + ), + ( + "request_failure_after_partial_success", + CaptureError( + 503, + "unavailable", + endpoint=_CAPTURE_AI_V1_PATH, + event_results={ + "u-ok": CaptureEventResult("warning"), + "u-retry": CaptureEventResult("retry"), + }, + ), + 2, + "1 event(s) not persisted by /i/v1/ai/events: CaptureError (status=503)", + ), + ( + "other_exception_uses_caller_endpoint", + ValueError("bad payload"), + 4, + "4 event(s) not persisted by /i/v1/analytics/events: ValueError", + ), + ] + ) + def test_message(self, _name, error, batch_size, expected) -> None: + self.assertEqual( + _capture_loss_message(error, batch_size, _CAPTURE_V1_PATH), expected + ) class TestBackoff(unittest.TestCase): @@ -744,19 +582,20 @@ class TestBackoff(unittest.TestCase): @parameterized.expand( [ # (attempt_index, retry_after, expected sleep seconds) - ("first_no_header", 0, None, 1), - ("second_no_header", 1, None, 2), + ("first_no_header", 0, None, 0.1), + ("second_no_header", 1, None, 0.2), ("exp_capped_at_30", 10, None, 30), - ("zero_header_uses_backoff", 0, 0, 1), + ("zero_header_uses_backoff", 0, 0, 0.1), ("larger_header_wins", 0, 5.0, 5.0), - ("smaller_header_ignored", 3, 2.0, 8), # configured 8 > 2.0 - ("equal_header_and_backoff", 0, 1.0, 1), + ("smaller_header_ignored", 3, 0.5, 0.8), # configured 0.8 > 0.5 + ("equal_header_and_backoff", 1, 0.2, 0.2), ("header_at_ceiling", 0, 30.0, 30), ("header_above_ceiling_clamped", 0, 120.0, _MAX_BACKOFF_SECONDS), ("absurd_header_clamped", 0, 10**9, _MAX_BACKOFF_SECONDS), ] ) 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) + sleep.assert_called_once() + self.assertAlmostEqual(sleep.call_args.args[0], expected) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 531bbf1c9..37d862b5c 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -18,6 +18,7 @@ import pytest from posthog.capture_compression import CaptureCompression +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 @@ -26,6 +27,7 @@ from posthog.types import FeatureFlag, FeatureFlagResult, LegacyFlagMetadata from posthog.version import VERSION from posthog.contexts import tag +from posthog.test.capture_helpers import patch_capture_send, sent_batch # Legacy single-flag behavior remains covered here; warning emission itself is @@ -53,8 +55,8 @@ class TestClient(unittest.TestCase): @classmethod def setUpClass(cls): # This ensures no real HTTP POST requests are made - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() @@ -292,7 +294,7 @@ def test_empty_flush(self): self.client.flush() def test_empty_flush_does_not_drain_a_later_event(self): - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_at=100, flush_interval=0.5) client.flush() @@ -321,7 +323,7 @@ def test_flush_timeout_returns_when_queue_does_not_drain(self): def test_flush_does_not_wait_for_flush_interval(self): # flush() must attempt delivery now rather than letting the consumer sit # on a below-flush_at batch until flush_interval elapses. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_interval=30) client.capture("event", distinct_id="distinct_id") @@ -335,7 +337,7 @@ def test_flush_does_not_wait_for_flush_interval(self): def test_flush_delivers_when_flush_interval_exceeds_the_flush_timeout(self): # Waiting out flush_interval meant a flush_interval longer than the # flush timeout delivered nothing at all. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_interval=30) client.capture("event", distinct_id="distinct_id") @@ -346,7 +348,7 @@ def test_flush_delivers_when_flush_interval_exceeds_the_flush_timeout(self): def test_flush_keeps_batches_whole(self): # Draining early must not turn a full queue into one request per event. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_at=10, flush_interval=30) for _ in range(30): client.capture("event", distinct_id="distinct_id") @@ -355,7 +357,8 @@ def test_flush_keeps_batches_whole(self): self.assertTrue(client.queue.empty()) batch_sizes = [ - len(call.kwargs["batch"]) for call in mock_post.call_args_list + len(sent_batch(mock_post, index)) + for index in range(mock_post.call_count) ] self.assertEqual(sum(batch_sizes), 30) self.assertLessEqual(len(batch_sizes), 5) @@ -378,7 +381,7 @@ def test_flush_logs_and_returns_on_unexpected_error(self): client.queue.task_done() def test_basic_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture("python test event", distinct_id="distinct_id") self.assertIsNotNone(msg_uuid) @@ -386,7 +389,7 @@ def test_basic_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -403,7 +406,7 @@ def test_basic_capture(self): assert msg["properties"]["$os_version"] == mock.ANY def test_capture_omits_is_server_when_disabled(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -413,12 +416,12 @@ def test_capture_omits_is_server_when_disabled(self): client.capture("python test event", distinct_id="distinct_id") self.assertFalse(self.failed) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertNotIn("$is_server", msg["properties"]) def test_is_server_not_overridden_by_super_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -428,11 +431,11 @@ def test_is_server_not_overridden_by_super_properties(self): client.capture("python test event", distinct_id="distinct_id") self.assertFalse(self.failed) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["properties"]["$is_server"], True) def test_basic_capture_with_uuid(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) uuid = str(uuid4()) msg_uuid = client.capture( @@ -443,7 +446,7 @@ def test_basic_capture_with_uuid(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -454,7 +457,7 @@ def test_basic_capture_with_uuid(self): self.assertEqual(msg["properties"]["$lib_version"], VERSION) def test_basic_capture_with_uuid_object(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) uuid = UUID("00000000-0000-4000-8000-000000000002") msg_uuid = client.capture( @@ -464,9 +467,27 @@ def test_basic_capture_with_uuid_object(self): self.assertFalse(self.failed) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["uuid"], str(uuid)) + @parameterized.expand( + [ + ("uppercase", "0190A3B4-C5D6-7E8F-9A0B-1C2D3E4F5A6B"), + ("no hyphens", "0190a3b4c5d67e8f9a0b1c2d3e4f5a6b"), + ("braced", "{0190a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b}"), + ] + ) + def test_capture_sends_and_returns_canonical_uuid(self, _name, supplied): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + msg_uuid = client.capture( + "python test event", distinct_id="distinct_id", uuid=supplied + ) + + canonical = "0190a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b" + self.assertEqual(msg_uuid, canonical) + self.assertEqual(sent_batch(mock_post)[0]["uuid"], canonical) + @parameterized.expand( [ ("empty string", ""), @@ -478,7 +499,7 @@ def test_basic_capture_with_uuid_object(self): def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid( self, _name, invalid_uuid ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="ERROR") as logs: msg_uuid = client.capture( @@ -486,9 +507,9 @@ def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid( ) self.assertIsNotNone(msg_uuid) - UUID(msg_uuid) + self.assertEqual(UUID(msg_uuid).version, 7) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["uuid"], msg_uuid) self.assertNotEqual(msg["uuid"], str(invalid_uuid)) self.assertTrue( @@ -509,7 +530,7 @@ def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid( ] ) def test_capture_with_invalid_uuid_falls_back_in_debug(self, _name, invalid_uuid): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, debug=True, sync_mode=True) with self.assertLogs("posthog", level="ERROR"): msg_uuid = client.capture( @@ -521,7 +542,7 @@ def test_capture_with_invalid_uuid_falls_back_in_debug(self, _name, invalid_uuid mock_post.assert_called_once() def test_basic_capture_with_project_api_key(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -534,7 +555,7 @@ def test_basic_capture_with_project_api_key(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -544,7 +565,7 @@ def test_basic_capture_with_project_api_key(self): self.assertEqual(msg["properties"]["$lib_version"], VERSION) def test_basic_super_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, super_properties={"source": "repo-name"}, @@ -556,7 +577,7 @@ def test_basic_super_properties(self): # Check the enqueued message mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -611,13 +632,13 @@ def test_capture_exception_uses_current_otel_span_context( ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client(FAKE_TEST_API_KEY, sync_mode=True) client.capture_exception(Exception("test exception"), properties=properties) - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] if expected_trace_id is None: self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -685,7 +706,7 @@ def test_capture_uses_current_otel_span_context_when_enabled( ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client( @@ -694,7 +715,7 @@ def test_capture_uses_current_otel_span_context_when_enabled( capture = getattr(client, entrypoint) capture("$ai_event", distinct_id="distinct_id", properties=properties) - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] if expected_trace_id is None: self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -712,14 +733,14 @@ def test_capture_does_not_attach_otel_span_context_by_default(self, entrypoint): ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client(FAKE_TEST_API_KEY, sync_mode=True) capture = getattr(client, entrypoint) capture("$ai_event", distinct_id="distinct_id") - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -856,7 +877,7 @@ def test_capture_exception_logs_when_enabled(self): def test_basic_capture_with_feature_flags(self, patch_flags): patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -871,7 +892,7 @@ def test_basic_capture_with_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -979,7 +1000,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -996,7 +1017,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1017,7 +1038,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): self.assertEqual(patch_flags.call_count, 0) # test that flags are not evaluated without local evaluation - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1031,7 +1052,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] assert "$feature/beta-feature" not in msg["properties"] @@ -1149,7 +1170,7 @@ def test_dont_override_capture_with_local_flags(self, patch_flags): }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1169,7 +1190,7 @@ def test_dont_override_capture_with_local_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1199,7 +1220,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1214,7 +1235,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1283,7 +1304,7 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, host="https://app.posthog.com", @@ -1304,7 +1325,7 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1343,7 +1364,7 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them( ): patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1358,7 +1379,7 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1421,7 +1442,7 @@ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_ }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1440,7 +1461,7 @@ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_ # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1503,7 +1524,7 @@ def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_fl }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1522,7 +1543,7 @@ def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_fl # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1549,7 +1570,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_true( self, patch_flags ): """Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation""" - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1591,7 +1612,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_true( # Check the message includes the local flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/local-flag"], True) @@ -1604,7 +1625,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_false( """Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation""" patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1638,7 +1659,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_false( # Check the message includes the remote flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value") @@ -1650,7 +1671,7 @@ def test_capture_with_send_feature_flags_options_default_behavior( """Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation""" patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1676,7 +1697,7 @@ def test_capture_with_send_feature_flags_options_default_behavior( # Check the message includes the flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -1688,7 +1709,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): """Test that capture_exception also supports SendFeatureFlagsOptions""" patch_flags.return_value = {"featureFlags": {"exception-flag": True}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1718,7 +1739,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): # Check the message includes the flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$exception") @@ -1727,7 +1748,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): def test_stringifies_distinct_id(self): # A large number that loses precision in node: # node -e "console.log(157963456373623802 + 1)" > 157963456373623800 - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "python test event", distinct_id=157963456373623802 @@ -1737,13 +1758,13 @@ def test_stringifies_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "157963456373623802") def test_advanced_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "python test event", @@ -1757,7 +1778,7 @@ def test_advanced_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1770,7 +1791,7 @@ def test_advanced_capture(self): self.assertTrue("$groups" not in msg["properties"]) def test_capture_converts_aware_timestamp_to_utc_without_changing_instant(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1780,11 +1801,11 @@ def test_capture_converts_aware_timestamp_to_utc_without_changing_instant(self): ), ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") def test_capture_converts_parseable_timestamp_string_to_utc(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1792,14 +1813,14 @@ def test_capture_converts_parseable_timestamp_string_to_utc(self): timestamp="2014-09-03T05:30:00+05:30", ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @parameterized.expand(["2026-06-27", "not-an-iso-timestamp"]) def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestamp): now = datetime(2026, 6, 27, 12, 30, tzinfo=timezone.utc) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch("posthog.client.datetime", wraps=datetime) as mock_datetime, mock.patch.object(Client.log, "warning") as mock_warning, ): @@ -1812,7 +1833,7 @@ def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestam ) self.assertIsNotNone(result) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2026-06-27T12:30:00+00:00") mock_warning.assert_called_once_with( "Invalid timestamp %r. Falling back to the current UTC time.", timestamp @@ -1822,7 +1843,7 @@ def test_capture_does_not_normalize_datetime_properties(self): property_value = datetime( 2014, 9, 3, 5, 30, tzinfo=timezone(timedelta(hours=5, minutes=30)) ) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1830,11 +1851,11 @@ def test_capture_does_not_normalize_datetime_properties(self): properties={"caller_datetime": property_value}, ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertIs(msg["properties"]["caller_datetime"], property_value) def test_groups_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "test_event", @@ -1846,7 +1867,7 @@ def test_groups_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -1855,7 +1876,7 @@ def test_groups_capture(self): ) def test_basic_set(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set( distinct_id="distinct_id", properties={"trait": "value"} @@ -1865,7 +1886,7 @@ def test_basic_set(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["$set"]["trait"], "value") @@ -1874,7 +1895,7 @@ def test_basic_set(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_advanced_set(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set( distinct_id="distinct_id", @@ -1887,7 +1908,7 @@ def test_advanced_set(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1899,7 +1920,7 @@ def test_advanced_set(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_basic_set_once(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set_once( distinct_id="distinct_id", properties={"trait": "value"} @@ -1909,7 +1930,7 @@ def test_basic_set_once(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["$set_once"]["trait"], "value") @@ -1918,7 +1939,7 @@ def test_basic_set_once(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_advanced_set_once(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set_once( distinct_id="distinct_id", @@ -1931,7 +1952,7 @@ def test_advanced_set_once(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1943,7 +1964,7 @@ def test_advanced_set_once(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_basic_group_identify(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify("organization", "id:5") @@ -1951,7 +1972,7 @@ def test_basic_group_identify(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -1971,7 +1992,7 @@ def test_basic_group_identify(self): self.assertIsNotNone(msg.get("uuid")) def test_basic_group_identify_with_distinct_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", "id:5", distinct_id="distinct_id" @@ -1980,7 +2001,7 @@ def test_basic_group_identify_with_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2001,7 +2022,7 @@ def test_basic_group_identify_with_distinct_id(self): self.assertIsNotNone(msg.get("uuid")) def test_advanced_group_identify(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", @@ -2015,7 +2036,7 @@ def test_advanced_group_identify(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2034,7 +2055,7 @@ def test_advanced_group_identify(self): self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") def test_advanced_group_identify_with_distinct_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", @@ -2049,7 +2070,7 @@ def test_advanced_group_identify_with_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2076,7 +2097,7 @@ def test_advanced_group_identify_with_distinct_id(self): ] ) def test_group_identify_without_group_type_is_dropped(self, _name, group_type): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.group_identify(group_type, "id:5") @@ -2092,7 +2113,7 @@ def test_group_identify_without_group_type_is_dropped(self, _name, group_type): ] ) def test_group_identify_without_group_key_is_dropped(self, _name, group_key): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.group_identify("organization", group_key) @@ -2102,18 +2123,18 @@ def test_group_identify_without_group_key_is_dropped(self, _name, group_key): self.assertIn("group_key", logs.output[0]) def test_group_identify_accepts_falsy_non_string_group_key(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify("organization", 0) self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] # The group key is validated, not normalized - it goes out as passed. self.assertEqual(msg["properties"]["$group_key"], 0) def test_basic_alias(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.alias("previousId", "distinct_id") self.assertIsNotNone(msg_uuid) @@ -2121,7 +2142,7 @@ def test_basic_alias(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["distinct_id"], "previousId") self.assertEqual(msg["properties"]["alias"], "distinct_id") @@ -2133,7 +2154,7 @@ def test_basic_alias(self): ] ) def test_alias_without_previous_id_is_dropped(self, _name, previous_id): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.alias(previous_id, "distinct_id") @@ -2143,18 +2164,18 @@ def test_alias_without_previous_id_is_dropped(self, _name, previous_id): self.assertIn("previous_id", logs.output[0]) def test_alias_accepts_non_string_previous_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.alias(0, "distinct_id") self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["distinct_id"], "0") self.assertEqual(msg["properties"]["distinct_id"], "0") def test_alias_without_distinct_id_is_dropped(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.alias("previousId", None) @@ -2190,7 +2211,7 @@ def test_alias_without_distinct_id_is_dropped(self): def test_capture_with_session_id_variations( self, test_name, session_id, additional_properties, expected_properties ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) properties = {"$session_id": session_id, **additional_properties} @@ -2203,7 +2224,7 @@ def test_capture_with_session_id_variations( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -2217,7 +2238,7 @@ def test_capture_with_session_id_variations( self.assertEqual(msg["properties"][key], value) def test_session_id_preserved_with_groups(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) session_id = "group-session-101" @@ -2232,7 +2253,7 @@ def test_session_id_preserved_with_groups(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], session_id) @@ -2242,7 +2263,7 @@ def test_session_id_preserved_with_groups(self): ) def test_session_id_with_anonymous_event(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) session_id = "anonymous-session-202" @@ -2259,7 +2280,7 @@ def test_session_id_with_anonymous_event(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], session_id) @@ -2339,7 +2360,7 @@ def test_session_id_with_different_event_types( additional_properties, expected_additional_properties, ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) properties = {"$session_id": session_id, **additional_properties} @@ -2351,7 +2372,7 @@ def test_session_id_with_different_event_types( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], event_name) @@ -2406,7 +2427,7 @@ def test_session_id_with_super_properties_variations( expected_session_id, expected_super_props, ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, super_properties=super_properties, sync_mode=True ) @@ -2421,7 +2442,7 @@ def test_session_id_with_super_properties_variations( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], expected_session_id) @@ -2704,22 +2725,79 @@ def flush(timeout_seconds): self.assertEqual(timeouts, [0, None]) - def test_sync_send_failure_does_not_invoke_async_on_error(self): - on_error = mock.Mock() + def test_sync_send_failure_invokes_on_error_after_releasing_send_slot(self): + error = Exception("upload failed") + active_sends_in_callback = [] + client: Client + + def on_error(err, batch): + active_sends_in_callback.append(client._analytics_lane._active_sync_sends) + self.assertIs(err, error) + self.assertEqual([event["event"] for event in batch], ["event"]) + client.shutdown() + + client = Client(FAKE_TEST_API_KEY, sync_mode=True, on_error=on_error) + + with patch_capture_send("client", side_effect=error): + result = client.capture("event", distinct_id="distinct_id") + + self.assertIsNone(result) + self.assertEqual(active_sends_in_callback, [0]) + + def test_sync_send_failure_inside_on_error_logs_instead_of_recursing(self): + calls = [] + client: Client + + def on_error(err, batch): + calls.append(batch[0]["event"]) + client.capture("from_callback", distinct_id="distinct_id") + + client = Client(FAKE_TEST_API_KEY, sync_mode=True, on_error=on_error) + + with patch_capture_send("client", side_effect=Exception("upload failed")): + with self.assertLogs("posthog", level="ERROR") as logs: + client.capture("event", distinct_id="distinct_id") + + self.assertEqual(calls, ["event"]) + self.assertIn( + "1 event(s) not persisted by /i/v1/analytics/events: Exception", + "\n".join(logs.output), + ) + + @parameterized.expand([("capture",), ("capture_ai",)]) + def test_sync_send_failure_in_debug_reports_once_then_raises(self, method_name): + errors = [] client = Client( FAKE_TEST_API_KEY, sync_mode=True, - on_error=on_error, + debug=True, + on_error=lambda err, batch: errors.append(err), ) + failure = APIError(503, "unavailable") - with mock.patch( - "posthog.client.batch_post", side_effect=Exception("upload failed") + with patch_capture_send("client", side_effect=failure): + with self.assertRaises(APIError) as raised: + getattr(client, method_name)("event", distinct_id="distinct_id") + + self.assertIs(raised.exception, failure) + self.assertEqual(errors, [failure]) + + def test_sync_send_failure_without_on_error_logs_aggregate_line(self): + client = Client(FAKE_TEST_API_KEY, sync_mode=True) + + with patch_capture_send( + "client", side_effect=APIError(400, "password=server-secret") ): - result = client.capture("event", distinct_id="distinct_id") + with self.assertLogs("posthog", level="ERROR") as logs: + result = client.capture("event", distinct_id="distinct_id") self.assertIsNone(result) - on_error.assert_not_called() - self.assertEqual(client._analytics_lane._active_sync_sends, 0) + output = "\n".join(logs.output) + self.assertIn( + "1 event(s) not persisted by /i/v1/analytics/events: APIError (status=400)", + output, + ) + self.assertNotIn("server-secret", output) def test_on_error_can_request_shutdown_with_pending_work(self): first_send_started = threading.Event() @@ -3401,7 +3479,7 @@ def blocking_post(*args, **kwargs): target=lambda: (client.shutdown(), shutdown_done.set()) ) - with mock.patch("posthog.client.batch_post", side_effect=blocking_post) as post: + with patch_capture_send("client", side_effect=blocking_post) as post: capture_thread.start() self.assertTrue(send_started.wait(2)) shutdown_thread.start() @@ -3424,7 +3502,7 @@ def blocking_post(*args, **kwargs): post.assert_called_once() def test_synchronous(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, sync_mode=True) msg_uuid = client.capture("test event", distinct_id="distinct_id") @@ -3502,8 +3580,12 @@ def test_numeric_distinct_id(self): def test_debug(self): Client("bad_key", debug=True) - def test_gzip(self): - client = Client(FAKE_TEST_API_KEY, on_error=self.fail, gzip=True) + def test_gzip_compression(self): + client = Client( + FAKE_TEST_API_KEY, + on_error=self.fail, + capture_compression=CaptureCompression.GZIP, + ) for _ in range(10): client.capture( "event", distinct_id="distinct_id", properties={"trait": "value"} @@ -3516,14 +3598,12 @@ def test_user_defined_flush_at(self): FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3 ) - def mock_post_fn(*args, **kwargs): - self.assertEqual(len(kwargs["batch"]), 10) + def mock_post_fn(api_key, host, batch, **kwargs): + self.assertEqual(len(batch), 10) # the post function should be called 2 times, with a batch size of 10 # each time. - with mock.patch( - "posthog.consumer.batch_post", side_effect=mock_post_fn - ) as mock_post: + with patch_capture_send("consumer", side_effect=mock_post_fn) as mock_post: for _ in range(20): client.capture( "event", distinct_id="distinct_id", properties={"trait": "value"} @@ -3576,7 +3656,7 @@ def test_disabled_with_feature_flags(self, patch_flags): self.assertTrue(client.queue.empty()) def test_enabled_to_disabled(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3590,7 +3670,7 @@ def test_enabled_to_disabled(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -3600,7 +3680,7 @@ def test_enabled_to_disabled(self): self.assertFalse(self.failed) def test_disable_geoip_default_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3612,12 +3692,12 @@ def test_disable_geoip_default_on_events(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) capture_msg = batch_data[0] self.assertEqual(capture_msg["properties"]["$geoip_disable"], True) def test_disable_geoip_override_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3643,17 +3723,17 @@ def test_disable_geoip_override_on_events(self): self.assertEqual(mock_post.call_count, 2) # Check set event - set_batch = mock_post.call_args_list[0][1]["batch"] + set_batch = sent_batch(mock_post, 0) capture_msg = set_batch[0] self.assertEqual(capture_msg["properties"]["$geoip_disable"], True) # Check page event - page_batch = mock_post.call_args_list[1][1]["batch"] + page_batch = sent_batch(mock_post, 1) identify_msg = page_batch[0] self.assertEqual("$geoip_disable" not in identify_msg["properties"], True) def test_disable_geoip_method_overrides_init_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3667,7 +3747,7 @@ def test_disable_geoip_method_overrides_init_on_events(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertTrue("$geoip_disable" not in msg["properties"]) @@ -4119,7 +4199,7 @@ def test_get_decide_returns_normalized_decide_response(self, patch_flags): } def test_set_context_session_with_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("context-session-123") @@ -4134,7 +4214,7 @@ def test_set_context_session_with_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4143,7 +4223,7 @@ def test_set_context_session_with_capture(self): @parameterized.expand([("new_context",), ("scoped",)]) def test_client_context_helpers_apply_to_capture(self, context_helper): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) def capture_in_context(): @@ -4171,7 +4251,7 @@ def scoped_capture(): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "context-user") @@ -4182,7 +4262,7 @@ def scoped_capture(): self.assertEqual(client.get_tags(), {}) def test_client_scoped_context_helpers_apply_to_capture_async(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) @client.scoped(fresh=True) @@ -4197,7 +4277,7 @@ async def scoped_capture(): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "async-scoped-user") @@ -4213,7 +4293,7 @@ async def scoped_capture(): self.assertEqual(client.get_tags(), {}) def test_set_context_session_with_page_explicit_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("page-explicit-session-789") @@ -4230,7 +4310,7 @@ def test_set_context_session_with_page_explicit_properties(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4241,7 +4321,7 @@ def test_set_context_session_override_in_capture(self): """Test that explicit session ID overrides context session ID in capture""" from posthog.contexts import new_context, set_context_session - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("context-session-override") @@ -4259,7 +4339,7 @@ def test_set_context_session_override_in_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4462,7 +4542,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -4490,7 +4570,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): # Check the message includes only the filtered flags mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/flag1"], "value1") @@ -4498,7 +4578,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): # flag2 should not be included since it wasn't requested self.assertNotIn("$feature/flag2", msg["properties"]) - @mock.patch("posthog.client.batch_post") + @patch_capture_send("client") def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_post): """Test that get_feature_flag_result returns a FeatureFlagResult when payload is empty string""" client = Client( @@ -4549,7 +4629,7 @@ def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_pos self.assertEqual(result.get_value(), "empty-variant") self.assertIsNone(result.payload) - @mock.patch("posthog.client.batch_post") + @patch_capture_send("client") def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post): """Test that get_all_flags_and_payloads includes flags with empty string payloads""" client = Client( @@ -4605,14 +4685,14 @@ def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post): ) def test_context_tags_added(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): tag("random_tag", 12345) client.capture("python test event", distinct_id="distinct_id") - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"]) @@ -4661,30 +4741,18 @@ def test_debug_flag_re_raises_exceptions(self, mock_enqueue): class TestClientCaptureRetrySemantics(unittest.TestCase): - @parameterized.expand( - [ - ("v0_sync", "v0", True), - ("v1_sync", "v1", True), - ("v0_async", "v0", False), - ("v1_async", "v1", False), - ] - ) - def test_negative_max_retries_still_attempts_delivery_once( - self, _name, capture_mode, sync_mode - ): + @parameterized.expand([("sync", True), ("async", False)]) + def test_negative_max_retries_still_attempts_delivery_once(self, _name, sync_mode): response = mock.Mock(status_code=200, headers={}, text="") response.json.return_value = {"results": {}} client = None - with ( - mock.patch("posthog.client.batch_post") as sync_v0_post, - mock.patch("posthog.consumer.batch_post") as async_v0_post, - mock.patch("posthog.capture_v1._post_v1", return_value=response) as v1_post, - ): + with mock.patch( + "posthog.capture_send._post_v1", return_value=response + ) as v1_post: try: client = Client( FAKE_TEST_API_KEY, - capture_mode=capture_mode, sync_mode=sync_mode, max_retries=-1, flush_at=1, @@ -4695,59 +4763,30 @@ def test_negative_max_retries_still_attempts_delivery_once( client.flush() self.assertEqual(client.max_retries, 0) - if capture_mode == "v1": - v1_post.assert_called_once() - sync_v0_post.assert_not_called() - async_v0_post.assert_not_called() - elif sync_mode: - sync_v0_post.assert_called_once() - async_v0_post.assert_not_called() - v1_post.assert_not_called() - else: - async_v0_post.assert_called_once() - sync_v0_post.assert_not_called() - v1_post.assert_not_called() + v1_post.assert_called_once() finally: if client is not None and not sync_mode: client.shutdown() -class TestClientSyncCaptureMode(unittest.TestCase): - """Sync-mode `_enqueue` selects the analytics submitter by `capture_mode`.""" +class TestClientSyncCapture(unittest.TestCase): + """Sync-mode `_enqueue` sends analytics events through the v1 submitter.""" def _client(self, **kwargs): return Client(FAKE_TEST_API_KEY, sync_mode=True, **kwargs) - @parameterized.expand( - [ - ("v0", None, False), - ("v1", "v1", True), - ] - ) - def test_capture_mode_selects_sync_submitter(self, _name, capture_mode, expects_v1): - kwargs = {"capture_mode": capture_mode} if capture_mode else {} - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - self._client(**kwargs).capture("evt", distinct_id="d") - if expects_v1: - mock_post.assert_not_called() - mock_v1.assert_called_once() - sent_batch = mock_v1.call_args.args[2] - self.assertEqual(len(sent_batch), 1) - self.assertEqual(sent_batch[0]["event"], "evt") - else: - mock_v1.assert_not_called() - mock_post.assert_called_once() - - def test_v1_sync_forwards_config_to_submitter(self): - with ( - mock.patch("posthog.client.batch_post"), - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): + def test_sync_capture_posts_to_analytics_endpoint(self): + with mock.patch("posthog.client._send_v1_batch") as mock_v1: + self._client().capture("evt", distinct_id="d") + mock_v1.assert_called_once() + batch = mock_v1.call_args.args[2] + self.assertEqual(len(batch), 1) + self.assertEqual(batch[0]["event"], "evt") + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) + + def test_sync_forwards_config_to_submitter(self): + with mock.patch("posthog.client._send_v1_batch") as mock_v1: self._client( - capture_mode="v1", capture_compression=CaptureCompression.GZIP, max_retries=4, historical_migration=True, @@ -4757,29 +4796,15 @@ def test_v1_sync_forwards_config_to_submitter(self): self.assertEqual(kwargs["max_retries"], 4) self.assertEqual(kwargs["historical_migration"], True) - def test_v1_sync_gzip_flag_falls_back_to_gzip_compression(self): - # Legacy `gzip=True` with no explicit capture_compression -> GZIP on v1. - with ( - mock.patch("posthog.client.batch_post"), - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - self._client(capture_mode="v1", gzip=True).capture("evt", distinct_id="d") - self.assertEqual( - mock_v1.call_args.kwargs["compression"], CaptureCompression.GZIP - ) - - def test_v1_sync_ai_named_event_through_capture_uses_v1(self): + def test_sync_ai_named_event_through_capture_uses_analytics_endpoint(self): # `capture()` never special-cases AI events: an `$ai_*`-named event - # follows `capture_mode` and rides the v1 submitter like any analytics - # event. Only `capture_ai()` reaches the AI lane. - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - client = self._client(capture_mode="v1") + # rides the analytics endpoint like any other event. Only + # `capture_ai()` reaches the AI lane. + with mock.patch("posthog.client._send_v1_batch") as mock_v1: + client = self._client() client.capture("$ai_generation", distinct_id="d") - mock_post.assert_not_called() mock_v1.assert_called_once() - sent_batch = mock_v1.call_args.args[2] - self.assertEqual(len(sent_batch), 1) - self.assertEqual(sent_batch[0]["event"], "$ai_generation") + batch = mock_v1.call_args.args[2] + self.assertEqual(len(batch), 1) + self.assertEqual(batch[0]["event"], "$ai_generation") + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) diff --git a/posthog/test/test_code_variables.py b/posthog/test/test_code_variables.py index 6263b4b8d..21d527469 100644 --- a/posthog/test/test_code_variables.py +++ b/posthog/test/test_code_variables.py @@ -112,17 +112,11 @@ def extract( import os import posthog from posthog import Posthog -from requests import Response import posthog.request +from posthog.test.capture_helpers import offline_v1_post -def offline_post(url, **kwargs): - response = Response() - response.status_code = 200 - return response - - -posthog.request._session.post = offline_post +posthog.request._session.post = offline_v1_post def make_client(**options): diff --git a/posthog/test/test_consumer.py b/posthog/test/test_consumer.py index c2c54cf52..f4cf3188d 100644 --- a/posthog/test/test_consumer.py +++ b/posthog/test/test_consumer.py @@ -2,9 +2,6 @@ import threading import time import unittest -from datetime import datetime, timedelta, timezone -from email.utils import format_datetime -from typing import Any from unittest import mock from parameterized import parameterized @@ -15,9 +12,9 @@ from Queue import Queue from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.consumer import MAX_MSG_SIZE, Consumer, _DrainSignal -from posthog.request import AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT, APIError +from posthog.test.capture_helpers import patch_capture_send, sent_batch from posthog.test.logging_helpers import capture_message_only_logs from posthog.test.test_utils import TEST_API_KEY @@ -207,16 +204,37 @@ def test_max_msg_size_param_raises_per_event_ceiling(self) -> None: q.put(big_msg) self.assertEqual(consumer.next(), [big_msg]) + @parameterized.expand( + [ + # A small event serializes to 18 bytes, the "big" one to 81. + ("closes_before_overflow", 43, [0, 1, 2, 3], [[0, 1], [2, 3]]), + ("event_over_limit_goes_alone", 30, [0, "big", 1], [[0], ["big"], [1]]), + ] + ) + def test_batch_byte_limit_is_checked_before_appending( + self, _name, limit, ids, expected + ) -> None: + q = Queue() + consumer = Consumer(q, "", flush_at=10, flush_interval=0.01) + for i in ids: + q.put({"m": "x" * (60 if i == "big" else 1), "i": i}) + + with mock.patch("posthog.consumer.BATCH_SIZE_LIMIT", limit): + batches = [[e["i"] for e in consumer.next()] for _ in expected] + + self.assertEqual(batches, expected) + self.assertEqual(q.unfinished_tasks, len(ids)) + def test_upload(self) -> None: q = Queue() consumer = Consumer(q, TEST_API_KEY, flush_at=1) event = _track_event() q.put(event) - with mock.patch("posthog.consumer.batch_post") as post: + with patch_capture_send("consumer") as post: success = consumer.upload() self.assertTrue(success) post.assert_called_once() - self.assertEqual(post.call_args.kwargs["batch"], [event]) + self.assertEqual(sent_batch(post), [event]) self.assertEqual(q.unfinished_tasks, 0) self.assertTrue(q.empty()) @@ -235,9 +253,11 @@ def test_message_only_error_logs_include_posthog_prefix(self) -> None: # other tests can log into the same stream. Assert on the line under # test rather than on the entire capture. upload_logs = [ - line for line in logs.getvalue().splitlines() if "error uploading" in line + line for line in logs.getvalue().splitlines() if "not persisted" in line ] - expected_log = "[PostHog] error uploading: boom" + expected_log = ( + "[PostHog] 1 event(s) not persisted by /i/v1/analytics/events: Exception" + ) self.assertEqual( [line for line in upload_logs if line == expected_log], [expected_log] ) @@ -307,12 +327,12 @@ def test_multiple_uploads_per_interval(self) -> None: delivered = threading.Event() batches = [] - def record_batch(*args, **kwargs): - batches.append(kwargs["batch"]) + def record_batch(api_key, host, batch, **kwargs): + batches.append(batch) if len(batches) == 2: delivered.set() - with mock.patch("posthog.consumer.batch_post", side_effect=record_batch): + with patch_capture_send("consumer", side_effect=record_batch): consumer.start() try: events = [ @@ -327,86 +347,15 @@ def record_batch(*args, **kwargs): consumer.join(15) self.assertFalse(consumer.is_alive()) - def test_request(self) -> None: - consumer = Consumer(None, TEST_API_KEY) - batch = [_track_event()] - with mock.patch("posthog.consumer.batch_post") as post: - consumer.request(batch) - post.assert_called_once_with( - TEST_API_KEY, - None, - gzip=False, - timeout=15, - batch=batch, - historical_migration=False, - path="/batch/", - ) - - def _run_retry_test( - self, - exception: Exception, - exception_count: int, - retries: int = 10, - expected_attempts: int = 3, - raises: bool = False, - ) -> None: - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= exception_count: - raise exception - - consumer = Consumer(None, TEST_API_KEY, retries=retries) - batch = [_track_event()] - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post) as post, - mock.patch("posthog.consumer.time.sleep"), - ): - if raises: - with self.assertRaises(type(exception)) as raised: - consumer.request(batch) - self.assertIs(raised.exception, exception) - else: - consumer.request(batch) - self.assertEqual(post.call_count, expected_attempts) - for call in post.call_args_list: - self.assertEqual(call.kwargs["batch"], batch) - - @parameterized.expand( - [ - ("general_errors", Exception("generic exception"), 2), - ("server_errors", APIError(500, "Internal Server Error"), 2), - ("rate_limit_errors", APIError(429, "Too Many Requests"), 2), - ] - ) - def test_request_retries_on_retriable_errors( - self, _name: str, exception: Exception, exception_count: int - ) -> None: - self._run_retry_test(exception, exception_count) - - def test_request_does_not_retry_client_errors(self) -> None: - self._run_retry_test( - APIError(400, "Client Errors"), 1, expected_attempts=1, raises=True - ) - - def test_request_fails_when_exceptions_exceed_retries(self) -> None: - self._run_retry_test( - APIError(500, "Internal Server Error"), - 4, - retries=3, - expected_attempts=4, - raises=True, - ) - def test_negative_retries_still_attempts_delivery_once(self) -> None: consumer = Consumer(None, TEST_API_KEY, retries=-1) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_send: consumer.request([_track_event()]) self.assertEqual(consumer.retries, 0) - mock_post.assert_called_once() + mock_send.assert_called_once() + self.assertEqual(mock_send.call_args.kwargs["max_retries"], 0) def test_pause(self) -> None: consumer = Consumer(None, TEST_API_KEY) @@ -602,7 +551,7 @@ def test_max_batch_size(self) -> None: # Let's capture 8MB of data to trigger two batches n_msgs = int(8_000_000 / msg_size) - with mock.patch.object(consumer, "_send") as mock_send: + with mock.patch.object(consumer, "request") as mock_send: consumer.start() try: for _ in range(0, n_msgs + 2): @@ -620,101 +569,6 @@ def test_max_batch_size(self) -> None: consumer.join(5) self.assertFalse(consumer.is_alive()) - def test_request_sleeps_with_retry_after(self) -> None: - error = APIError(429, "Too Many Requests", retry_after=5.0) - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 1: - raise error - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - mock_sleep.assert_called_once_with(5.0) - - def test_request_uses_exponential_backoff_without_retry_after(self) -> None: - error = APIError(503, "Service Unavailable") - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 3: - raise error - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - self.assertEqual( - mock_sleep.call_args_list, - [ - mock.call(1), # 2^0 - mock.call(2), # 2^1 - mock.call(4), # 2^2 - ], - ) - - @parameterized.expand( - [ - ("huge_numeric", "1000000000", [30, 30]), - ("small_numeric", "0.25", [1, 2]), - ("huge_date", "Fri, 01 Jan 2100 00:00:00 GMT", [30, 30]), - ("small_date", None, [1, 2]), - ] - ) - def test_request_bounds_retry_after_without_reducing_attempts( - self, _name: str, retry_after_header: str | None, expected_sleeps: list[int] - ) -> None: - if retry_after_header is None: - retry_after_header = format_datetime( - datetime.now(timezone.utc) + timedelta(seconds=1), usegmt=True - ) - - retry_response = mock.Mock( - status_code=503, - headers={"Retry-After": retry_after_header}, - text="Service Unavailable", - ) - retry_response.json.return_value = {"detail": "Service Unavailable"} - success_response = mock.Mock(status_code=200) - session = mock.Mock() - session.post.side_effect = [retry_response, retry_response, success_response] - - consumer = Consumer(None, TEST_API_KEY, retries=2) - with ( - mock.patch("posthog.request._get_session", return_value=session), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - - self.assertEqual(session.post.call_count, 3) - self.assertEqual( - [call.args[0] for call in mock_sleep.call_args_list], expected_sleeps - ) - - def test_request_retries_on_408(self) -> None: - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 1: - raise APIError(408, "Request Timeout") - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep"), - ): - consumer.request([_track_event()]) - self.assertEqual(call_count[0], 2) - @parameterized.expand( [ ("on_error_succeeds", False), @@ -751,48 +605,28 @@ def _ai_event(event_name: str = "$ai_generation") -> dict[str, str]: return {"type": "track", "event": event_name, "distinct_id": "distinct_id"} -class TestConsumerCaptureModeRouting(unittest.TestCase): - """`capture_mode` selects the submitter; V0 posts to the consumer's `endpoint`.""" +class TestConsumerSubmitterRouting(unittest.TestCase): + """Every consumer sends through the capture v1 submitter to its `endpoint`.""" - @parameterized.expand( - [ - ("v0", CaptureMode.V0, False), - ("v1", CaptureMode.V1, True), - ] - ) - def test_capture_mode_selects_analytics_submitter( - self, _name, mode, expects_v1 - ) -> None: - consumer = Consumer(None, TEST_API_KEY, capture_mode=mode) + def test_default_posts_to_analytics_endpoint(self) -> None: + consumer = Consumer(None, TEST_API_KEY) batch = [_track_event()] - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request(batch) - if expects_v1: - mock_post.assert_not_called() - mock_v1.assert_called_once() - self.assertEqual(mock_v1.call_args.args[2], batch) - else: - mock_v1.assert_not_called() - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], EVENTS_ENDPOINT) - - def test_v1_forwards_consumer_config_to_submitter(self) -> None: + mock_v1.assert_called_once() + self.assertEqual(sent_batch(mock_v1), batch) + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) + + def test_forwards_consumer_config_to_submitter(self) -> None: consumer = Consumer( None, TEST_API_KEY, - capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.DEFLATE, timeout=7, retries=4, historical_migration=True, ) - with ( - mock.patch("posthog.consumer.batch_post"), - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request([_track_event()]) kwargs = mock_v1.call_args.kwargs self.assertEqual(kwargs["compression"], CaptureCompression.DEFLATE) @@ -800,25 +634,11 @@ def test_v1_forwards_consumer_config_to_submitter(self) -> None: self.assertEqual(kwargs["max_retries"], 4) self.assertEqual(kwargs["historical_migration"], True) - def test_v0_posts_to_configured_endpoint(self) -> None: - consumer = Consumer(None, TEST_API_KEY, endpoint=AI_EVENTS_ENDPOINT) + def test_posts_to_configured_endpoint(self) -> None: + consumer = Consumer(None, TEST_API_KEY, endpoint=_CAPTURE_AI_V1_PATH) batch = [_ai_event()] - with mock.patch("posthog.consumer.batch_post") as mock_post: - consumer.request(batch) - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - self.assertEqual(mock_post.call_args.kwargs["batch"], batch) - - def test_v1_routes_whole_batch_through_v1_submitter(self) -> None: - # A consumer doesn't know AI events exist: with `capture_mode` v1, the - # whole batch (including `$ai_*`-named events) rides the v1 submitter. - consumer = Consumer(None, TEST_API_KEY, capture_mode=CaptureMode.V1) - batch = [_ai_event(), _track_event()] - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request(batch) - mock_v1.assert_called_once() - self.assertEqual(mock_v1.call_args.args[2], batch) - mock_post.assert_not_called() + mock_v1.assert_called_once() + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + self.assertEqual(sent_batch(mock_v1), batch) diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index ef83c433f..b12ff88fa 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -278,15 +278,10 @@ def test_excepthook(tmpdir): dedent( """ from posthog import Posthog - from requests import Response import posthog.request + from posthog.test.capture_helpers import offline_v1_post - def offline_post(url, **kwargs): - response = Response() - response.status_code = 200 - return response - - posthog.request._session.post = offline_post + posthog.request._session.post = offline_v1_post posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch)) # frame_value = "LOL" @@ -305,7 +300,8 @@ def offline_post(url, **kwargs): assert b"ZeroDivisionError" in output assert b"LOL" in output - assert b"DEBUG:posthog:[PostHog] data uploaded successfully" in output + assert b"[PostHog] capture v1 response" in output + assert b" ok=1 " in output assert ( b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"' in output diff --git a/posthog/test/test_flag_definition_cache.py b/posthog/test/test_flag_definition_cache.py index 2d05997d4..d221245d9 100644 --- a/posthog/test/test_flag_definition_cache.py +++ b/posthog/test/test_flag_definition_cache.py @@ -17,6 +17,7 @@ ) from posthog.request import GetResponse from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import patch_capture_send class MockCacheProvider: @@ -94,8 +95,8 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase): @classmethod def setUpClass(cls): # Prevent real HTTP requests - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() diff --git a/posthog/test/test_gevent_compat.py b/posthog/test/test_gevent_compat.py index 34e0655bf..2948e9b2c 100644 --- a/posthog/test/test_gevent_compat.py +++ b/posthog/test/test_gevent_compat.py @@ -10,6 +10,7 @@ from posthog.client import Client, _new_lane_queue from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import patch_capture_send class TestLaneQueueFallback(unittest.TestCase): @@ -80,7 +81,7 @@ def test_incompatible_queue_does_not_disable_queue_independent_capabilities(self with ( mock.patch("posthog.client.Queue", return_value=incompatible_queue), mock.patch.dict(sys.modules, {"gevent.monkey": None}), - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch( "posthog.client.flags", return_value={"featureFlags": {"beta-feature": True}}, @@ -113,9 +114,9 @@ def test_capture_and_flush_after_monkey_patching(self): "is not being exercised" ) - from unittest import mock + from posthog.test.capture_helpers import patch_capture_send - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: from posthog.client import Client client = Client("phc_test", flush_at=1, flush_interval=60) @@ -137,7 +138,7 @@ def test_capture_and_flush_after_monkey_patching(self): client.flush(timeout_seconds=10) client.join() - assert mock_post.called, "batch_post was never called" + assert mock_post.called, "capture send was never called" assert client.queue.empty(), "flush did not drain the queue" """ ) diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index dc875e674..6ebd8fe4e 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -7,6 +7,7 @@ import posthog from posthog import Posthog +from posthog.test.capture_helpers import patch_capture_send, sent_batch class TestModule(unittest.TestCase): @@ -17,7 +18,7 @@ def _assert_enqueue_result(self, result): self.assertTrue(result) def setUp(self): - patcher = mock.patch("posthog.consumer.batch_post") + patcher = patch_capture_send("consumer") self.transport = patcher.start() self.addCleanup(patcher.stop) self.on_error = mock.Mock() @@ -32,7 +33,7 @@ def test_track(self): self._assert_enqueue_result(res) self.posthog.flush() self.transport.assert_called_once() - event = self.transport.call_args.kwargs["batch"][0] + event = sent_batch(self.transport)[0] self.assertEqual(event["event"], "python module event") self.assertEqual(event["distinct_id"], "distinct_id") self.assertEqual(event["uuid"], res) @@ -42,7 +43,7 @@ def test_alias(self): self._assert_enqueue_result(res) self.posthog.flush() self.transport.assert_called_once() - event = self.transport.call_args.kwargs["batch"][0] + event = sent_batch(self.transport)[0] self.assertEqual(event["event"], "$create_alias") self.assertEqual(event["distinct_id"], "previousId") self.assertEqual(event["properties"]["alias"], "distinct_id") diff --git a/posthog/test/test_release_id.py b/posthog/test/test_release_id.py index 12b8e7e24..109f79f44 100644 --- a/posthog/test/test_release_id.py +++ b/posthog/test/test_release_id.py @@ -10,6 +10,7 @@ from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client from posthog.release_id import RELEASE_ID_ENV_VAR, _resolve_release_id from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import patch_async_capture_send # (name, call, expected event): one row per public event-producing method, shared # by the sync and async clients. Each call builds its own arguments, because a @@ -155,10 +156,10 @@ async def _async_events(env_value, send_events): """Build an async client under `env_value`, run `send_events`, return the batch.""" batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): with _release_id_env(env_value): client = AsyncPosthog("test-key", flush_interval=30) async with client: diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index d4ad7c9da..c16e08f47 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -1,7 +1,5 @@ -import gzip import json import unittest -import zlib from datetime import date, datetime, timedelta from unittest import mock @@ -18,7 +16,6 @@ KEEP_ALIVE_SOCKET_OPTIONS, QuotaLimitError, _mask_tokens_in_url, - batch_post, determine_server_host, disable_connection_reuse, enable_keep_alive, @@ -132,47 +129,6 @@ def test_message_only_debug_logs_include_posthog_prefix(): class TestRequests(unittest.TestCase): - def test_valid_request(self): - response = requests.Response() - response.status_code = 200 - session = mock.Mock() - session.post.return_value = response - batch = [ - {"distinct_id": "distinct_id", "event": "python event", "type": "track"} - ] - - res = batch_post(TEST_API_KEY, batch=batch, session=session) - - self.assertIs(res, response) - session.post.assert_called_once() - self.assertTrue(session.post.call_args.args[0].endswith("/batch/")) - body = json.loads(session.post.call_args.kwargs["data"]) - self.assertEqual(body["batch"], batch) - self.assertEqual(body["api_key"], TEST_API_KEY) - - def test_invalid_request_error(self): - response = requests.Response() - response.status_code = 400 - response._content = b'{"detail": "Invalid batch"}' - session = mock.Mock() - session.post.return_value = response - - with self.assertRaises(APIError) as raised: - batch_post("testsecret", batch=[], session=session) - - self.assertEqual(raised.exception.status, 400) - self.assertEqual(raised.exception.message, "Invalid batch") - session.post.assert_called_once() - - def test_invalid_host(self): - self.assertRaises( - requests.exceptions.MissingSchema, - batch_post, - "testsecret", - "t.posthog.com/", - batch=[], - ) - def test_post_without_path_preserves_type_error(self): mock_session = mock.MagicMock() @@ -185,7 +141,7 @@ def test_post_without_path_preserves_type_error(self): mock_session.post.assert_not_called() - def test_post_sends_string_payload_without_gzip(self): + def test_post_sends_string_payload(self): mock_response = requests.Response() mock_response.status_code = 200 mock_session = mock.MagicMock() @@ -205,54 +161,6 @@ def test_post_sends_string_payload_without_gzip(self): self.assertEqual(url, "https://test.posthog.com/batch/") self.assertIsInstance(data, str) - def test_post_sends_bytes_payload_with_gzip(self): - mock_response = requests.Response() - mock_response.status_code = 200 - mock_session = mock.MagicMock() - mock_session.post.return_value = mock_response - - request_module.post( - TEST_API_KEY, - host="https://test.posthog.com", - path="/batch/", - gzip=True, - session=mock_session, - batch=[], - ) - - data = mock_session.post.call_args.kwargs["data"] - headers = mock_session.post.call_args.kwargs["headers"] - self.assertIsInstance(data, bytes) - self.assertEqual(headers["Content-Encoding"], "gzip") - body = json.loads(gzip.decompress(data)) - self.assertEqual(body["batch"], []) - self.assertEqual(body["api_key"], TEST_API_KEY) - - def test_post_falls_back_to_uncompressed_payload_when_gzip_fails(self): - for compression_error in [OSError("boom"), zlib.error("boom")]: - with self.subTest(compression_error=type(compression_error)): - mock_response = requests.Response() - mock_response.status_code = 200 - mock_session = mock.MagicMock() - mock_session.post.return_value = mock_response - - with mock.patch.object( - request_module, "GzipFile", side_effect=compression_error - ): - request_module.post( - TEST_API_KEY, - host="https://test.posthog.com", - path="/batch/", - gzip=True, - session=mock_session, - batch=[], - ) - - data = mock_session.post.call_args.kwargs["data"] - headers = mock_session.post.call_args.kwargs["headers"] - self.assertIsInstance(data, str) - self.assertNotIn("Content-Encoding", headers) - def test_datetime_serialization(self): data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)} result = json.dumps(data, cls=DatetimeSerializer) @@ -265,30 +173,6 @@ def test_date_serialization(self): expected = '{"created": "%s"}' % today.isoformat() self.assertEqual(result, expected) - def test_should_not_timeout(self): - response = requests.Response() - response.status_code = 200 - session = mock.Mock() - session.post.return_value = response - - self.assertIs( - batch_post(TEST_API_KEY, batch=[], timeout=7, session=session), response - ) - session.post.assert_called_once() - self.assertEqual(session.post.call_args.kwargs["timeout"], 7) - - def test_should_timeout(self): - error = requests.ReadTimeout("response deadline exceeded") - session = mock.Mock() - session.post.side_effect = error - - with self.assertRaises(requests.ReadTimeout) as raised: - batch_post("key", batch=[], timeout=1, session=session) - - self.assertIs(raised.exception, error) - session.post.assert_called_once() - self.assertEqual(session.post.call_args.kwargs["timeout"], 1) - def test_quota_limited_flags_response(self): mock_response = requests.Response() mock_response.status_code = 200 diff --git a/posthog/test/test_server_payload_snapshots.py b/posthog/test/test_server_payload_snapshots.py index 2874e0761..71f437edd 100644 --- a/posthog/test/test_server_payload_snapshots.py +++ b/posthog/test/test_server_payload_snapshots.py @@ -29,6 +29,16 @@ def _successful_response() -> requests.Response: return response +def _capture_ok_response(url, data=None, **kwargs) -> requests.Response: + response = requests.Response() + response.status_code = 200 + uuids = [event["uuid"] for event in json.loads(data)["batch"]] + response._content = json.dumps( + {"results": {uuid: {"result": "ok"} for uuid in uuids}} + ).encode() + return response + + def _has_test_file_suffix(value: str) -> bool: return value.replace("\\", "/").endswith(_TEST_FILE_SUFFIX) @@ -43,8 +53,10 @@ def _normalize_snapshot_value(value): for key, item in value.items(): if key == "$lib_version": normalized[key] = "" + elif key == "PostHog-Request-Id": + normalized[key] = "" elif ( - key == "User-Agent" + key in ("User-Agent", "PostHog-Sdk-Info") and isinstance(item, str) and _USER_AGENT_PATTERN.fullmatch(item) ): @@ -99,19 +111,18 @@ def _assert_json_snapshot(name: str, value) -> None: assert actual == expected -def _legacy_event_family_request(): +def _event_family_request(): session = mock.MagicMock() - session.post.return_value = _successful_response() + session.post.side_effect = _capture_ok_response with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.request._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( "phc_snapshot_project", host="https://example.posthog.test", - capture_mode="v0", flush_at=100, flush_interval=100, ) @@ -167,18 +178,17 @@ def _raise_snapshot_exception() -> None: def _exception_request(): session = mock.MagicMock() - session.post.return_value = _successful_response() + session.post.side_effect = _capture_ok_response with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.request._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={}), ): client = Client( "phc_snapshot_project", host="https://example.posthog.test", - capture_mode="v0", sync_mode=True, project_root=str(Path(__file__).parents[2]), capture_exception_code_variables=False, @@ -257,8 +267,8 @@ def test_does_not_normalize_unexpected_user_agent(user_agent): } -def test_legacy_capture_identify_alias_and_group_identify_request_snapshot(): - _assert_json_snapshot("legacy_event_family", _legacy_event_family_request()) +def test_capture_identify_alias_and_group_identify_request_snapshot(): + _assert_json_snapshot("event_family", _event_family_request()) def test_complete_exception_request_snapshot(): diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index e655c8cd5..c2dd1a0c5 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -261,6 +261,32 @@ def test_clean_fn(self): def test_remove_slash(self, input_url, expected_url): assert expected_url == utils.remove_trailing_slash(input_url) + @parameterized.expand( + [ + ("random_ones", b"\xff" * 10, "01234567-89ab-7fff-bfff-ffffffffffff"), + ("random_zeros", b"\x00" * 10, "01234567-89ab-7000-8000-000000000000"), + ( + "random_mixed", + bytes(range(0x10, 0x1A)), + "01234567-89ab-7011-9213-141516171819", + ), + ] + ) + def test_uuid7_layout(self, _name, random_bytes, expected): + # Bit 48 of the millisecond clock is set to check that it is masked off. + time_ns = ((1 << 48) + 0x0123456789AB) * 1_000_000 + 999_999 + with ( + mock.patch.object(utils.time, "time_ns", return_value=time_ns), + mock.patch.object( + utils.os, + "urandom", + side_effect=lambda n: (random_bytes + b"\xaa" * n)[:n], + ), + ): + value = utils._uuid7() + self.assertEqual(str(value), expected) + self.assertEqual(value.version, 7) + def test_clean_pydantic(self): class ModelV2(BaseModel): foo: str diff --git a/posthog/test/tracing/test_client_traces.py b/posthog/test/tracing/test_client_traces.py index ab9d0ee0e..f3b2b48c9 100644 --- a/posthog/test/tracing/test_client_traces.py +++ b/posthog/test/tracing/test_client_traces.py @@ -15,6 +15,7 @@ from posthog.tracing._transport import OK from posthog.tracing._span import NOOP_SPAN, RecordingSpan, Span from posthog.version import VERSION +from posthog.test.capture_helpers import patch_capture_send, sent_batch FAKE_API_KEY = "phc_test_key" @@ -671,10 +672,10 @@ def test_the_span_exit_drain_leaves_sync_mode_events_alone(self): client.start_span("x").end() with mock.patch("posthog.client._atexit_deadline", None): client._atexit_spans() - with mock.patch("posthog.client.batch_post") as batch_post: + with patch_capture_send("client") as batch_post: client.capture("after-exit", distinct_id="d") batch_post.assert_called_once() - assert batch_post.call_args[1]["batch"][0]["event"] == "after-exit" + assert sent_batch(batch_post)[0]["event"] == "after-exit" client.shutdown() @pytest.mark.parametrize("traces", [{}, None]) diff --git a/posthog/tracing/_export.py b/posthog/tracing/_export.py index e68385f78..3a3e4b398 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/posthog/utils.py b/posthog/utils.py index 05608983f..840478411 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -1,6 +1,7 @@ import json import logging import numbers +import os import re import threading import time @@ -51,6 +52,21 @@ def guess_timezone(dt: datetime) -> datetime: return dt.astimezone(timezone.utc) +def _uuid7() -> UUID: + """Return a time-ordered version 7 UUID (RFC 9562). + + Capture generates v7 server-side when a client omits the uuid, so + SDK-generated event uuids sort the same way. + """ + unix_ms = time.time_ns() // 1_000_000 + # Python 3.10 requires the byte order; from 3.11 "big" is the default. + random_bits = int.from_bytes(os.urandom(10), "big") # pragma: no mutate + value = ((unix_ms & ((1 << 48) - 1)) << 80) | random_bits + value = (value & ~(0xF << 76)) | (0x7 << 76) + value = (value & ~(0x3 << 62)) | (0x2 << 62) + return UUID(int=value) + + def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: """Normalize a datetime or parseable ISO datetime string to UTC.""" parsed_timestamp: datetime diff --git a/pyproject.toml b/pyproject.toml index 17052d31f..7ad70ea0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ ] dependencies = [ "requests>=2.7,<3.0", - "backoff>=1.10.0", "distro>=1.5.0", "typing-extensions>=4.2.0", ] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9d..282645df7 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -7,7 +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.CaptureMode -> posthog.capture_mode.CaptureMode +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 @@ -232,17 +232,14 @@ alias posthog.ai.utils.tag -> posthog.tag alias posthog.ai.utils.warn_if_posthog_ai_gateway -> posthog.ai.gateway.warn_if_posthog_ai_gateway alias posthog.args.FeatureFlagEvaluations -> posthog.feature_flag_evaluations.FeatureFlagEvaluations alias posthog.args.SendFeatureFlagsOptions -> posthog.types.SendFeatureFlagsOptions -alias posthog.client.AI_EVENTS_ENDPOINT -> posthog.request.AI_EVENTS_ENDPOINT alias posthog.client.AI_MAX_MSG_SIZE -> posthog.consumer.AI_MAX_MSG_SIZE alias posthog.client.APIError -> posthog.request.APIError alias posthog.client.CaptureCompression -> posthog.capture_compression.CaptureCompression -alias posthog.client.CaptureMode -> posthog.capture_mode.CaptureMode alias posthog.client.Consumer -> posthog.consumer.Consumer alias posthog.client.DEFAULT_CODE_VARIABLES_DETECT_SECRETS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_DETECT_SECRETS alias posthog.client.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS alias posthog.client.DEFAULT_CODE_VARIABLES_MASK_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_PATTERNS alias posthog.client.DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -alias posthog.client.EVENTS_ENDPOINT -> posthog.request.EVENTS_ENDPOINT alias posthog.client.ExceptionArg -> posthog.args.ExceptionArg alias posthog.client.ExceptionCapture -> posthog.exception_capture.ExceptionCapture alias posthog.client.FeatureFlag -> posthog.types.FeatureFlag @@ -273,7 +270,6 @@ alias posthog.client.SendFeatureFlagsOptions -> posthog.types.SendFeatureFlagsOp alias posthog.client.SizeLimitedDict -> posthog.utils.SizeLimitedDict alias posthog.client.Span -> posthog.tracing.span.Span alias posthog.client.VERSION -> posthog.version.VERSION -alias posthog.client.batch_post -> posthog.request.batch_post alias posthog.client.clean -> posthog.utils.clean alias posthog.client.determine_server_host -> posthog.request.determine_server_host alias posthog.client.exc_info_from_error -> posthog.exception_utils.exc_info_from_error @@ -304,12 +300,8 @@ alias posthog.client.to_flags_and_payloads -> posthog.types.to_flags_and_payload alias posthog.client.to_payloads -> posthog.types.to_payloads alias posthog.client.to_values -> posthog.types.to_values alias posthog.client.try_attach_code_variables_to_frames -> posthog.exception_utils.try_attach_code_variables_to_frames -alias posthog.consumer.APIError -> posthog.request.APIError alias posthog.consumer.CaptureCompression -> posthog.capture_compression.CaptureCompression -alias posthog.consumer.CaptureMode -> posthog.capture_mode.CaptureMode alias posthog.consumer.DatetimeSerializer -> posthog.request.DatetimeSerializer -alias posthog.consumer.EVENTS_ENDPOINT -> posthog.request.EVENTS_ENDPOINT -alias posthog.consumer.batch_post -> posthog.request.batch_post alias posthog.contexts.Client -> posthog.client.Client alias posthog.disable_connection_reuse -> posthog.request.disable_connection_reuse alias posthog.enable_keep_alive -> posthog.request.enable_keep_alive @@ -660,9 +652,8 @@ attribute posthog.args.OptionalSetArgs.timestamp: NotRequired[Optional[Union[dat attribute posthog.args.OptionalSetArgs.uuid: NotRequired[Optional[Union[str, UUID]]] attribute posthog.async_client.AsyncClient.api_key = (project_api_key or '').strip() attribute posthog.async_client.AsyncClient.before_send = before_send -attribute posthog.async_client.AsyncClient.capture_compression = _resolve_capture_compression(capture_compression, gzip_fallback=gzip) +attribute posthog.async_client.AsyncClient.capture_compression = _resolve_capture_compression(capture_compression) attribute posthog.async_client.AsyncClient.capture_exception_code_variables = capture_exception_code_variables -attribute posthog.async_client.AsyncClient.capture_mode = _resolve_capture_mode(capture_mode) attribute posthog.async_client.AsyncClient.capture_trace_context = capture_trace_context attribute posthog.async_client.AsyncClient.code_variables_detect_secrets = code_variables_detect_secrets if code_variables_detect_secrets is not None else DEFAULT_CODE_VARIABLES_DETECT_SECRETS attribute posthog.async_client.AsyncClient.code_variables_ignore_patterns = code_variables_ignore_patterns if code_variables_ignore_patterns is not None else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -674,7 +665,6 @@ attribute posthog.async_client.AsyncClient.disabled = disabled or not self.api_k attribute posthog.async_client.AsyncClient.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) attribute posthog.async_client.AsyncClient.feature_flags_request_max_retries = max(0, feature_flags_request_max_retries) attribute posthog.async_client.AsyncClient.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds -attribute posthog.async_client.AsyncClient.gzip = gzip attribute posthog.async_client.AsyncClient.historical_migration = historical_migration attribute posthog.async_client.AsyncClient.host = determine_server_host(host) attribute posthog.async_client.AsyncClient.in_app_modules = in_app_modules @@ -700,18 +690,19 @@ 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_mode.CAPTURE_MODE_ENV_VAR = 'POSTHOG_CAPTURE_MODE' -attribute posthog.capture_mode.CaptureMode.V0 = 'v0' -attribute posthog.capture_mode.CaptureMode.V1 = 'v1' +attribute posthog.capture_send.CaptureError.attempts = attempts +attribute posthog.capture_send.CaptureError.drops = drops or [] +attribute posthog.capture_send.CaptureError.endpoint = endpoint +attribute posthog.capture_send.CaptureError.event_results = event_results or {} +attribute posthog.capture_send.CaptureError.request_id = request_id +attribute posthog.capture_send.CaptureError.retry_exhausted = retry_exhausted or [] +attribute posthog.capture_send.CaptureEventResult.details: Optional[str] = None +attribute posthog.capture_send.CaptureEventResult.result: Optional[str] 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, gzip_fallback=gzip) +attribute posthog.client.Client.capture_ai_compression = _resolve_capture_ai_compression(capture_ai_compression) +attribute posthog.client.Client.capture_compression = _resolve_capture_compression(capture_compression) attribute posthog.client.Client.capture_exception_code_variables = capture_exception_code_variables -attribute posthog.client.Client.capture_mode = _resolve_capture_mode(capture_mode) attribute posthog.client.Client.capture_trace_context = capture_trace_context attribute posthog.client.Client.code_variables_detect_secrets = code_variables_detect_secrets if code_variables_detect_secrets is not None else DEFAULT_CODE_VARIABLES_DETECT_SECRETS attribute posthog.client.Client.code_variables_ignore_patterns = code_variables_ignore_patterns if code_variables_ignore_patterns is not None else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -739,7 +730,6 @@ attribute posthog.client.Client.flag_cache = self._initialize_flag_cache(flag_fa attribute posthog.client.Client.flag_definition_version = 0 attribute posthog.client.Client.flag_fallback_cache_url = flag_fallback_cache_url attribute posthog.client.Client.group_type_mapping: Optional[dict[str, str]] = None -attribute posthog.client.Client.gzip = gzip attribute posthog.client.Client.historical_migration = historical_migration attribute posthog.client.Client.host = determine_server_host(host) attribute posthog.client.Client.in_app_modules = in_app_modules @@ -766,16 +756,16 @@ attribute posthog.code_variables_detect_secrets = DEFAULT_CODE_VARIABLES_DETECT_ attribute posthog.code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS attribute posthog.code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS attribute posthog.code_variables_mask_url_credentials = DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -attribute posthog.consumer.AI_MAX_MSG_SIZE = 8 * 1024 * 1024 +attribute posthog.consumer.AI_ENVELOPE_HEADROOM = 64 * 1024 +attribute posthog.consumer.AI_MAX_MSG_SIZE = AI_MAX_PROPERTIES_SIZE + AI_ENVELOPE_HEADROOM +attribute posthog.consumer.AI_MAX_PROPERTIES_SIZE = 8 * 1024 * 1024 attribute posthog.consumer.BATCH_SIZE_LIMIT = 5 * 1024 * 1024 attribute posthog.consumer.Consumer.api_key = api_key attribute posthog.consumer.Consumer.capture_compression = capture_compression -attribute posthog.consumer.Consumer.capture_mode = capture_mode attribute posthog.consumer.Consumer.daemon = True attribute posthog.consumer.Consumer.endpoint = endpoint attribute posthog.consumer.Consumer.flush_at = flush_at attribute posthog.consumer.Consumer.flush_interval = flush_interval -attribute posthog.consumer.Consumer.gzip = gzip attribute posthog.consumer.Consumer.historical_migration = historical_migration attribute posthog.consumer.Consumer.host = host attribute posthog.consumer.Consumer.log = logging.getLogger('posthog') @@ -1005,13 +995,11 @@ attribute posthog.privacy_mode = False attribute posthog.project_api_key = None attribute posthog.project_root = None attribute posthog.release_id.RELEASE_ID_ENV_VAR = 'POSTHOG_RELEASE_ID' -attribute posthog.request.AI_EVENTS_ENDPOINT = '/i/v0/ai/batch/' attribute posthog.request.APIError.message = message attribute posthog.request.APIError.retry_after = retry_after attribute posthog.request.APIError.status = status attribute posthog.request.DEFAULT_HOST = US_INGESTION_ENDPOINT attribute posthog.request.EU_INGESTION_ENDPOINT = 'https://eu.i.posthog.com' -attribute posthog.request.EVENTS_ENDPOINT = '/batch/' attribute posthog.request.GetResponse.data: Any attribute posthog.request.GetResponse.etag: Optional[str] = None attribute posthog.request.GetResponse.not_modified: bool = False @@ -1179,14 +1167,14 @@ class posthog.ai.types.TokenUsage class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs -class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = 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: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) +class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = 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: Optional[list[str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) 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_mode.CaptureMode -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.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, gzip=False, 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_mode: Optional[Union[CaptureMode, str]] = None, 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, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) +class posthog.capture_send.CaptureError(status: int | str, message: str, *, endpoint: 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, event_results: Optional[dict[str, CaptureEventResult]] = None) +class posthog.capture_send.CaptureEventResult(result: Optional[str], details: 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, capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_max_queue_size: int = 1000, capture_ai_timeout: float = 30, capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, 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=3, 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) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) @@ -1419,14 +1407,13 @@ function posthog.mcp.session_token.encode_session_id(payload: SessionTokenPayloa function posthog.mcp.session_token.read_mcp_session_header(headers: Any) -> Optional[str] function posthog.mcp.tools.get_more_tools_result() -> Dict[str, Any] function posthog.new_context(fresh: bool = False, capture_exceptions: Optional[bool] = None, client: Optional[Client] = None) -function posthog.request.batch_post(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, path: str = EVENTS_ENDPOINT, **kwargs) -> requests.Response function posthog.request.determine_server_host(host: Optional[str]) -> str function posthog.request.disable_connection_reuse() -> None function posthog.request.enable_keep_alive() -> None -function posthog.request.flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, max_retries: int = 1, **kwargs) -> Any +function posthog.request.flags(api_key: str, host: Optional[str] = None, timeout: int = 15, max_retries: int = 1, **kwargs) -> Any function posthog.request.get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None, etag: Optional[str] = None) -> GetResponse function posthog.request.normalize_host(host: Optional[str]) -> str -function posthog.request.post(api_key: str, host: Optional[str] = None, path: Optional[str] = None, gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs) -> requests.Response +function posthog.request.post(api_key: str, host: Optional[str] = None, path: Optional[str] = None, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs) -> requests.Response function posthog.request.remote_config(personal_api_key: str, project_api_key: str, host: Optional[str] = None, key: str = '', timeout: int = 15) -> Any function posthog.request.reset_sessions() -> None function posthog.request.set_socket_options(socket_options: Optional[SocketOptions]) -> None @@ -1580,6 +1567,7 @@ method posthog.async_client.AsyncClient.set_once(**kwargs: Unpack[OptionalSetArg method posthog.async_client.AsyncClient.shutdown() -> None method posthog.bucketed_rate_limiter.BucketedRateLimiter.consume_rate_limit(key: Hashable) -> bool method posthog.bucketed_rate_limiter.BucketedRateLimiter.stop() -> None +method posthog.capture_send.CaptureError.verdict_summary() -> str method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] method posthog.client.Client.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1763,8 +1751,8 @@ module posthog.args module posthog.async_client module posthog.bucketed_rate_limiter module posthog.capture_compression -module posthog.capture_mode -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/Dockerfile.v1 b/sdk_compliance_adapter/Dockerfile.v1 deleted file mode 100644 index 6891837a5..000000000 --- a/sdk_compliance_adapter/Dockerfile.v1 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -# Copy the SDK source code -COPY posthog/ /app/sdk/posthog/ -COPY setup.py pyproject.toml README.md LICENSE /app/sdk/ - -# Install the SDK from source -RUN cd /app/sdk && pip install --no-cache-dir -e . - -# Install adapter dependencies -RUN pip install --no-cache-dir flask python-dateutil - -# Copy adapter code -COPY sdk_compliance_adapter/adapter.py /app/adapter.py - -# Select the capture-v1 protocol; same adapter code, different runtime mode. -ENV CAPTURE_MODE=v1 - -# Expose port 8080 -EXPOSE 8080 - -# Run the adapter -CMD ["python", "/app/adapter.py"] diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index eae25916b..6b17bf5dd 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -30,7 +30,7 @@ The adapter implements the standard SDK adapter interface defined in the [test h ### Key Implementation Details -**Request Tracking**: The adapter monkey-patches `batch_post` to track all HTTP requests made by the SDK, including retries. +**Request Tracking**: The adapter monkey-patches the capture v1 `_post_v1` to track all HTTP requests made by the SDK, including retries. **State Management**: Thread-safe state tracking for events captured vs sent, retry attempts, and errors. diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 0d803ae52..be2e1905b 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -15,9 +15,9 @@ from posthog import Client from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _post_v1 as original_post_v1 -from posthog.request import EVENTS_ENDPOINT, USER_AGENT -from posthog.request import batch_post as original_batch_post +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 # Configure logging @@ -28,16 +28,6 @@ app = Flask(__name__) -# Selects which capture protocol this adapter process speaks. Baked at build -# time via the CAPTURE_MODE env var ("v1" => capture-v1, anything else => legacy -# v0), mirroring the v0/v1 Dockerfile split. One process speaks one mode and -# advertises it via /health capabilities. -CAPTURE_MODE = os.environ.get("CAPTURE_MODE", "") - - -def is_v1() -> bool: - return CAPTURE_MODE == "v1" - class RequestInfo: """Information about an HTTP request made by the SDK""" @@ -80,7 +70,6 @@ def __init__(self): self.client: Optional[Client] = None self.remote_client: Client | None = None self.reload_thread: threading.Thread | None = None - self.retry_attempts: Dict[str, int] = {} # Track retry attempts by batch ID def reset(self): """Reset all state""" @@ -106,7 +95,6 @@ def reset(self): self.total_retries = 0 self.last_error = None self.requests_made = [] - self.retry_attempts = {} def increment_captured(self): """Increment total events captured""" @@ -114,37 +102,6 @@ def increment_captured(self): self.total_events_captured += 1 self.pending_events += 1 - def record_request(self, status_code: int, batch: List[Dict], batch_id: str): - """Record an HTTP request made by the SDK""" - with self.lock: - # Determine retry attempt for this batch - retry_attempt = self.retry_attempts.get(batch_id, 0) - - # Extract UUIDs from batch - uuid_list = [event.get("uuid", "") for event in batch] - - request_info = RequestInfo( - timestamp_ms=int(time.time() * 1000), - status_code=status_code, - retry_attempt=retry_attempt, - event_count=len(batch), - uuid_list=uuid_list, - ) - self.requests_made.append(request_info) - - # Update counters - if status_code == 200: - # Success - clear pending events - self.total_events_sent += len(batch) - self.pending_events = max(0, self.pending_events - len(batch)) - # Remove batch from retry tracking - self.retry_attempts.pop(batch_id, None) - else: - # Failure - increment retry count - self.retry_attempts[batch_id] = retry_attempt + 1 - if retry_attempt > 0: - self.total_retries += 1 - def record_request_v1( self, status_code: int, batch: List[Dict], attempt: int, terminal_count: int ): @@ -194,40 +151,6 @@ def get_state(self) -> Dict[str, Any]: state = SDKState() -def create_batch_id(batch: List[Dict]) -> str: - """Create a unique ID for a batch based on UUIDs""" - uuids = sorted([event.get("uuid", "") for event in batch]) - return "-".join(uuids[:3]) # Use first 3 UUIDs as batch ID - - -def patched_batch_post( - api_key: str, - host: Optional[str] = None, - gzip: bool = False, - timeout: int = 15, - path: str = EVENTS_ENDPOINT, - **kwargs, -): - """Patched version of batch_post that tracks requests""" - batch = kwargs.get("batch", []) - batch_id = create_batch_id(batch) - - try: - # Call original batch_post - response = original_batch_post(api_key, host, gzip, timeout, path, **kwargs) - # Record successful request - state.record_request(200, batch, batch_id) - return response - except Exception as e: - # Record failed request - status_code = ( - getattr(e, "status_code", 500) if hasattr(e, "status_code") else 500 - ) - state.record_request(status_code, batch, batch_id) - state.record_error(str(e)) - raise - - def patched_post_v1( api_key: str, host: Optional[str], @@ -239,11 +162,12 @@ def patched_post_v1( timeout: int = 15, sdk_info: str = USER_AGENT, session: Any = None, + path: str = _CAPTURE_V1_PATH, ): """Patched version of _post_v1 that records requests for /state assertions. - Mirrors the legacy `patched_batch_post`, but reads the retry attempt from the - call (1-based) and counts only terminal per-event results as sent. + Reads the retry attempt from the call (1-based) and counts only terminal + per-event results as sent. """ batch = batch_body.get("batch", []) try: @@ -257,6 +181,7 @@ def patched_post_v1( timeout=timeout, sdk_info=sdk_info, session=session, + path=path, ) except Exception as e: status_code = getattr(e, "status", 0) @@ -284,33 +209,24 @@ def patched_post_v1( return response -# Monkey-patch the batch_post function -import posthog.request # noqa: E402 - -posthog.request.batch_post = patched_batch_post - -# Also patch in consumer module -import posthog.consumer # noqa: E402 - -posthog.consumer.batch_post = patched_batch_post - # 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"]) def health(): """Health check endpoint""" - capabilities = ( - ["capture_v1", "capture_ai_v0", "encoding_gzip"] - if is_v1() - else ["capture_v0", "capture_ai_v0", "encoding_gzip"] - ) - capabilities.append("feature_flags_local_evaluation_v1") + # No AI capture capability: `capture_ai` posts capture v1 to + # /i/v1/ai/events, which this harness version has no suite for. + capabilities = [ + "capture_v1", + "encoding_gzip", + "feature_flags_local_evaluation_v1", + ] return jsonify( { "sdk_name": "posthog-python", @@ -351,9 +267,6 @@ def init(): # Convert flush_interval from ms to seconds flush_interval = flush_interval_ms / 1000.0 - # One adapter process speaks one capture protocol, selected by CAPTURE_MODE. - capture_mode = "v1" if is_v1() else "v0" - # Explicit reloads exercise the real loader without background polling # racing the harness's per-test definition snapshots. client_options = { @@ -361,12 +274,15 @@ def init(): "host": host, "flush_at": flush_at, "flush_interval": flush_interval, - "gzip": enable_compression, + "capture_compression": ( + CaptureCompression.GZIP + if enable_compression + else CaptureCompression.NONE + ), "max_retries": max_retries, "debug": False, "disable_geoip": disable_geoip, "historical_migration": historical_migration, - "capture_mode": capture_mode, "enable_local_evaluation": False, } personal_api_key = data.get("personal_api_key") @@ -381,8 +297,8 @@ def init(): logger.info( f"Initialized SDK with api_key={api_key[:10]}..., host={host}, " f"flush_at={flush_at}, flush_interval={flush_interval}, " - f"max_retries={max_retries}, gzip={enable_compression}, " - f"capture_mode={capture_mode}, disable_geoip={disable_geoip}, " + f"max_retries={max_retries}, compression={enable_compression}, " + f"disable_geoip={disable_geoip}, " f"historical_migration={historical_migration}" ) @@ -414,9 +330,8 @@ def capture(): # Fold capture-v1 options back into the magic `$`-prefixed properties the # SDK lifts onto the wire `options` object. Renamed keys mirror the SDK's - # sentinel table; unknown keys get a bare `$` prefix. v0 has no wire - # options object, so this only applies in v1 mode. - if options and is_v1(): + # sentinel table; unknown keys get a bare `$` prefix. + if options: properties = dict(properties or {}) option_to_property = { "cookieless_mode": "$cookieless_mode", @@ -470,7 +385,7 @@ def capture_ai(): if not event: return jsonify({"error": "event is required"}), 400 - if options and is_v1(): + if options: properties = dict(properties or {}) option_to_property = { "cookieless_mode": "$cookieless_mode", diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index c679f5c4f..56ef72680 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.8" services: - # PostHog Python SDK adapter (capture v0) + # PostHog Python SDK adapter sdk-adapter: build: context: .. @@ -11,16 +11,6 @@ services: networks: - test-network - # PostHog Python SDK adapter (capture v1) - sdk-adapter-v1: - build: - context: .. - dockerfile: sdk_compliance_adapter/Dockerfile.v1 - ports: - - "8082:8080" - networks: - - test-network - # Test harness test-harness: image: ghcr.io/posthog/sdk-test-harness:1.1.1 diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 8acc78386..1e06c4628 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,9 +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.request, "batch_post"), - (posthog.consumer, "batch_post"), - (posthog.capture_v1, "_post_v1"), + (posthog.capture_send, "_post_v1"), ]: monkeypatch.setattr(module, name, getattr(module, name)) spec = importlib.util.spec_from_file_location( @@ -79,15 +77,12 @@ def initialize(adapter, **overrides): return adapter.app.test_client() -@pytest.mark.parametrize("mode,capability", [("", "capture_v0"), ("v1", "capture_v1")]) -def test_health_opts_into_local_evaluation_without_losing_capture( - adapter, monkeypatch, mode, capability -): - monkeypatch.setattr(adapter, "CAPTURE_MODE", mode) +def test_health_opts_into_local_evaluation_without_losing_capture(adapter): capabilities = adapter.app.test_client().get("/health").json["capabilities"] assert "feature_flags_local_evaluation_v1" in capabilities - assert capability in capabilities - assert "capture_ai_v0" in capabilities + assert "capture_v1" in capabilities + assert "capture_v0" not in capabilities + assert "capture_ai_v0" not in capabilities def test_init_enables_explicit_definitions_loading_without_polling(adapter): diff --git a/uv.lock b/uv.lock index 60046d858..0a2391359 100644 --- a/uv.lock +++ b/uv.lock @@ -319,15 +319,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -2773,7 +2764,6 @@ name = "posthog" version = "7.64.1" source = { editable = "." } dependencies = [ - { name = "backoff" }, { name = "distro" }, { name = "requests" }, { name = "typing-extensions" }, @@ -2852,7 +2842,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, - { name = "backoff", specifier = ">=1.10.0" }, { name = "claude-agent-sdk", marker = "extra == 'test'" }, { name = "coverage", marker = "extra == 'test'" }, { name = "distro", specifier = ">=1.5.0" },