Skip to content

Commit 44df57f

Browse files
committed
test: capture invariant hardening
1 parent 42c1d07 commit 44df57f

4 files changed

Lines changed: 195 additions & 25 deletions

File tree

‎posthog/test/test_ai_capture_lane.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ def test_sync_mode_ai_event_over_cap_is_not_sent(self):
209209
mock_send.assert_not_called()
210210
self.assertIn("exceeds the 1024KiB limit", "\n".join(logs.output))
211211

212+
def test_sync_mode_ai_event_at_endpoint_ceiling_is_sent(self):
213+
client = Client(TEST_API_KEY, sync_mode=True)
214+
with patch_capture_send("client") as mock_send:
215+
result = client.capture_ai(
216+
"$ai_generation",
217+
distinct_id="d",
218+
properties={"p": "x" * AI_MAX_PROPERTIES_SIZE},
219+
)
220+
221+
self.assertIsNotNone(result)
222+
mock_send.assert_called_once()
223+
212224
def test_analytics_lane_rejects_events_over_900kib(self):
213225
client = self._client()
214226
consumer = client.consumers[0]

‎posthog/test/test_async_client.py‎

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from posthog import AsyncClient, AsyncPosthog, CaptureCompression
1515
from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
16-
from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE
16+
from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE
1717
from posthog.contexts import (
1818
new_context,
1919
set_capture_exception_code_variables_context,
@@ -123,7 +123,10 @@ async def send_batch(api_key, host, batch, **kwargs):
123123

124124

125125
@pytest.mark.asyncio
126-
async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission():
126+
@pytest.mark.parametrize("method_name", ["capture", "capture_ai"])
127+
async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission(
128+
method_name,
129+
):
127130
client = AsyncPosthog("test-key", flush_interval=30)
128131
client._ensure_workers_started(client._analytics_lane)
129132
scheduled_callbacks = []
@@ -136,7 +139,7 @@ async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission():
136139
):
137140
capture_thread = threading.Thread(
138141
target=lambda: capture_result.append(
139-
client.capture("threaded event", distinct_id="user-1")
142+
getattr(client, method_name)("threaded event", distinct_id="user-1")
140143
),
141144
daemon=True,
142145
)
@@ -149,9 +152,12 @@ async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission():
149152
capture_thread.join(timeout=1)
150153

151154
assert capture_result == [None]
152-
for task in client._analytics_lane.worker_tasks:
155+
assert client._ai_lane.worker_tasks == []
156+
assert client._pending_queue_items() == 0
157+
worker_tasks = client._all_worker_tasks()
158+
for task in worker_tasks:
153159
task.cancel()
154-
await asyncio.gather(*client._analytics_lane.worker_tasks, return_exceptions=True)
160+
await asyncio.gather(*worker_tasks, return_exceptions=True)
155161
await client._close_transport()
156162

157163

