From 990e3981b477a208704861384cebb3c2fc798d11 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Sun, 1 Nov 2026 12:59:01 -0800 Subject: [PATCH 1/5] docs: close review gaps in the 8.0 migration guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 29da4f0db..7ee92f241 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -14,7 +14,7 @@ You need to change code if your app does any of these: - calls `posthog.request.post` or `posthog.request.flags` with positional arguments after `path` or `host` - sets `$exception_level` or another reserved exception property in `capture_exception(properties=...)` - sets `capture_mode`, `POSTHOG_CAPTURE_MODE` or `gzip` -- imports `CaptureV1Error`, `posthog.capture_v1`, `request.batch_post`, `EVENTS_ENDPOINT` or `AI_EVENTS_ENDPOINT` +- imports `CaptureMode`, `posthog.capture_mode`, `CaptureV1Error`, `posthog.capture_v1`, `request.batch_post`, `EVENTS_ENDPOINT` or `AI_EVENTS_ENDPOINT` - sends events to a self-hosted PostHog that does not serve the capture v1 endpoints - sends events through a proxy that redirects capture requests to another host - reuses one event `uuid` for more than one event @@ -75,7 +75,7 @@ To find MCP traffic, filter on the `$mcp_*` events and properties instead of `$l | `CaptureV1Error` (`from posthog.capture_v1 import CaptureV1Error`) | `CaptureError` (`from posthog import CaptureError`). There is no alias. | | `posthog.capture_v1` | `posthog.capture_event` and `posthog.capture_send` | | `request.batch_post`, `async_batch_post`, `EVENTS_ENDPOINT`, `AI_EVENTS_ENDPOINT` | Nothing. Send events through a client. | -| The `gzip` parameter of `request.post` and `request.flags` | Nothing. The parameters after `path` in `post` and after `host` in `flags` are keyword-only, so a 7.x call that passes them by position raises `TypeError`. | +| The `gzip` parameter of `request.post` and `request.flags` | Nothing. The parameters after `path` in `post` and after `host` in `flags` are keyword-only, so a 7.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error: it is sent as a field in the request body. | | The `backoff` dependency | Add it to your own requirements if your code imports it. | The `posthoganalytics` package has the same changes. For example, import `CaptureError` from `posthoganalytics`. @@ -106,13 +106,14 @@ When the whole request is over the billing limit, capture returns a `402` with n - `verdict_summary()`: counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` - `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. - With `on_error` set, the SDK logs nothing for a failed batch. -- Without `on_error`, the SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. -- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. Update any log-based alerts that match them. +- Without `on_error`, the SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. After a 2xx response it also counts the lost events by PostHog's reason, for example `drop/event_too_big=1`. +- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. In `sync_mode`, a failed request no longer logs the 7.x `Error in capture: ...` line, or the same line for another method. Update any log-based alerts that match these lines. - In `sync_mode`, a failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error. +- In `sync_mode`, a failed request is retried like a background batch, in the calling thread. With the default `max_retries` of 3, a call makes up to 4 attempts, and each attempt can take up to `timeout` (`capture_ai_timeout` for AI events). With the defaults, an analytics event whose requests keep timing out blocks the call for about a minute. 7.x made one attempt. - If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again. - The SDK retries `404`, `408`, `429`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including the other 5xx codes. 7.x retried `429` and every 5xx, but not `404`. Capture itself does not return `429`; a proxy in front of it can. A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. - Each attempt makes one connection try. 7.x also retried a failed connection up to twice inside each attempt, so a capture request could block for up to three times the `timeout` per attempt. -- Retries start at 100 ms and double each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients do not retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10. +- Retries start at 100 ms and double each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients do not retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10. In 7.x the first retry waited up to 1 second. - The SDK's own log lines no longer contain event content: the debug line for a queued event names the event and the lane, and the invalid timestamp warning no longer includes the value. When your `before_send` hook raises, the error line names the event and the exception type, not the exception message, which can hold the data the hook was removing. The traceback is logged at debug level. To inspect events, use `before_send`. - A personal API key (`phx_...`) passed as the project API key disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. @@ -124,8 +125,8 @@ Capture rejects a whole batch when one event cannot be parsed. - An event whose name is missing, `None`, empty or not a string is dropped. The SDK logs one warning per reason per process. 7.x sent it. - An event whose `before_send` hook sets `distinct_id` to `None` or a non-string is dropped, with one warning per reason per process. The SDK does not convert the hook's value or make up an ID. 7.x sent it, and capture would reject the whole batch. - PostHog drops an event whose name is over 200 UTF-8 bytes (`event_name_too_long`), and `on_error` reports it. 7.x sent events to legacy capture, which had no event name limit. -- A `NaN` or infinite `float` value is sent as `null`, as in 7.75 and later, and the event is kept. Earlier 7.x versions sent invalid JSON, which made capture reject the whole batch. -- An event that cannot be serialized to JSON, for example one with a circular reference, is dropped and logged. +- A `NaN` or infinite `float` value is sent as `null`, as in 7.75 and later, and the event is kept. Earlier 7.x versions sent invalid JSON, which made capture reject the whole batch. A `NaN` value in `$set` sets that person property to `null`. +- An event that cannot be serialized to JSON, for example one with a circular reference, is dropped and logged. As in 7.x, a circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. - A property or option key that JSON cannot represent, such as a tuple, is dropped without a log, and the rest of the event is sent. 7.x dropped the event. `int`, finite `float`, `bool` and `None` keys are still converted to strings. A `NaN` or infinite `float` key still drops the event. ## Event uuids @@ -153,6 +154,7 @@ The SDK moves them out of `properties` for you. - `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID is not the session's user. - `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the call is dropped with a warning. - Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. +- Person processing turned off in the context, with `set_context_option("process_person_profile", False)` or a `$process_person_profile` tag, or in `super_options`, now applies to `$create_alias` and `$groupidentify` events too. A `$process_person_profile: False` in `super_properties` already did this in 7.x. PostHog drops these events when person processing is off, with an ingestion warning. Capture accepts them, so `on_error` does not report the drop. To keep them, turn person processing off per call instead. ## Tracing headers @@ -183,7 +185,7 @@ Capture v1 sends processing options in an `options` object, next to `properties` - `super_options`, a new `Client`, `AsyncPosthog` and module setting, sets options on every event, like `super_properties`. - `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. - Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. -- Options are sent as given. PostHog validates them. +- Options are sent as given. PostHog validates them. It drops an event with an option value it cannot read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. - An `options` value that is not a dict is logged as an error and ignored. The event is still sent. - The legacy option properties, the `$`-prefixed properties legacy capture reads as settings, still work. They fill the matching option only when it is unset, and the SDK always removes them from `properties`. Their values are no longer converted, so pass `True` or `False`, not `"true"`. @@ -202,7 +204,7 @@ Values apply in this order. Steps 2 to 4 fill only the options and properties th 1. the `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable` 2. context options and tags 3. `super_options` and `super_properties` -4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `$trace_id` and `$span_id` from the active OpenTelemetry span, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID` +4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `$trace_id` and `$span_id` from the active OpenTelemetry span (in `capture` and `capture_ai` only with `capture_trace_context=True`, and always in `capture_exception`), `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID` 5. `before_send`, which sees the result of steps 1 to 4 and can change or remove any of it 6. legacy properties, which fill unset options and are then removed @@ -227,6 +229,8 @@ These changes follow from this order: ## AI capture - `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. +- Send `$ai_` events with `capture_ai`. `capture()` sends every event to `/i/v1/analytics/events`, including `$ai_` events, which PostHog accepts there for now. +- `capture_ai` sends any event name to the AI endpoint. PostHog drops an event whose name does not start with `$ai_` as `misrouted_event`, and `on_error` reports it. The SDK writes only a debug log. - The OpenTelemetry integration (`PostHogSpanProcessor`, `PostHogTraceExporter`) is unchanged: it still exports spans over OTLP to `/i/v0/ai/otel`. - `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. - The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. If the client has no `capture_ai`, the wrapper, callback handler or tracing processor logs one error when you create it and captures no AI events; provider calls still work. Before, such a client got `capture` calls. From 57e9198e200f23104ce8da1f8d3fb72bf4b69e71 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Sun, 1 Nov 2026 13:21:05 -0800 Subject: [PATCH 2/5] fix: always send fractional seconds in capture timestamps Event timestamp, batch created_at and PostHog-Request-Timestamp now use one helper that writes six fractional digits in UTC, so whole seconds no longer lose the fraction. Co-Authored-By: Claude Opus 5.5 --- .../changesets/capture-timestamp-fraction.md | 5 + posthog/async_client.py | 3 +- posthog/capture_event.py | 6 +- posthog/capture_send.py | 6 +- posthog/client.py | 3 +- posthog/test/snapshots/event_family.json | 12 +- posthog/test/snapshots/exception_event.json | 6 +- posthog/test/test_capture_event.py | 30 +++-- posthog/test/test_capture_invariants.py | 109 ++++++++++++++++-- posthog/test/test_client.py | 16 +-- posthog/test/test_utils.py | 4 +- posthog/utils.py | 11 +- 12 files changed, 165 insertions(+), 46 deletions(-) create mode 100644 .sampo/changesets/capture-timestamp-fraction.md diff --git a/.sampo/changesets/capture-timestamp-fraction.md b/.sampo/changesets/capture-timestamp-fraction.md new file mode 100644 index 000000000..a54ae0eaa --- /dev/null +++ b/.sampo/changesets/capture-timestamp-fraction.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Capture timestamps always have six fractional-second digits, even on a whole second: `2026-01-02T03:04:05.000000+00:00`, not `2026-01-02T03:04:05+00:00`. This applies to the event `timestamp`, the batch `created_at` and the `PostHog-Request-Timestamp` header. The `timestamp` that `before_send` receives has the same format. diff --git a/posthog/async_client.py b/posthog/async_client.py index 9ecef6585..2f9576b5b 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -103,6 +103,7 @@ SizeLimitedDict, _is_personal_api_key, _normalize_timestamp, + _rfc3339_timestamp, clean, _drop_invalid_distinct_id, _drop_invalid_event_name, @@ -540,7 +541,7 @@ def _prepare_event( "Invalid timestamp; it must be a datetime or an ISO 8601 string. " "Falling back to the current UTC time." ) - msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() + msg["timestamp"] = _rfc3339_timestamp(datetime.now(tz=timezone.utc)) sent_uuid = self._normalize_uuid(msg) properties = msg.setdefault("properties", {}) diff --git a/posthog/capture_event.py b/posthog/capture_event.py index 36c5aac0c..5504496c3 100644 --- a/posthog/capture_event.py +++ b/posthog/capture_event.py @@ -26,7 +26,7 @@ from typing import Any, Optional from uuid import UUID -from posthog.utils import _normalize_timestamp, _uuid7 +from posthog.utils import _normalize_timestamp, _rfc3339_timestamp, _uuid7 from posthog.utils import clean as _clean log = logging.getLogger("posthog") @@ -257,7 +257,7 @@ def _v1_timestamp(timestamp: Any) -> str: rejects naive timestamps. """ if timestamp is None: - return datetime.now(timezone.utc).isoformat() + return _rfc3339_timestamp(datetime.now(timezone.utc)) return _normalize_timestamp(timestamp) @@ -336,7 +336,7 @@ def _build_v1_batch_body( hoisted once so it stays stable across retry attempts. """ body: dict[str, Any] = { - "created_at": created_at or datetime.now(timezone.utc).isoformat(), + "created_at": created_at or _rfc3339_timestamp(datetime.now(timezone.utc)), "batch": events, } if historical_migration: diff --git a/posthog/capture_send.py b/posthog/capture_send.py index 0cdf533ca..c7f3714a0 100644 --- a/posthog/capture_send.py +++ b/posthog/capture_send.py @@ -47,7 +47,7 @@ _get_capture_session, normalize_host, ) -from posthog.utils import _uuid7, remove_trailing_slash +from posthog.utils import _rfc3339_timestamp, _uuid7, remove_trailing_slash if TYPE_CHECKING: import requests @@ -368,7 +368,7 @@ def _post_v1( _HEADER_SDK_INFO: sdk_info, _HEADER_ATTEMPT: str(attempt), _HEADER_REQUEST_ID: request_id, - _HEADER_REQUEST_TIMESTAMP: datetime.now(timezone.utc).isoformat(), + _HEADER_REQUEST_TIMESTAMP: _rfc3339_timestamp(datetime.now(timezone.utc)), } body, encoding = _compress_v1(compression, data) if encoding is not None: @@ -533,7 +533,7 @@ def _send_v1_batch( request_id = str(_uuid7()) # Hoisted once so the batch envelope is byte-identical across retry attempts # (only the events list shrinks and the attempt header increments). - created_at = datetime.now(timezone.utc).isoformat() + created_at = _rfc3339_timestamp(datetime.now(timezone.utc)) pending_events = [_to_v1_event(m) for m in batch] pending_uuids = [_canonical_uuid(e["uuid"]) for e in pending_events] # (uuid, details) for every event the server dropped, across all attempts. diff --git a/posthog/client.py b/posthog/client.py index c59a78e16..6f679bce7 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -147,6 +147,7 @@ SizeLimitedDict, clean, _normalize_timestamp, + _rfc3339_timestamp, _is_personal_api_key, _drop_invalid_distinct_id, _drop_invalid_event_name, @@ -2726,7 +2727,7 @@ def _enqueue( "Invalid timestamp; it must be a datetime or an ISO 8601 string. " "Falling back to the current UTC time." ) - msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() + msg["timestamp"] = _rfc3339_timestamp(datetime.now(tz=timezone.utc)) self._normalize_event_uuid(msg) diff --git a/posthog/test/snapshots/event_family.json b/posthog/test/snapshots/event_family.json index 0d72f4cf5..9747da5c7 100644 --- a/posthog/test/snapshots/event_family.json +++ b/posthog/test/snapshots/event_family.json @@ -28,7 +28,7 @@ } ] }, - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05.000000+00:00", "uuid": "00000000-0000-4000-8000-000000000001" }, { @@ -43,7 +43,7 @@ "plan": "pro" } }, - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05.000000+00:00", "uuid": "00000000-0000-4000-8000-000000000002" }, { @@ -56,7 +56,7 @@ "alias": "user-123", "distinct_id": "anonymous-789" }, - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05.000000+00:00", "uuid": "00000000-0000-4000-8000-000000000003" }, { @@ -73,18 +73,18 @@ "$group_type": "company", "$is_server": true }, - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05.000000+00:00", "uuid": "00000000-0000-4000-8000-000000000004" } ], - "created_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05.000000+00:00" }, "headers": { "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", "PostHog-Attempt": "1", "PostHog-Request-Id": "", - "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05.000000+00:00", "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, diff --git a/posthog/test/snapshots/exception_event.json b/posthog/test/snapshots/exception_event.json index f4ed6660b..f1336bd95 100644 --- a/posthog/test/snapshots/exception_event.json +++ b/posthog/test/snapshots/exception_event.json @@ -127,18 +127,18 @@ "component": "checkout", "severity": "high" }, - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05.000000+00:00", "uuid": "00000000-0000-4000-8000-000000000005" } ], - "created_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05.000000+00:00" }, "headers": { "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", "PostHog-Attempt": "1", "PostHog-Request-Id": "", - "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05.000000+00:00", "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py index e4a7c7633..2b0a9c7b7 100644 --- a/posthog/test/test_capture_event.py +++ b/posthog/test/test_capture_event.py @@ -1,5 +1,6 @@ import unittest from datetime import datetime, timedelta, timezone +from unittest import mock from parameterized import parameterized @@ -61,13 +62,22 @@ def test_other_forms_are_rejected(self, _name, value) -> None: self.assertIsNone(_canonical_event_uuid(value)) +def _whole_second_clock() -> "mock._patch": + whole_second = datetime(2026, 6, 27, 12, tzinfo=timezone.utc) + return mock.patch( + "posthog.capture_event.datetime", + wraps=datetime, + **{"now.return_value": whole_second}, + ) + + class TestToV1Event(unittest.TestCase): def test_required_fields_preserved(self) -> None: event = _to_v1_event(_legacy_msg(event="signed_up")) self.assertEqual(event["event"], "signed_up") self.assertEqual(event["uuid"], "0190000000007000800000000000000a") self.assertEqual(event["distinct_id"], "user-1") - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00.000000+00:00") def test_strips_lib_and_lib_version(self) -> None: event = _to_v1_event(_legacy_msg()) @@ -258,16 +268,16 @@ def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: ) ) ) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00.000000+00:00") def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00.000000+00:00") def test_timestamp_none_defaults_to_utc_now(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp=None)) - parsed = datetime.fromisoformat(event["timestamp"]) - self.assertEqual(parsed.tzinfo, timezone.utc) + with _whole_second_clock(): + event = _to_v1_event(_legacy_msg(timestamp=None)) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00.000000+00:00") class TestBuildV1BatchBody(unittest.TestCase): @@ -278,10 +288,10 @@ def test_envelope_shape_and_no_legacy_fields(self) -> None: self.assertNotIn("api_key", body) self.assertNotIn("sent_at", body) - def test_created_at_is_tz_aware_rfc3339(self) -> None: - body = _build_v1_batch_body([]) - parsed = datetime.fromisoformat(body["created_at"]) - self.assertIsNotNone(parsed.tzinfo) + def test_created_at_defaults_to_utc_now(self) -> None: + with _whole_second_clock(): + body = _build_v1_batch_body([]) + self.assertEqual(body["created_at"], "2026-06-27T12:00:00.000000+00:00") def test_created_at_passthrough_used_verbatim(self) -> None: # _send_v1_batch hoists created_at and passes it in so it stays stable diff --git a/posthog/test/test_capture_invariants.py b/posthog/test/test_capture_invariants.py index 5c931cde4..bf7f2c7bb 100644 --- a/posthog/test/test_capture_invariants.py +++ b/posthog/test/test_capture_invariants.py @@ -1,12 +1,19 @@ +import asyncio import json -from typing import Any, Optional +from contextlib import ExitStack, contextmanager +from datetime import datetime, timezone +from typing import Any, Iterator, Optional, Union from unittest import mock import pytest from requests import Response from posthog import AsyncPosthog, CaptureCompression, Client -from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH +from posthog.capture_send import ( + _CAPTURE_AI_V1_PATH, + _CAPTURE_V1_PATH, + _HEADER_REQUEST_TIMESTAMP, +) _UUID = "01890000-0000-7000-8000-000000000001" _TIMESTAMP = "2026-01-02T03:04:05+00:00" @@ -34,16 +41,31 @@ def _event_kwargs() -> dict[str, Any]: } +def _ok_response(events: list[dict]) -> Response: + response = Response() + response.status_code = 200 + response._content = json.dumps( + {"results": {event["uuid"]: {"result": "ok"} for event in events}} + ).encode() + return response + + def _record_wire(sent: list[tuple[str, list[dict]]]): def post(url, data=None, **kwargs): events = json.loads(data)["batch"] sent.append((url, events)) - response = Response() - response.status_code = 200 - response._content = json.dumps( - {"results": {event["uuid"]: {"result": "ok"} for event in events}} - ).encode() - return response + return _ok_response(events) + + session = mock.Mock() + session.post.side_effect = post + return mock.patch("posthog.capture_send._get_capture_session", return_value=session) + + +def _record_requests(requests: list[dict[str, Any]]): + def post(url, data=None, headers=None, **kwargs): + body = json.loads(data) + requests.append({"body": body, "headers": headers}) + return _ok_response(body["batch"]) session = mock.Mock() session.post.side_effect = post @@ -109,3 +131,74 @@ async def test_pass_through_before_send_leaves_async_wire_unchanged(method_name, assert [url.endswith(path) for url, _ in without_hook] == [True] assert with_hook == without_hook assert without_hook[0][1][0]["options"]["process_person_profile"] is False + + +_WHOLE_SECOND = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) +_WHOLE_SECOND_WIRE = "2026-01-02T03:04:05.000000+00:00" + + +@contextmanager +def _whole_second_clock() -> Iterator[None]: + """Freeze ``datetime.now`` at a whole second where capture reads the clock.""" + with ExitStack() as stack: + for module in ("client", "async_client", "capture_send"): + stack.enter_context( + mock.patch( + f"posthog.{module}.datetime", + wraps=datetime, + **{"now.return_value": _WHOLE_SECOND}, + ) + ) + yield + + +def _capture_on_path( + path: str, timestamp: Union[str, datetime, None] +) -> list[dict[str, Any]]: + requests: list[dict[str, Any]] = [] + kwargs: dict[str, Any] = {"distinct_id": "user-1", "timestamp": timestamp} + with _record_requests(requests), _whole_second_clock(): + if path == "async": + + async def send() -> None: + client = AsyncPosthog( + "test-key", capture_compression=CaptureCompression.NONE + ) + assert client.capture("event", **kwargs) + await client.shutdown() + + asyncio.run(send()) + else: + client = Client( + "test-key", + sync_mode=path == "sync_mode", + capture_compression=CaptureCompression.NONE, + ) + assert client.capture("event", **kwargs) + client.shutdown() + return requests + + +@pytest.mark.parametrize("path", ["sync_mode", "sync", "async"]) +@pytest.mark.parametrize( + ("timestamp", "expected"), + [ + (None, _WHOLE_SECOND_WIRE), + ("not-an-iso-timestamp", _WHOLE_SECOND_WIRE), + (_WHOLE_SECOND, _WHOLE_SECOND_WIRE), + ("2026-01-02T03:04:05Z", _WHOLE_SECOND_WIRE), + ("2026-01-02T03:04:05.000Z", _WHOLE_SECOND_WIRE), + ("2026-01-02T03:04:05.120Z", "2026-01-02T03:04:05.120000+00:00"), + ("2026-01-02T03:04:05.123456Z", "2026-01-02T03:04:05.123456+00:00"), + ], +) +def test_wire_timestamps_keep_fractional_digits_on_whole_seconds( + path, timestamp, expected +): + requests = _capture_on_path(path, timestamp) + + assert len(requests) == 1 + body, headers = requests[0]["body"], requests[0]["headers"] + assert [event["timestamp"] for event in body["batch"]] == [expected] + assert body["created_at"] == _WHOLE_SECOND_WIRE + assert headers[_HEADER_REQUEST_TIMESTAMP] == _WHOLE_SECOND_WIRE diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index b7fb301aa..2f5f7b3b4 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1830,7 +1830,7 @@ def test_advanced_capture(self): batch_data = sent_batch(mock_post) msg = batch_data[0] - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") self.assertEqual(msg["properties"]["property"], "value") self.assertEqual(msg["event"], "python test event") self.assertEqual(msg["properties"]["$lib"], "posthog-python") @@ -1851,7 +1851,7 @@ def test_capture_converts_aware_timestamp_to_utc_without_changing_instant(self): ) msg = sent_batch(mock_post)[0] - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") def test_capture_converts_parseable_timestamp_string_to_utc(self): with patch_capture_send("client") as mock_post: @@ -1863,7 +1863,7 @@ def test_capture_converts_parseable_timestamp_string_to_utc(self): ) msg = sent_batch(mock_post)[0] - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") @parameterized.expand(["2026-06-27", "not-an-iso-timestamp"]) def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestamp): @@ -1883,7 +1883,7 @@ def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestam self.assertIsNotNone(result) msg = sent_batch(mock_post)[0] - self.assertEqual(msg["timestamp"], "2026-06-27T12:30:00+00:00") + self.assertEqual(msg["timestamp"], "2026-06-27T12:30:00.000000+00:00") mock_warning.assert_called_once() self.assertNotIn(timestamp, repr(mock_warning.call_args)) @@ -2026,7 +2026,7 @@ def test_advanced_set(self): batch_data = sent_batch(mock_post) msg = batch_data[0] - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") self.assertEqual(msg["$set"]["trait"], "value") self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertEqual(msg["properties"]["$lib_version"], VERSION) @@ -2070,7 +2070,7 @@ def test_advanced_set_once(self): batch_data = sent_batch(mock_post) msg = batch_data[0] - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") self.assertEqual(msg["$set_once"]["trait"], "value") self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertEqual(msg["properties"]["$lib_version"], VERSION) @@ -2167,7 +2167,7 @@ def test_advanced_group_identify(self): "$is_server": True, }, ) - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") def test_advanced_group_identify_with_distinct_id(self): with patch_capture_send("client") as mock_post: @@ -2203,7 +2203,7 @@ def test_advanced_group_identify_with_distinct_id(self): "$is_server": True, }, ) - self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00.000000+00:00") @parameterized.expand( [ diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 9863cf4c0..996832674 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -159,7 +159,7 @@ def test_normalize_timestamp_treats_future_naive_string_as_utc(self): mock_datetime.now.return_value = now.replace(tzinfo=None) normalized = utils._normalize_timestamp("2030-01-01T12:00:00") - assert normalized == "2030-01-01T12:00:00+00:00" + assert normalized == "2030-01-01T12:00:00.000000+00:00" finally: if original_tz is None: os.environ.pop("TZ", None) @@ -181,7 +181,7 @@ def test_normalize_timestamp_converts_compact_offset_to_utc(self): normalized = utils._normalize_timestamp("2026-06-27T12:00:00+0530") mock_datetime.fromisoformat.assert_called_once_with("2026-06-27T12:00:00+05:30") - assert normalized == "2026-06-27T06:30:00+00:00" + assert normalized == "2026-06-27T06:30:00.000000+00:00" @parameterized.expand(["2026-06-27", "20260627", "not-Z-an-iso-timestamp"]) def test_normalize_timestamp_rejects_invalid_string(self, timestamp): diff --git a/posthog/utils.py b/posthog/utils.py index dbb94cbf3..2c7a0867c 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -67,6 +67,15 @@ def _uuid7() -> UUID: return UUID(int=value) +def _rfc3339_timestamp(dt: datetime) -> str: + """Format an aware datetime as a UTC RFC 3339 string for the capture wire. + + The fraction is always six digits, even when zero, because plain + ``isoformat()`` drops it for whole seconds. + """ + return dt.astimezone(timezone.utc).isoformat(timespec="microseconds") + + def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: """Normalize a datetime or parseable ISO datetime string to UTC.""" parsed_timestamp: datetime @@ -89,7 +98,7 @@ def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: ) from None else: parsed_timestamp = timestamp - return guess_timezone(parsed_timestamp).isoformat() + return _rfc3339_timestamp(guess_timezone(parsed_timestamp)) def remove_trailing_slash(host: str) -> str: From ce45cbbbde3c08bc8716ef0bae61743da56e6c38 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 2 Nov 2026 10:13:06 -0800 Subject: [PATCH 3/5] test: cover the capture timestamp formatter directly Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_utils.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 996832674..d036b40fa 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -176,6 +176,30 @@ def test_normalize_timestamp_converts_datetime_to_utc(self): utils._normalize_timestamp(timestamp) == "2026-01-15T12:30:45.123456+00:00" ) + @parameterized.expand( + [ + ( + "whole_second_keeps_zero_fraction", + datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + "2026-01-15T12:00:00.000000+00:00", + ), + ( + "microseconds_kept", + datetime(2026, 1, 15, 12, 0, 0, 120000, tzinfo=timezone.utc), + "2026-01-15T12:00:00.120000+00:00", + ), + ( + "offset_converted_to_utc", + datetime(2026, 1, 15, 7, 30, 0, tzinfo=timezone(timedelta(hours=-5))), + "2026-01-15T12:30:00.000000+00:00", + ), + ] + ) + def test_rfc3339_timestamp_always_writes_the_fraction_in_utc( + self, _name: str, value: datetime, expected: str + ): + assert utils._rfc3339_timestamp(value) == expected + def test_normalize_timestamp_converts_compact_offset_to_utc(self): with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: normalized = utils._normalize_timestamp("2026-06-27T12:00:00+0530") From cc6d54c0c670f9a0a65b18cf8c2eec1f95008232 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 2 Nov 2026 11:26:55 -0800 Subject: [PATCH 4/5] docs: link the capture v1 SDK upgrade guide from the 8.0 guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index d99aa8186..6bbfd06bb 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -4,7 +4,7 @@ posthog 8.0 sends every event through capture v1 and removes the legacy capture Most apps upgrade without code changes. Read the checklist first, then the sections that apply to you. - +For the changes every capture v1 SDK shares, such as event options, limits and per-event results, see [Upgrading to capture v1 SDKs](https://posthog.com/docs/libraries/capture-v1). ## Checklist From 22d202ace19dae2f8b2237b0ed8b597ce5dad5b3 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 2 Nov 2026 12:37:52 -0800 Subject: [PATCH 5/5] test: check timestamp UTC conversion under a non-UTC local zone Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_utils.py | 46 +++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index d036b40fa..d002f5eab 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -3,12 +3,12 @@ import sys import time import unittest -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from unittest import mock from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone, tzinfo from decimal import Decimal -from typing import Optional +from typing import Iterator, Optional from uuid import UUID from parameterized import parameterized @@ -22,6 +22,21 @@ FAKE_TEST_API_KEY = "random_key" +@contextmanager +def local_timezone(tz: str) -> Iterator[None]: + original_tz = os.environ.get("TZ") + os.environ["TZ"] = tz + time.tzset() + try: + yield + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + + class FakeRedis: def __init__(self, fail=False): self.store = {} @@ -125,10 +140,7 @@ def test_timezone_utils(self): @unittest.skipUnless(hasattr(time, "tzset"), "requires time.tzset") def test_guess_timezone_preserves_recent_naive_local_instant_as_utc(self): - original_tz = os.environ.get("TZ") - try: - os.environ["TZ"] = "EST5EDT" - time.tzset() + with local_timezone("EST5EDT"): local_now = datetime(2026, 1, 15, 7, 30, 45, 123456) utc_now = datetime(2026, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) @@ -140,19 +152,10 @@ def test_guess_timezone_preserves_recent_naive_local_instant_as_utc(self): assert normalized == utc_now assert normalized.isoformat() == "2026-01-15T12:30:45.123456+00:00" - finally: - if original_tz is None: - os.environ.pop("TZ", None) - else: - os.environ["TZ"] = original_tz - time.tzset() @unittest.skipUnless(hasattr(time, "tzset"), "requires time.tzset") def test_normalize_timestamp_treats_future_naive_string_as_utc(self): - original_tz = os.environ.get("TZ") - try: - os.environ["TZ"] = "EST5EDT" - time.tzset() + with local_timezone("EST5EDT"): now = datetime(2026, 1, 15, 12, 30, tzinfo=timezone.utc) with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: @@ -160,12 +163,6 @@ def test_normalize_timestamp_treats_future_naive_string_as_utc(self): normalized = utils._normalize_timestamp("2030-01-01T12:00:00") assert normalized == "2030-01-01T12:00:00.000000+00:00" - finally: - if original_tz is None: - os.environ.pop("TZ", None) - else: - os.environ["TZ"] = original_tz - time.tzset() def test_normalize_timestamp_converts_datetime_to_utc(self): timestamp = datetime( @@ -195,10 +192,13 @@ def test_normalize_timestamp_converts_datetime_to_utc(self): ), ] ) + @unittest.skipUnless(hasattr(time, "tzset"), "requires time.tzset") def test_rfc3339_timestamp_always_writes_the_fraction_in_utc( self, _name: str, value: datetime, expected: str ): - assert utils._rfc3339_timestamp(value) == expected + # A non-UTC local zone tells UTC conversion apart from local conversion. + with local_timezone("EST5EDT"): + assert utils._rfc3339_timestamp(value) == expected def test_normalize_timestamp_converts_compact_offset_to_utc(self): with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: