From 05d430e8ffaf6583577b6af1d445e318938636a4 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 17 Aug 2026 17:16:40 -0400 Subject: [PATCH 1/2] test: add server SDK wire snapshots --- .../changesets/stalwart-iceseeker-aurelien.md | 5 + posthog/test/snapshots/exception_event.json | 139 ++++++++++ posthog/test/snapshots/flags_request.json | 32 +++ .../test/snapshots/legacy_event_family.json | 94 +++++++ posthog/test/test_server_payload_snapshots.py | 240 ++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 .sampo/changesets/stalwart-iceseeker-aurelien.md create mode 100644 posthog/test/snapshots/exception_event.json create mode 100644 posthog/test/snapshots/flags_request.json create mode 100644 posthog/test/snapshots/legacy_event_family.json create mode 100644 posthog/test/test_server_payload_snapshots.py diff --git a/.sampo/changesets/stalwart-iceseeker-aurelien.md b/.sampo/changesets/stalwart-iceseeker-aurelien.md new file mode 100644 index 00000000..46fa4480 --- /dev/null +++ b/.sampo/changesets/stalwart-iceseeker-aurelien.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Add stable wire payload snapshots for server SDK coordination diff --git a/posthog/test/snapshots/exception_event.json b/posthog/test/snapshots/exception_event.json new file mode 100644 index 00000000..b971c7af --- /dev/null +++ b/posthog/test/snapshots/exception_event.json @@ -0,0 +1,139 @@ +{ + "body": { + "api_key": "phc_snapshot_project", + "batch": [ + { + "distinct_id": "user-123", + "event": "$exception", + "properties": { + "$exception_list": [ + { + "mechanism": { + "handled": true, + "type": "generic" + }, + "module": null, + "stacktrace": { + "frames": [ + { + "abs_path": "/posthog/test/test_server_payload_snapshots.py", + "context_line": " _raise_snapshot_exception()", + "filename": "posthog/test/test_server_payload_snapshots.py", + "function": "_exception_request", + "in_app": true, + "lineno": "", + "module": "posthog.test.test_server_payload_snapshots", + "platform": "python", + "post_context": [ + " except RuntimeError as error:", + " client.capture_exception(", + " error,", + " distinct_id=\"user-123\",", + " properties={\"component\": \"checkout\", \"severity\": \"high\"}," + ], + "pre_context": [ + " project_root=str(Path(__file__).parents[2]),", + " capture_exception_code_variables=False,", + " )", + " try:", + " try:" + ] + }, + { + "abs_path": "/posthog/test/test_server_payload_snapshots.py", + "context_line": " raise RuntimeError(\"checkout failed\") from cause", + "filename": "posthog/test/test_server_payload_snapshots.py", + "function": "_raise_snapshot_exception", + "in_app": true, + "lineno": "", + "module": "posthog.test.test_server_payload_snapshots", + "platform": "python", + "post_context": [ + "", + "", + "def _exception_request():", + " session = mock.MagicMock()", + " session.post.return_value = _successful_response()" + ], + "pre_context": [ + "", + "def _raise_snapshot_exception() -> None:", + " try:", + " raise ValueError(\"invalid order total\")", + " except ValueError as cause:" + ] + } + ], + "type": "raw" + }, + "type": "RuntimeError", + "value": "checkout failed" + }, + { + "mechanism": { + "handled": true, + "type": "generic" + }, + "module": null, + "stacktrace": { + "frames": [ + { + "abs_path": "/posthog/test/test_server_payload_snapshots.py", + "context_line": " raise ValueError(\"invalid order total\")", + "filename": "posthog/test/test_server_payload_snapshots.py", + "function": "_raise_snapshot_exception", + "in_app": true, + "lineno": "", + "module": "posthog.test.test_server_payload_snapshots", + "platform": "python", + "post_context": [ + " except ValueError as cause:", + " raise RuntimeError(\"checkout failed\") from cause", + "", + "", + "def _exception_request():" + ], + "pre_context": [ + " return _transport_request(session.post.call_args)", + "", + "", + "def _raise_snapshot_exception() -> None:", + " try:" + ] + } + ], + "type": "raw" + }, + "type": "ValueError", + "value": "invalid order total" + } + ], + "$geoip_disable": true, + "$groups": { + "company": "company-456" + }, + "$is_server": true, + "$lib": "posthog-python", + "$lib_version": "", + "$os": "", + "$os_distro": "", + "$os_version": "", + "$python_runtime": "", + "$python_version": "", + "component": "checkout", + "severity": "high" + }, + "timestamp": "2026-01-02T03:04:05+00:00", + "uuid": "00000000-0000-4000-8000-000000000005" + } + ], + "historical_migration": false, + "sent_at": "2026-01-02T03:04:05+00:00" + }, + "headers": { + "Content-Type": "application/json", + "User-Agent": "posthog-python/" + }, + "timeout": 15, + "url": "https://example.posthog.test/batch/" +} diff --git a/posthog/test/snapshots/flags_request.json b/posthog/test/snapshots/flags_request.json new file mode 100644 index 00000000..50a01840 --- /dev/null +++ b/posthog/test/snapshots/flags_request.json @@ -0,0 +1,32 @@ +{ + "body": { + "api_key": "phc_snapshot_project", + "device_id": "device-789", + "distinct_id": "user-123", + "flag_keys_to_evaluate": [ + "checkout-redesign", + "new-billing" + ], + "geoip_disable": false, + "group_properties": { + "company": { + "employees": 42, + "industry": "technology" + } + }, + "groups": { + "company": "company-456" + }, + "person_properties": { + "email": "person@example.com", + "plan": "pro" + }, + "sent_at": "2026-01-02T03:04:05+00:00" + }, + "headers": { + "Content-Type": "application/json", + "User-Agent": "posthog-python/" + }, + "timeout": 3, + "url": "https://example.posthog.test/flags/?v=2" +} diff --git a/posthog/test/snapshots/legacy_event_family.json b/posthog/test/snapshots/legacy_event_family.json new file mode 100644 index 00000000..4e33c6bd --- /dev/null +++ b/posthog/test/snapshots/legacy_event_family.json @@ -0,0 +1,94 @@ +{ + "body": { + "api_key": "phc_snapshot_project", + "batch": [ + { + "distinct_id": "user-123", + "event": "order completed", + "properties": { + "$geoip_disable": true, + "$groups": { + "company": "company-456" + }, + "$is_server": true, + "$lib": "posthog-python", + "$lib_version": "", + "$os": "", + "$os_distro": "", + "$os_version": "", + "$python_runtime": "", + "$python_version": "", + "amount": 29.5, + "items": [ + { + "quantity": 1, + "sku": "SKU-1" + }, + { + "quantity": 2, + "sku": "SKU-2" + } + ] + }, + "timestamp": "2026-01-02T03:04:05+00:00", + "uuid": "00000000-0000-4000-8000-000000000001" + }, + { + "$set": { + "email": "person@example.com", + "plan": "pro" + }, + "distinct_id": "user-123", + "event": "$set", + "properties": { + "$geoip_disable": true, + "$is_server": true, + "$lib": "posthog-python", + "$lib_version": "" + }, + "timestamp": "2026-01-02T03:04:05+00:00", + "uuid": "00000000-0000-4000-8000-000000000002" + }, + { + "distinct_id": "anonymous-789", + "event": "$create_alias", + "properties": { + "$geoip_disable": true, + "$is_server": true, + "$lib": "posthog-python", + "$lib_version": "", + "alias": "user-123", + "distinct_id": "anonymous-789" + }, + "timestamp": "2026-01-02T03:04:05+00:00", + "uuid": "00000000-0000-4000-8000-000000000003" + }, + { + "distinct_id": "user-123", + "event": "$groupidentify", + "properties": { + "$geoip_disable": true, + "$group_key": "company-456", + "$group_set": { + "employees": 42, + "name": "Example Corp" + }, + "$group_type": "company", + "$is_server": true, + "$lib": "posthog-python", + "$lib_version": "" + }, + "timestamp": "2026-01-02T03:04:05+00:00", + "uuid": "00000000-0000-4000-8000-000000000004" + } + ], + "historical_migration": false, + "sent_at": "2026-01-02T03:04:05+00:00" + }, + "headers": { + "Content-Type": "application/json", + "User-Agent": "posthog-python/" + }, + "timeout": 15, + "url": "https://example.posthog.test/batch/" +} diff --git a/posthog/test/test_server_payload_snapshots.py b/posthog/test/test_server_payload_snapshots.py new file mode 100644 index 00000000..792bca16 --- /dev/null +++ b/posthog/test/test_server_payload_snapshots.py @@ -0,0 +1,240 @@ +import json +from pathlib import Path +from unittest import mock + +import requests +from freezegun import freeze_time + +from posthog.client import Client + + +_SNAPSHOT_DIRECTORY = Path(__file__).with_name("snapshots") +_TEST_FILE_SUFFIX = "posthog/test/test_server_payload_snapshots.py" +_FIXED_TIME = "2026-01-02T03:04:05+00:00" +_RUNTIME_CONTEXT = { + "$os": "", + "$os_distro": "", + "$os_version": "", + "$python_runtime": "", + "$python_version": "", +} + + +def _successful_response() -> requests.Response: + response = requests.Response() + response.status_code = 200 + response._content = b"{}" + return response + + +def _has_test_file_suffix(value: str) -> bool: + return value.replace("\\", "/").endswith(_TEST_FILE_SUFFIX) + + +def _normalize_snapshot_value(value): + if isinstance(value, list): + return [_normalize_snapshot_value(item) for item in value] + if not isinstance(value, dict): + return value + + normalized = {} + for key, item in value.items(): + if key == "$lib_version": + normalized[key] = "" + elif key == "User-Agent" and isinstance(item, str): + normalized[key] = "posthog-python/" + elif ( + key == "abs_path" and isinstance(item, str) and _has_test_file_suffix(item) + ): + normalized[key] = f"/{_TEST_FILE_SUFFIX}" + elif ( + key == "filename" and isinstance(item, str) and _has_test_file_suffix(item) + ): + normalized[key] = _TEST_FILE_SUFFIX + else: + normalized[key] = _normalize_snapshot_value(item) + return normalized + + +def _normalize_exception_line_numbers(request): + exceptions = request["body"]["batch"][0]["properties"]["$exception_list"] + runtime_frames = exceptions[0]["stacktrace"]["frames"] + cause_frames = exceptions[1]["stacktrace"]["frames"] + line_numbers = [ + runtime_frames[0]["lineno"], + runtime_frames[1]["lineno"], + cause_frames[0]["lineno"], + ] + + assert all( + type(line_number) is int and line_number > 0 for line_number in line_numbers + ) + assert line_numbers[2] < line_numbers[1] < line_numbers[0] + + for frame in [*runtime_frames, *cause_frames]: + frame["lineno"] = "" + return request + + +def _transport_request(call): + return _normalize_snapshot_value( + { + "body": json.loads(call.kwargs["data"]), + "headers": dict(call.kwargs["headers"]), + "timeout": call.kwargs["timeout"], + "url": call.args[0], + } + ) + + +def _assert_json_snapshot(name: str, value) -> None: + actual = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + expected = (_SNAPSHOT_DIRECTORY / f"{name}.json").read_text() + assert actual == expected + + +def _legacy_event_family_request(): + session = mock.MagicMock() + session.post.return_value = _successful_response() + + with ( + freeze_time(_FIXED_TIME), + mock.patch("posthog.request._get_session", return_value=session), + mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), + ): + client = Client( + "phc_snapshot_project", + host="https://example.posthog.test", + capture_mode="v0", + flush_at=100, + flush_interval=100, + ) + try: + client.capture( + "order completed", + distinct_id="user-123", + properties={ + "amount": 29.5, + "items": [ + {"quantity": 1, "sku": "SKU-1"}, + {"quantity": 2, "sku": "SKU-2"}, + ], + }, + groups={"company": "company-456"}, + timestamp=_FIXED_TIME, + uuid="00000000-0000-4000-8000-000000000001", + ) + client.set( + distinct_id="user-123", + properties={"email": "person@example.com", "plan": "pro"}, + timestamp=_FIXED_TIME, + uuid="00000000-0000-4000-8000-000000000002", + ) + client.alias( + previous_id="anonymous-789", + distinct_id="user-123", + timestamp=_FIXED_TIME, + uuid="00000000-0000-4000-8000-000000000003", + ) + client.group_identify( + "company", + "company-456", + properties={"employees": 42, "name": "Example Corp"}, + distinct_id="user-123", + timestamp=_FIXED_TIME, + uuid="00000000-0000-4000-8000-000000000004", + ) + client.flush() + finally: + client.shutdown() + + session.post.assert_called_once() + return _transport_request(session.post.call_args) + + +def _raise_snapshot_exception() -> None: + try: + raise ValueError("invalid order total") + except ValueError as cause: + raise RuntimeError("checkout failed") from cause + + +def _exception_request(): + session = mock.MagicMock() + session.post.return_value = _successful_response() + + with ( + freeze_time(_FIXED_TIME), + mock.patch("posthog.request._get_session", return_value=session), + mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), + mock.patch("posthog.client._get_current_otel_span_properties", return_value={}), + ): + client = Client( + "phc_snapshot_project", + host="https://example.posthog.test", + capture_mode="v0", + sync_mode=True, + project_root=str(Path(__file__).parents[2]), + capture_exception_code_variables=False, + ) + try: + try: + _raise_snapshot_exception() + except RuntimeError as error: + client.capture_exception( + error, + distinct_id="user-123", + properties={"component": "checkout", "severity": "high"}, + groups={"company": "company-456"}, + timestamp=_FIXED_TIME, + uuid="00000000-0000-4000-8000-000000000005", + ) + finally: + client.shutdown() + + session.post.assert_called_once() + return _normalize_exception_line_numbers(_transport_request(session.post.call_args)) + + +def _flags_request(): + session = mock.MagicMock() + session.post.return_value = _successful_response() + + with ( + freeze_time(_FIXED_TIME), + mock.patch("posthog.request._get_flags_session", return_value=session), + ): + client = Client( + "phc_snapshot_project", + host="https://example.posthog.test", + send=False, + ) + try: + client.get_flags_decision( + "user-123", + groups={"company": "company-456"}, + person_properties={"email": "person@example.com", "plan": "pro"}, + group_properties={ + "company": {"employees": 42, "industry": "technology"} + }, + disable_geoip=False, + flag_keys_to_evaluate=["checkout-redesign", "new-billing"], + device_id="device-789", + ) + finally: + client.shutdown() + + session.post.assert_called_once() + return _transport_request(session.post.call_args) + + +def test_legacy_capture_identify_alias_and_group_identify_request_snapshot(): + _assert_json_snapshot("legacy_event_family", _legacy_event_family_request()) + + +def test_complete_exception_request_snapshot(): + _assert_json_snapshot("exception_event", _exception_request()) + + +def test_complete_flags_request_snapshot(): + _assert_json_snapshot("flags_request", _flags_request()) From 6579ab29f0a09caa95086cb4a40ac3686009d86c Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 17 Aug 2026 17:30:37 -0400 Subject: [PATCH 2/2] chore: remove test-only changeset --- .sampo/changesets/stalwart-iceseeker-aurelien.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .sampo/changesets/stalwart-iceseeker-aurelien.md diff --git a/.sampo/changesets/stalwart-iceseeker-aurelien.md b/.sampo/changesets/stalwart-iceseeker-aurelien.md deleted file mode 100644 index 46fa4480..00000000 --- a/.sampo/changesets/stalwart-iceseeker-aurelien.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -pypi/posthog: patch ---- - -Add stable wire payload snapshots for server SDK coordination