@@ -288,7 +294,8 @@ async def send_batch(api_key, host, batch, **kwargs):
288294
("method_name", "client_kwargs", "payload_size", "sent"),
289295
[
290296
("capture_immediate", {}, MAX_MSG_SIZE, False),
291-
("capture_ai_immediate", {}, MAX_MSG_SIZE, True),
297+
("capture_ai_immediate", {}, AI_MAX_PROPERTIES_SIZE, True),
298+
("capture_ai_immediate", {}, AI_MAX_MSG_SIZE, False),
292299
("capture_ai_immediate", {"capture_ai_max_event_bytes": 1024}, 2048, False),
293300
],
294301
)
@@ -359,7 +366,7 @@ async def send_batch(api_key, host, batch, **kwargs):
359366
client.capture_ai(
360367
"$ai_generation",
361368
distinct_id="user-1",
362-
properties={"$ai_input": "x" * MAX_MSG_SIZE},
369+
properties={"$ai_input": "x" * AI_MAX_PROPERTIES_SIZE},
363370
)
364371
await client.flush(timeout_seconds=1)
365372
assert sent == {
@@ -800,23 +807,31 @@ async def on_error(error, batch):
800807

801808

802809
@pytest.mark.asyncio
803-
@pytest.mark.parametrize("immediate", [False, True])
804-
async def test_failed_capture_does_not_log_server_response_detail(caplog, immediate):
810+
@pytest.mark.parametrize(
811+
("method_name", "path"),
812+
[
813+
("capture", _CAPTURE_V1_PATH),
814+
("capture_immediate", _CAPTURE_V1_PATH),
815+
("capture_ai", _CAPTURE_AI_V1_PATH),
816+
("capture_ai_immediate", _CAPTURE_AI_V1_PATH),
817+
],
818+
)
819+
async def test_failed_capture_logs_one_loss_line_without_response_detail(
820+
caplog, method_name, path
821+
):
805822
caplog.set_level(logging.DEBUG, logger="posthog")
806823
server_error = APIError(400, "password=server-secret")
807824

808825
with patch_async_capture_send(side_effect=server_error):
809826
client = AsyncPosthog("test-key", flush_at=1, max_retries=0)
810-
if immediate:
811-
await client.capture_immediate("event", distinct_id="user-1")
812-
else:
813-
client.capture("event", distinct_id="user-1")
814-
await client.flush(timeout_seconds=1)
827+
result = getattr(client, method_name)("$ai_generation", distinct_id="user-1")
828+
if method_name.endswith("_immediate"):
829+
await result
830+
await client.flush(timeout_seconds=1)
815831
await client.shutdown()
816832

817833
assert "server-secret" not in caplog.text
818-
assert "APIError" in caplog.text
819-
assert "status=400" in caplog.text
834+
assert f"1 event(s) not persisted by {path}: APIError (status=400)" in caplog.text
820835

821836

822837
@pytest.mark.asyncio
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import json
2+
from typing import Any, Optional
3+
from unittest import mock
4+
5+
import pytest
6+
from requests import Response
7+
8+
from posthog import AsyncPosthog, CaptureCompression, Client
9+
from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
10+
11+
_UUID = "01890000-0000-7000-8000-000000000001"
12+
_TIMESTAMP = "2026-01-02T03:04:05+00:00"
13+
14+
15+
def _event_kwargs() -> dict[str, Any]:
16+
return {
17+
"distinct_id": "user-1",
18+
"uuid": _UUID,
19+
"timestamp": _TIMESTAMP,
20+
"properties": {
21+
"$process_person_profile": False,
22+
"$product_tour_id": "tour-1",
23+
"empty_list": [],
24+
"empty_map": {},
25+
"none": None,
26+
"nested": {"a": {"b": [1, None, {}]}},
27+
},
28+
"options": {
29+
"process_person_profile": None,
30+
"cookieless_mode": False,
31+
"custom_list": [],
32+
"custom_map": {"inner": {}},
33+
},
34+
}
35+
36+
37+
def _record_wire(sent: list[tuple[str, list[dict]]]):
38+
def post(url, data=None, **kwargs):
39+
events = json.loads(data)["batch"]
40+
sent.append((url, events))
41+
response = Response()
42+
response.status_code = 200
43+
response._content = json.dumps(
44+
{"results": {event["uuid"]: {"result": "ok"} for event in events}}
45+
).encode()
46+
return response
47+
48+
session = mock.Mock()
49+
session.post.side_effect = post
50+
return mock.patch("posthog.capture_send._get_session", return_value=session)
51+
52+
53+
def _send_sync(method_name: str, before_send) -> list[tuple[str, list[dict]]]:
54+
sent: list[tuple[str, list[dict]]] = []
55+
with _record_wire(sent):
56+
client = Client(
57+
"test-key",
58+
sync_mode=True,
59+
before_send=before_send,
60+
capture_compression=CaptureCompression.NONE,
61+
)
62+
assert getattr(client, method_name)("$ai_generation", **_event_kwargs())
63+
client.shutdown()
64+
return sent
65+
66+
67+
async def _send_async(method_name: str, before_send) -> list[tuple[str, list[dict]]]:
68+
sent: list[tuple[str, list[dict]]] = []
69+
with _record_wire(sent):
70+
client = AsyncPosthog(
71+
"test-key",
72+
before_send=before_send,
73+
capture_compression=CaptureCompression.NONE,
74+
)
75+
assert await getattr(client, method_name)("$ai_generation", **_event_kwargs())
76+
await client.shutdown()
77+
return sent
78+
79+
80+
def _pass_through(event: dict) -> Optional[dict]:
81+
return event
82+
83+
84+
@pytest.mark.parametrize(
85+
("method_name", "path"),
86+
[("capture", _CAPTURE_V1_PATH), ("capture_ai", _CAPTURE_AI_V1_PATH)],
87+
)
88+
def test_pass_through_before_send_leaves_sync_wire_unchanged(method_name, path):
89+
without_hook = _send_sync(method_name, None)
90+
with_hook = _send_sync(method_name, _pass_through)
91+
92+
assert [url.endswith(path) for url, _ in without_hook] == [True]
93+
assert with_hook == without_hook
94+
assert without_hook[0][1][0]["options"]["process_person_profile"] is False
95+
96+
97+
@pytest.mark.asyncio
98+
@pytest.mark.parametrize(
99+
("method_name", "path"),
100+
[
101+
("capture_immediate", _CAPTURE_V1_PATH),
102+
("capture_ai_immediate", _CAPTURE_AI_V1_PATH),
103+
],
104+
)
105+
async def test_pass_through_before_send_leaves_async_wire_unchanged(method_name, path):
106+
without_hook = await _send_async(method_name, None)
107+
with_hook = await _send_async(method_name, _pass_through)
108+
109+
assert [url.endswith(path) for url, _ in without_hook] == [True]
110+
assert with_hook == without_hook
111+
assert without_hook[0][1][0]["options"]["process_person_profile"] is False

‎posthog/test/test_client.py‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import pytest
1919

2020
from posthog.capture_compression import CaptureCompression
21-
from posthog.capture_send import _CAPTURE_V1_PATH
21+
from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
2222
from posthog.client import Client
2323
from posthog.contexts import get_context_session_id, new_context, set_context_session
2424
from posthog.request import APIError, GetResponse
@@ -2764,19 +2764,29 @@ def on_error(err, batch):
27642764
"\n".join(logs.output),
27652765
)
27662766

