diff --git a/AGENTS.md b/AGENTS.md index 745e91ad..e05e0a25 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`. +Preserve capture v1 as the default (v0 is opt-in for analytics only; `capture_ai` always posts v1 to `/i/v1/ai/events`); 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`. ## Mirror and build safety diff --git a/posthog/__init__.py b/posthog/__init__.py index 5d6ae119..ccbc8db5 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -425,7 +425,7 @@ def get_tags() -> Dict[str, Any]: 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. +# then CaptureMode.V1. 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. diff --git a/posthog/capture_mode.py b/posthog/capture_mode.py index 7ea3696f..5f72e370 100644 --- a/posthog/capture_mode.py +++ b/posthog/capture_mode.py @@ -13,10 +13,9 @@ 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 + ``V1`` is ``POST /i/v1/analytics/events`` (Bearer auth, per-event results, + partial retry) and the default. ``V0`` opts back into the legacy + ``POST /batch/`` endpoint. Inheriting from ``str`` keeps the members directly comparable to and serializable as their ``"v0"`` / ``"v1"`` values. """ @@ -61,15 +60,15 @@ def _resolve_capture_mode( """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. + var > ``CaptureMode.V1``. An unrecognized env value logs a warning and falls + back to ``V1`` 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 + return CaptureMode.V1 resolved = _ALIASES.get(raw.strip().lower()) if resolved is None: @@ -77,8 +76,8 @@ def _resolve_capture_mode( "Unrecognized %s=%r; falling back to %s. Expected one of %s.", CAPTURE_MODE_ENV_VAR, raw, - CaptureMode.V0.value, + CaptureMode.V1.value, sorted(_ALIASES), ) - return CaptureMode.V0 + return CaptureMode.V1 return resolved diff --git a/posthog/capture_v1.py b/posthog/capture_v1.py index 47817548..438aaf01 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_v1.py @@ -1,9 +1,10 @@ """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). +This module owns everything specific to the capture v1 endpoints +(``POST /i/v1/analytics/events`` and ``POST /i/v1/ai/events``, which share one +wire contract): the *transform* layer (legacy-shaped queued message -> v1 wire +event + batch envelope) and the *transport* layer (a single HTTP attempt, +response parsing, and the partial-retry send loop). The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from the legacy ``/batch/`` shape in a few load-bearing ways that this module @@ -66,6 +67,7 @@ __all__ = ["CaptureV1Error"] _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. @@ -350,8 +352,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 +364,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", @@ -473,8 +476,9 @@ 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 @@ -525,6 +529,7 @@ 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. diff --git a/posthog/client.py b/posthog/client.py index 2bb16c49..d2d705d5 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -32,7 +32,11 @@ _resolve_capture_compression, ) from posthog.capture_mode import CaptureMode, _resolve_capture_mode -from posthog.capture_v1 import _send_v1_batch +from posthog.capture_v1 import ( + _CAPTURE_AI_V1_PATH, + _CAPTURE_V1_PATH, + _send_v1_batch, +) from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE, Consumer, _DrainSignal from posthog.contexts import ( _get_current_context, @@ -85,7 +89,6 @@ 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, @@ -841,10 +844,10 @@ def __init__( 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``. + ``CaptureMode.V1`` (``/i/v1/analytics/events``). Set + ``CaptureMode.V0`` (or pass the string ``"v0"``) to opt back + into the legacy ``/batch/`` endpoint. When omitted, the + ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V1``. 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 @@ -1085,24 +1088,27 @@ def __init__( self._analytics_lane = _Lane( name="analytics", **lane_defaults, - endpoint=EVENTS_ENDPOINT, + endpoint=( + _CAPTURE_V1_PATH + if self.capture_mode == CaptureMode.V1 + else EVENTS_ENDPOINT + ), 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. + # The AI lane always posts capture v1 to the AI endpoint, whatever the + # analytics `capture_mode`, so multi-MB AI events stay off the + # analytics endpoint's smaller caps. It sends uncompressed. Lazy start, + # so the many clients that never emit AI events pay for no extra + # threads. self._ai_lane = _Lane( name="ai", **lane_defaults, - endpoint=AI_EVENTS_ENDPOINT, + endpoint=_CAPTURE_AI_V1_PATH, max_msg_size=AI_MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, + capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.NONE, eager_start=False, ) @@ -2438,19 +2444,19 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): 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`. + # Sync mode bypasses the lane's queue but keeps its wire config, + # so AI events post to the AI endpoint whatever `capture_mode`. if lane.capture_mode == CaptureMode.V1: _send_v1_batch( self.api_key, self.host, [msg], - compression=self.capture_compression, + compression=lane.capture_compression, timeout=self.timeout, max_retries=self.max_retries, historical_migration=self.historical_migration, sdk_info=self._sdk_info, + path=lane.endpoint, ) return diff --git a/posthog/consumer.py b/posthog/consumer.py index c604b975..8a69844c 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -7,7 +7,7 @@ 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_v1 import _CAPTURE_V1_PATH, _backoff, _send_v1_batch from posthog.request import ( EVENTS_ENDPOINT, USER_AGENT as _USER_AGENT, @@ -113,9 +113,9 @@ def __init__( retries=10, timeout=15, historical_migration=False, - endpoint=EVENTS_ENDPOINT, + endpoint=None, max_msg_size=MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, + capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.NONE, ): """Create a consumer thread.""" @@ -129,6 +129,12 @@ def __init__( self.on_error = on_error self.queue = queue self.gzip = gzip + # Without an explicit endpoint, post to the analytics path of the + # selected protocol. + if endpoint is None: + endpoint = ( + _CAPTURE_V1_PATH if capture_mode == CaptureMode.V1 else EVENTS_ENDPOINT + ) self.endpoint = endpoint self.max_msg_size = max_msg_size self.capture_mode = capture_mode @@ -288,10 +294,10 @@ def next(self): return items def request(self, batch): - """Upload the batch via the wire protocol selected by `capture_mode`. + """Upload the batch to this consumer's `endpoint` via the wire protocol + selected by `capture_mode`. - V1 uses the partial-retry submitter (which posts to its own path); V0 - posts the batch to this consumer's `endpoint`. + V1 uses the partial-retry submitter; V0 posts the whole batch. """ if self.capture_mode == CaptureMode.V1: _send_v1_batch( @@ -303,6 +309,7 @@ def request(self, batch): max_retries=self.retries, historical_migration=self.historical_migration, sdk_info=self._sdk_info, + path=self.endpoint, ) return self._send(batch, self.endpoint) diff --git a/posthog/test/capture_helpers.py b/posthog/test/capture_helpers.py new file mode 100644 index 00000000..542d3e10 --- /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_v1``. Wire shape is +covered by ``test_capture_v1``. +""" + +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/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index 3ab2f9f8..91b8c725 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -6,18 +6,21 @@ import posthog from posthog.ai.utils import _capture_ai_event, finalize_ai_content, with_privacy_mode +from posthog.capture_compression import CaptureCompression from posthog.capture_mode import CaptureMode 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.capture_v1 import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH +from posthog.request import EVENTS_ENDPOINT 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 +32,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 +58,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 +74,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) @@ -122,7 +125,7 @@ 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) @@ -136,15 +139,15 @@ def test_analytics_consumers_keep_todays_parameters(self): 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 +155,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): @@ -192,48 +195,68 @@ 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 TestAiLaneAlwaysV1(unittest.TestCase): + """The AI lane posts capture v1 to the AI endpoint whatever `capture_mode`.""" - def test_ai_lane_consumers_pin_v0_and_ai_endpoint(self): - client = Client(TEST_API_KEY, send=False, capture_mode="v1", thread=2) + def test_ai_lane_consumers_use_v1_and_ai_endpoint(self): + client = Client(TEST_API_KEY, send=False, capture_mode="v0", thread=2) client._ai_lane.start() 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.endpoint, _CAPTURE_AI_V1_PATH) self.assertEqual(consumer.max_msg_size, AI_MAX_MSG_SIZE) - self.assertEqual(consumer.capture_mode, CaptureMode.V0) + self.assertEqual(consumer.capture_mode, CaptureMode.V1) + self.assertEqual(consumer.capture_compression, CaptureCompression.NONE) - 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) + def test_async_ai_events_use_v1_with_capture_mode_v0(self): + client = Client( + TEST_API_KEY, + capture_mode="v0", + capture_compression="gzip", + flush_interval=0.05, + ) with ( mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, + patch_capture_send("consumer") as mock_v1, ): client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") client.flush() - mock_v1.assert_called() self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [AI_EVENTS_ENDPOINT], + [EVENTS_ENDPOINT], + ) + mock_v1.assert_called_once() + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + self.assertEqual( + mock_v1.call_args.kwargs["compression"], CaptureCompression.NONE ) + self.assertEqual([e["event"] for e in sent_batch(mock_v1)], ["$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") + def test_sync_ai_events_use_v1_with_capture_mode_v0(self): + client = Client( + TEST_API_KEY, + sync_mode=True, + capture_mode="v0", + capture_compression="gzip", + ) with ( mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, + patch_capture_send("client") as mock_v1, ): 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) + self.assertEqual(mock_post.call_args.kwargs["path"], EVENTS_ENDPOINT) mock_v1.assert_called_once() + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + self.assertEqual( + mock_v1.call_args.kwargs["compression"], CaptureCompression.NONE + ) class TestAiLaneLazyStart(unittest.TestCase): @@ -241,12 +264,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 +285,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 +308,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 +328,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 +355,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 +366,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 +460,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 +522,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 +593,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 +621,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 +635,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 fcb6e229..9c100bc7 100644 --- a/posthog/test/test_async_client.py +++ b/posthog/test/test_async_client.py @@ -21,6 +21,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,10 +46,10 @@ 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", @@ -76,16 +77,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 +162,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 +185,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 +217,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 +231,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 +239,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 +269,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 +287,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 @@ -329,7 +325,7 @@ async def test_missing_async_extra_does_not_accept_undeliverable_events(): "posthog.async_client._require_httpx", side_effect=RuntimeError("install posthog[async]"), ): - client = AsyncPosthog("test-key") + client = AsyncPosthog("test-key", capture_mode=CaptureMode.V0) assert client.capture("event", distinct_id="user-1") is None assert ( client.set(distinct_id="user-1", properties={"email": "a@example.com"}) @@ -382,10 +378,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 +405,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 +436,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 +465,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 +494,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 +505,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 +520,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 +557,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 +567,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 +581,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") @@ -618,7 +612,7 @@ async def test_reuses_and_closes_instance_owned_http_client(): "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() ) as batch_post, ): - client = AsyncPosthog("test-key") + client = AsyncPosthog("test-key", capture_mode=CaptureMode.V0) await client.capture_immediate("first", distinct_id="user-1") await client.capture_immediate("second", distinct_id="user-1") await client.shutdown() @@ -636,13 +630,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 +691,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 +712,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) @@ -738,9 +729,7 @@ async def test_failed_capture_does_not_log_server_response_detail(caplog, immedi 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 +748,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_feature_flags.py b/posthog/test/test_async_feature_flags.py index 75c8b207..da35bf17 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_before_send.py b/posthog/test/test_before_send.py index ee048290..3d7441d9 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_mode.py b/posthog/test/test_capture_mode.py index 473e1a88..e63705d4 100644 --- a/posthog/test/test_capture_mode.py +++ b/posthog/test/test_capture_mode.py @@ -16,10 +16,10 @@ class TestResolveCaptureMode(unittest.TestCase): - def test_defaults_to_v0_with_no_kwarg_and_no_env(self) -> None: + def test_defaults_to_v1_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) + self.assertIs(_resolve_capture_mode(None), CaptureMode.V1) @parameterized.expand( [ @@ -62,14 +62,14 @@ def test_env_var_resolution(self, _name, env_value, expected) -> None: self.assertIs(_resolve_capture_mode(None), expected) @parameterized.expand([("empty", ""), ("whitespace", " ")]) - def test_blank_env_var_defaults_to_v0(self, _name, env_value) -> None: + def test_blank_env_var_defaults_to_v1(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) + self.assertIs(_resolve_capture_mode(None), CaptureMode.V1) - def test_unrecognized_env_var_warns_and_defaults_to_v0(self) -> None: + def test_unrecognized_env_var_warns_and_defaults_to_v1(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.assertIs(_resolve_capture_mode(None), CaptureMode.V1) self.assertIn("bogus", stream.getvalue()) @parameterized.expand([("bad_str", "bogus"), ("wrong_type", 1)]) @@ -79,11 +79,11 @@ def test_invalid_explicit_kwarg_raises(self, _name, value) -> None: class TestCaptureModePlumbing(unittest.TestCase): - def test_client_resolves_and_stores_default_v0(self) -> None: + def test_client_resolves_and_stores_default_v1(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) + self.assertIs(client.capture_mode, CaptureMode.V1) @parameterized.expand( [ @@ -99,11 +99,11 @@ def test_client_kwarg_sets_mode(self, _name, kwarg, expected) -> None: 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) + client = Client(TEST_API_KEY, capture_mode=CaptureMode.V0, send=False, thread=2) self.assertEqual(len(client.consumers), 2) for consumer in client.consumers: - self.assertIs(consumer.capture_mode, CaptureMode.V1) + self.assertIs(consumer.capture_mode, CaptureMode.V0) - def test_consumer_defaults_to_v0(self) -> None: + def test_consumer_defaults_to_v1(self) -> None: consumer = Consumer(None, TEST_API_KEY) - self.assertIs(consumer.capture_mode, CaptureMode.V0) + self.assertIs(consumer.capture_mode, CaptureMode.V1) diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_v1.py index f4809d50..e11e74c7 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_v1.py @@ -10,6 +10,7 @@ from posthog.capture_compression import CaptureCompression from posthog.capture_v1 import ( + _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, _HEADER_ATTEMPT, _HEADER_REQUEST_ID, @@ -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, @@ -410,6 +413,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"] @@ -585,13 +590,15 @@ def test_request_id_and_created_at_stable_attempt_increments(self) -> None: 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 diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 531bbf1c..faef807c 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_v1 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,7 +467,7 @@ 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( @@ -478,7 +481,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( @@ -488,7 +491,7 @@ def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid( self.assertIsNotNone(msg_uuid) UUID(msg_uuid) 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 +512,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 +524,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 +537,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 +547,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 +559,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 +614,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 +688,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 +697,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 +715,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 +859,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 +874,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 +982,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 +999,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 +1020,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 +1034,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 +1152,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 +1172,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 +1202,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 +1217,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 +1286,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 +1307,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 +1346,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 +1361,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 +1424,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 +1443,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 +1506,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 +1525,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 +1552,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 +1594,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 +1607,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 +1641,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 +1653,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 +1679,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 +1691,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 +1721,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 +1730,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 +1740,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 +1760,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 +1773,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 +1783,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 +1795,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 +1815,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 +1825,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 +1833,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 +1849,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 +1858,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 +1868,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 +1877,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 +1890,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 +1902,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 +1912,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 +1921,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 +1934,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 +1946,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 +1954,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 +1974,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 +1983,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 +2004,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 +2018,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 +2037,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 +2052,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 +2079,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 +2095,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 +2105,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 +2124,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 +2136,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 +2146,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 +2193,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 +2206,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 +2220,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 +2235,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 +2245,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 +2262,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 +2342,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 +2354,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 +2409,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 +2424,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) @@ -2712,9 +2715,7 @@ def test_sync_send_failure_does_not_invoke_async_on_error(self): on_error=on_error, ) - with mock.patch( - "posthog.client.batch_post", side_effect=Exception("upload failed") - ): + with patch_capture_send("client", side_effect=Exception("upload failed")): result = client.capture("event", distinct_id="distinct_id") self.assertIsNone(result) @@ -3401,7 +3402,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 +3425,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") @@ -3516,14 +3517,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 +3575,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 +3589,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 +3599,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 +3611,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 +3642,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 +3666,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 +4118,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 +4133,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 +4142,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 +4170,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 +4181,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 +4196,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 +4212,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 +4229,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 +4240,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 +4258,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 +4461,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 +4489,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 +4497,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 +4548,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 +4604,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"]) @@ -4720,8 +4719,9 @@ def _client(self, **kwargs): @parameterized.expand( [ - ("v0", None, False), + ("default", None, True), ("v1", "v1", True), + ("v0", "v0", False), ] ) def test_capture_mode_selects_sync_submitter(self, _name, capture_mode, expects_v1): @@ -4734,9 +4734,10 @@ def test_capture_mode_selects_sync_submitter(self, _name, capture_mode, expects_ 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") + 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) else: mock_v1.assert_not_called() mock_post.assert_called_once() @@ -4780,6 +4781,6 @@ def test_v1_sync_ai_named_event_through_capture_uses_v1(self): 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") diff --git a/posthog/test/test_code_variables.py b/posthog/test/test_code_variables.py index 6263b4b8..21d52746 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 c2c54cf5..1199d2da 100644 --- a/posthog/test/test_consumer.py +++ b/posthog/test/test_consumer.py @@ -16,8 +16,10 @@ from posthog.capture_compression import CaptureCompression from posthog.capture_mode import CaptureMode +from posthog.capture_v1 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 @@ -212,11 +214,11 @@ def test_upload(self) -> None: 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()) @@ -307,12 +309,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 = [ @@ -328,7 +330,7 @@ def record_batch(*args, **kwargs): self.assertFalse(consumer.is_alive()) def test_request(self) -> None: - consumer = Consumer(None, TEST_API_KEY) + consumer = Consumer(None, TEST_API_KEY, capture_mode=CaptureMode.V0) batch = [_track_event()] with mock.patch("posthog.consumer.batch_post") as post: consumer.request(batch) @@ -357,7 +359,9 @@ def mock_post(*args: Any, **kwargs: Any) -> None: if call_count[0] <= exception_count: raise exception - consumer = Consumer(None, TEST_API_KEY, retries=retries) + consumer = Consumer( + None, TEST_API_KEY, retries=retries, capture_mode=CaptureMode.V0 + ) batch = [_track_event()] with ( mock.patch("posthog.consumer.batch_post", side_effect=mock_post) as post, @@ -400,7 +404,7 @@ def test_request_fails_when_exceptions_exceed_retries(self) -> None: ) def test_negative_retries_still_attempts_delivery_once(self) -> None: - consumer = Consumer(None, TEST_API_KEY, retries=-1) + consumer = Consumer(None, TEST_API_KEY, retries=-1, capture_mode=CaptureMode.V0) with mock.patch("posthog.consumer.batch_post") as mock_post: consumer.request([_track_event()]) @@ -602,7 +606,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): @@ -629,7 +633,7 @@ def mock_post(*args: Any, **kwargs: Any) -> None: if call_count[0] <= 1: raise error - consumer = Consumer(None, TEST_API_KEY, retries=3) + consumer = Consumer(None, TEST_API_KEY, retries=3, capture_mode=CaptureMode.V0) with ( mock.patch("posthog.consumer.batch_post", side_effect=mock_post), mock.patch("posthog.consumer.time.sleep") as mock_sleep, @@ -646,7 +650,7 @@ def mock_post(*args: Any, **kwargs: Any) -> None: if call_count[0] <= 3: raise error - consumer = Consumer(None, TEST_API_KEY, retries=3) + consumer = Consumer(None, TEST_API_KEY, retries=3, capture_mode=CaptureMode.V0) with ( mock.patch("posthog.consumer.batch_post", side_effect=mock_post), mock.patch("posthog.consumer.time.sleep") as mock_sleep, @@ -687,7 +691,7 @@ def test_request_bounds_retry_after_without_reducing_attempts( session = mock.Mock() session.post.side_effect = [retry_response, retry_response, success_response] - consumer = Consumer(None, TEST_API_KEY, retries=2) + consumer = Consumer(None, TEST_API_KEY, retries=2, capture_mode=CaptureMode.V0) with ( mock.patch("posthog.request._get_session", return_value=session), mock.patch("posthog.consumer.time.sleep") as mock_sleep, @@ -707,7 +711,7 @@ def mock_post(*args: Any, **kwargs: Any) -> None: if call_count[0] <= 1: raise APIError(408, "Request Timeout") - consumer = Consumer(None, TEST_API_KEY, retries=3) + consumer = Consumer(None, TEST_API_KEY, retries=3, capture_mode=CaptureMode.V0) with ( mock.patch("posthog.consumer.batch_post", side_effect=mock_post), mock.patch("posthog.consumer.time.sleep"), @@ -752,10 +756,11 @@ def _ai_event(event_name: str = "$ai_generation") -> dict[str, str]: class TestConsumerCaptureModeRouting(unittest.TestCase): - """`capture_mode` selects the submitter; V0 posts to the consumer's `endpoint`.""" + """`capture_mode` selects the submitter; both post to the consumer's `endpoint`.""" @parameterized.expand( [ + ("default", None, True), ("v0", CaptureMode.V0, False), ("v1", CaptureMode.V1, True), ] @@ -763,7 +768,8 @@ class TestConsumerCaptureModeRouting(unittest.TestCase): def test_capture_mode_selects_analytics_submitter( self, _name, mode, expects_v1 ) -> None: - consumer = Consumer(None, TEST_API_KEY, capture_mode=mode) + kwargs = {"capture_mode": mode} if mode else {} + consumer = Consumer(None, TEST_API_KEY, **kwargs) batch = [_track_event()] with ( mock.patch("posthog.consumer.batch_post") as mock_post, @@ -774,6 +780,7 @@ def test_capture_mode_selects_analytics_submitter( mock_post.assert_not_called() mock_v1.assert_called_once() self.assertEqual(mock_v1.call_args.args[2], batch) + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) else: mock_v1.assert_not_called() mock_post.assert_called_once() @@ -800,8 +807,22 @@ def test_v1_forwards_consumer_config_to_submitter(self) -> None: self.assertEqual(kwargs["max_retries"], 4) self.assertEqual(kwargs["historical_migration"], True) + def test_v1_posts_to_configured_endpoint(self) -> None: + consumer = Consumer(None, TEST_API_KEY, endpoint=_CAPTURE_AI_V1_PATH) + batch = [_ai_event()] + with patch_capture_send("consumer") as mock_v1: + consumer.request(batch) + mock_v1.assert_called_once() + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + self.assertEqual(sent_batch(mock_v1), batch) + def test_v0_posts_to_configured_endpoint(self) -> None: - consumer = Consumer(None, TEST_API_KEY, endpoint=AI_EVENTS_ENDPOINT) + consumer = Consumer( + None, + TEST_API_KEY, + endpoint=AI_EVENTS_ENDPOINT, + capture_mode=CaptureMode.V0, + ) batch = [_ai_event()] with mock.patch("posthog.consumer.batch_post") as mock_post: consumer.request(batch) diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index ef83c433..b12ff88f 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_gevent_compat.py b/posthog/test/test_gevent_compat.py index 34e0655b..2948e9b2 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 dc875e67..6ebd8fe4 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 12b8e7e2..109f79f4 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/tracing/test_client_traces.py b/posthog/test/tracing/test_client_traces.py index ab9d0ee0..f3b2b48c 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/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9..319451d6 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -232,7 +232,6 @@ 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 @@ -1186,7 +1185,7 @@ 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.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=None, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V1, 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) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 0d803ae5..2a68dbf5 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -15,6 +15,7 @@ from posthog import Client from posthog.capture_compression import CaptureCompression +from posthog.capture_v1 import _CAPTURE_V1_PATH from posthog.capture_v1 import _post_v1 as original_post_v1 from posthog.request import EVENTS_ENDPOINT, USER_AGENT from posthog.request import batch_post as original_batch_post @@ -239,6 +240,7 @@ 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. @@ -257,6 +259,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) @@ -305,10 +308,10 @@ def patched_post_v1( @app.route("/health", methods=["GET"]) def health(): """Health check endpoint""" + # 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", "capture_ai_v0", "encoding_gzip"] - if is_v1() - else ["capture_v0", "capture_ai_v0", "encoding_gzip"] + ["capture_v1", "encoding_gzip"] if is_v1() else ["capture_v0", "encoding_gzip"] ) capabilities.append("feature_flags_local_evaluation_v1") return jsonify( diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 8acc7838..ac1ddc29 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -87,7 +87,7 @@ def test_health_opts_into_local_evaluation_without_losing_capture( 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_ai_v0" not in capabilities def test_init_enables_explicit_definitions_loading_without_polling(adapter):