2767-
def test_sync_send_failure_without_on_error_logs_aggregate_line(self):
2767+
@parameterized.expand(
2768+
[
2769+
("analytics", "capture", _CAPTURE_V1_PATH),
2770+
("ai", "capture_ai", _CAPTURE_AI_V1_PATH),
2771+
]
2772+
)
2773+
def test_sync_send_failure_without_on_error_logs_aggregate_line(
2774+
self, _name, method_name, path
2775+
):
27682776
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
27692777

27702778
with patch_capture_send(
27712779
"client", side_effect=APIError(400, "password=server-secret")
27722780
):
27732781
with self.assertLogs("posthog", level="ERROR") as logs:
2774-
result = client.capture("event", distinct_id="distinct_id")
2782+
result = getattr(client, method_name)(
2783+
"$ai_generation", distinct_id="distinct_id"
2784+
)
27752785

27762786
self.assertIsNone(result)
27772787
output = "\n".join(logs.output)
27782788
self.assertIn(
2779-
"1 event(s) not persisted by /i/v1/analytics/events: APIError (status=400)",
2789+
f"1 event(s) not persisted by {path}: APIError (status=400)",
27802790
output,
27812791
)
27822792
self.assertNotIn("server-secret", output)
@@ -3401,12 +3411,21 @@ def test_shutdown_does_not_wait_for_idle_consumers_flush_interval(self):
34013411

34023412
self.assertLess(time.monotonic() - start, 1)
34033413

3404-
def test_shutdown_waits_for_racing_enqueue_before_draining(self):
3414+
@parameterized.expand(
3415+
[
3416+
("analytics", "capture", "_analytics_lane", _CAPTURE_V1_PATH),
3417+
("ai_first_event", "capture_ai", "_ai_lane", _CAPTURE_AI_V1_PATH),
3418+
]
3419+
)
3420+
def test_shutdown_waits_for_racing_enqueue_before_draining(
3421+
self, _name, method_name, lane_attr, path
3422+
):
34053423
client = Client(FAKE_TEST_API_KEY, flush_interval=0.01)
3424+
lane = getattr(client, lane_attr)
34063425
put_started = threading.Event()
34073426
release_put = threading.Event()
34083427
shutdown_done = threading.Event()
3409-
original_put = client.queue.put
3428+
original_put = lane.queue.put
34103429
capture_result = []
34113430

34123431
def blocking_put(*args, **kwargs):
@@ -3416,14 +3435,17 @@ def blocking_put(*args, **kwargs):
34163435

34173436
capture_thread = threading.Thread(
34183437
target=lambda: capture_result.append(
3419-
client.capture("racing event", distinct_id="distinct_id")
3438+
getattr(client, method_name)("$ai_racing", distinct_id="distinct_id")
34203439
)
34213440
)
34223441
shutdown_thread = threading.Thread(
34233442
target=lambda: (client.shutdown(), shutdown_done.set())
34243443
)
34253444

3426-
with mock.patch.object(client.queue, "put", side_effect=blocking_put):
3445+
with (
3446+
patch_capture_send("consumer") as mock_send,
3447+
mock.patch.object(lane.queue, "put", side_effect=blocking_put),
3448+
):
34273449
capture_thread.start()
34283450
self.assertTrue(put_started.wait(2))
34293451
shutdown_thread.start()
@@ -3439,7 +3461,17 @@ def blocking_put(*args, **kwargs):
34393461
self.assertFalse(shutdown_thread.is_alive())
34403462
self.assertTrue(shutdown_done.is_set())
34413463
self.assertIsNotNone(capture_result[0])
3442-
self.assertTrue(client.queue.empty())
3464+
self.assertTrue(lane.queue.empty())
3465+
self.assertEqual(
3466+
[
3467+
(call.kwargs["path"], len(sent_batch(mock_send, i)))
3468+
for i, call in enumerate(mock_send.call_args_list)
3469+
],
3470+
[(path, 1)],
3471+
)
3472+
self.assertFalse(
3473+
any(c.is_alive() for lane in client._lanes for c in lane.consumers)
3474+
)
34433475

34443476
def test_shutdown_waits_for_sync_send_and_rejects_later_sends(self):
34453477
client = Client(FAKE_TEST_API_KEY, sync_mode=True)

0 commit comments

Comments
 (0)