From b276fe14737ee2f1d3109cefb618e3ee92826fd2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 08:06:51 +0200 Subject: [PATCH 1/9] fix: normalize SDK timestamps to UTC --- .sampo/changesets/gallant-prince-ukko.md | 5 +++ posthog/__init__.py | 19 +++++++---- posthog/args.py | 8 +++-- posthog/capture_v1.py | 18 +++++----- posthog/client.py | 20 ++++++----- posthog/exception_utils.py | 4 ++- posthog/test/test_capture_v1.py | 24 ++++++++++++- posthog/test/test_client.py | 43 +++++++++++++++++++++++- posthog/test/test_exception_utils.py | 18 ++++++++++ posthog/test/test_module.py | 7 ++++ posthog/test/test_request.py | 4 ++- posthog/test/test_utils.py | 30 +++++++++++++++++ posthog/utils.py | 23 ++++++++++--- 13 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 .sampo/changesets/gallant-prince-ukko.md create mode 100644 posthog/test/test_exception_utils.py diff --git a/.sampo/changesets/gallant-prince-ukko.md b/.sampo/changesets/gallant-prince-ukko.md new file mode 100644 index 00000000..a8acf00a --- /dev/null +++ b/.sampo/changesets/gallant-prince-ukko.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Normalize SDK timestamps to UTC diff --git a/posthog/__init__.py b/posthog/__init__.py index a1825821..96238cac 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -437,7 +437,8 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: **kwargs: Optional arguments including: distinct_id: Unique identifier for the user properties: Dict of event properties - timestamp: When the event occurred + timestamp: When the event occurred. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Unique identifier for this event. If omitted, one is generated and returned. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with @@ -545,7 +546,8 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: distinct_id: Unique identifier for the user. Falls back to the context distinct ID; if none exists, this call does nothing. properties: Dict of person properties to set. - timestamp: When the properties were set. + timestamp: When the properties were set. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Unique identifier for this operation. If omitted, one is generated and returned. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and @@ -577,7 +579,8 @@ def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: distinct_id: Unique identifier for the user. Falls back to the context distinct ID; if none exists, this call does nothing. properties: Dict of person properties to set only once. - timestamp: When the properties were set. + timestamp: When the properties were set. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Unique identifier for this operation. If omitted, one is generated and returned. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and @@ -604,7 +607,7 @@ def group_identify( group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, - timestamp: Optional[datetime.datetime] = None, + timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, @@ -618,7 +621,8 @@ def group_identify( group_key: Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty. properties: Properties to set on the group - timestamp: Optional timestamp for the event + timestamp: Optional timestamp for the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup distinct_id: Optional distinct ID of the user performing the action @@ -651,7 +655,7 @@ def group_identify( def alias( previous_id: ID_TYPES, distinct_id: str, - timestamp: Optional[datetime.datetime] = None, + timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, ) -> Optional[str]: @@ -661,7 +665,8 @@ def alias( Args: previous_id: The unique ID of the user before distinct_id: The current unique id - timestamp: Optional timestamp for the event + timestamp: Optional timestamp for the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup diff --git a/posthog/args.py b/posthog/args.py index 313edc0f..42083b92 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -31,7 +31,9 @@ class OptionalCaptureArgs(TypedDict): distinct_id is used, if available, otherwise a UUID is generated, and the event is marked as personless. Setting context-level distinct_id's is recommended. properties: Dictionary of properties to track with the event - timestamp: When the event occurred (defaults to current time) + timestamp: When the event occurred (defaults to current time). UTC is + preferred; non-UTC datetimes and parseable ISO timestamp strings are + converted to UTC. uuid: Unique identifier for this specific event. If not provided, one is generated. The event UUID is returned, so you can correlate it with actions in your app (like showing users an error ID if you capture an exception). If provided, it must be a valid UUID string or @@ -73,7 +75,9 @@ class OptionalSetArgs(TypedDict): distinct_id is used, if available, otherwise this function does nothing. Setting context-level distinct_id's is recommended. properties: Dictionary of properties to set on the person - timestamp: When the properties were set (defaults to current time) + timestamp: When the properties were set (defaults to current time). UTC + is preferred; non-UTC datetimes and parseable ISO timestamp strings + are converted to UTC. uuid: Unique identifier for this operation. If not provided, one is generated. This UUID is returned, so you can correlate it with actions in your app. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored diff --git a/posthog/capture_v1.py b/posthog/capture_v1.py index 4a52c60a..f9f35600 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_v1.py @@ -53,7 +53,7 @@ _get_session, normalize_host, ) -from posthog.utils import guess_timezone as _guess_timezone, remove_trailing_slash +from posthog.utils import _normalize_timestamp, remove_trailing_slash if TYPE_CHECKING: import requests @@ -146,19 +146,17 @@ def _coerce_str(value: Any) -> Optional[str]: def _v1_timestamp(timestamp: Any) -> str: - """Return a timezone-aware RFC3339 timestamp string. + """Return a UTC RFC3339 timestamp string. - Messages off the queue already carry an ISO-8601 string (``_enqueue`` runs - ``guess_timezone(...).isoformat()``), so that is passed through. A - ``datetime`` is normalized to timezone-aware and serialized; a missing value - defaults to now in UTC. The v1 server parses strictly with - ``DateTime::parse_from_rfc3339`` and rejects naive timestamps. + Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` + normalizes canonical datetimes), so that is passed through. A ``datetime`` + is normalized to UTC and serialized; a missing value defaults to now in UTC. + The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and + rejects naive timestamps. """ if timestamp is None: return datetime.now(timezone.utc).isoformat() - if isinstance(timestamp, datetime): - return _guess_timezone(timestamp).isoformat() - return timestamp + return _normalize_timestamp(timestamp) def _to_v1_event(msg: dict) -> dict: diff --git a/posthog/client.py b/posthog/client.py index 1091baa9..b295ce63 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -109,7 +109,7 @@ RedisFlagCache, SizeLimitedDict, clean, - guess_timezone, + _normalize_timestamp, system_context, ) from posthog.version import VERSION @@ -1496,7 +1496,8 @@ def capture( event: The event name to capture. distinct_id: The distinct ID of the user. properties: A dictionary of properties to include with the event. - timestamp: The timestamp of the event. + timestamp: The timestamp of the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. @@ -1748,7 +1749,8 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: Args: distinct_id: The distinct ID of the user. properties: A dictionary of properties to set. - timestamp: The timestamp of the event. + timestamp: The timestamp of the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. @@ -1798,7 +1800,8 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: Args: distinct_id: The distinct ID of the user. properties: A dictionary of properties to set once. - timestamp: The timestamp of the event. + timestamp: The timestamp of the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. @@ -1858,7 +1861,8 @@ def group_identify( group_key: The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty. properties: A dictionary of properties to set on the group. - timestamp: The timestamp of the event. + timestamp: The timestamp of the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. @@ -1931,7 +1935,8 @@ def alias( distinct_id: The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available. - timestamp: The timestamp of the event. + timestamp: The timestamp of the event. UTC is preferred; non-UTC + datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. @@ -2245,8 +2250,7 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): timestamp = datetime.now(tz=timezone.utc) # add common - timestamp = guess_timezone(timestamp) - msg["timestamp"] = timestamp.isoformat() + msg["timestamp"] = _normalize_timestamp(timestamp) self._normalize_event_uuid(msg) diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index a686a29e..37b0f5f2 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -15,7 +15,7 @@ import sys import types from collections import Counter -from datetime import datetime +from datetime import datetime, timezone from types import FrameType, TracebackType # noqa: F401 from typing import ( # noqa: F401 TYPE_CHECKING, @@ -214,6 +214,8 @@ def to_timestamp(value): def format_timestamp(value): # type: (datetime) -> str + if value.tzinfo is not None and value.utcoffset() is not None: + value = value.astimezone(timezone.utc) return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_v1.py index 0846ae85..9180ee0e 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_v1.py @@ -1,7 +1,7 @@ import json import unittest import zlib -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest import mock import zstandard @@ -13,6 +13,7 @@ _CAPTURE_V1_PATH, _HEADER_ATTEMPT, _HEADER_REQUEST_ID, + _HEADER_REQUEST_TIMESTAMP, _HEADER_SDK_INFO, _MAX_BACKOFF_SECONDS, CaptureV1Error, @@ -336,6 +337,25 @@ def test_timestamp_naive_datetime_made_tz_aware(self) -> None: parsed = datetime.fromisoformat(event["timestamp"]) self.assertIsNotNone(parsed.tzinfo) + def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event( + _legacy_msg( + timestamp=datetime( + 2026, + 6, + 27, + 17, + 45, + tzinfo=timezone(timedelta(hours=5, minutes=45)), + ) + ) + ) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + def test_timestamp_none_defaults_to_utc_now(self) -> None: event = _to_v1_event(_legacy_msg(timestamp=None)) parsed = datetime.fromisoformat(event["timestamp"]) @@ -395,6 +415,8 @@ def test_required_headers_present(self) -> None: self.assertEqual(headers[_HEADER_REQUEST_ID], "req-123") self.assertTrue(headers[_HEADER_SDK_INFO].startswith("posthog-python/")) self.assertEqual(headers["Content-Type"], "application/json") + request_timestamp = datetime.fromisoformat(headers[_HEADER_REQUEST_TIMESTAMP]) + self.assertEqual(request_timestamp.utcoffset(), timedelta(0)) def test_no_api_key_in_body(self) -> None: # v1 authenticates via the Bearer header; the key must not leak into the body. diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 4b07ec72..534065ae 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -9,7 +9,7 @@ import unittest import warnings from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor -from datetime import datetime +from datetime import datetime, timedelta, timezone from unittest import mock from uuid import UUID, uuid4 @@ -1671,6 +1671,47 @@ def test_advanced_capture(self): self.assertEqual(msg["distinct_id"], "distinct_id") 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: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + client.capture( + "python test event", + distinct_id="distinct_id", + timestamp=datetime( + 2014, 9, 3, 5, 30, tzinfo=timezone(timedelta(hours=5, minutes=30)) + ), + ) + + msg = mock_post.call_args[1]["batch"][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: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + client.capture( + "python test event", + distinct_id="distinct_id", + timestamp="2014-09-03T05:30:00+05:30", + ) + + msg = mock_post.call_args[1]["batch"][0] + self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + + 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: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + client.capture( + "python test event", + distinct_id="distinct_id", + properties={"caller_datetime": property_value}, + ) + + msg = mock_post.call_args[1]["batch"][0] + self.assertIs(msg["properties"]["caller_datetime"], property_value) + def test_groups_capture(self): with mock.patch("posthog.client.batch_post") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) diff --git a/posthog/test/test_exception_utils.py b/posthog/test/test_exception_utils.py new file mode 100644 index 00000000..007d575b --- /dev/null +++ b/posthog/test/test_exception_utils.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta, timezone + +from posthog.exception_utils import format_timestamp + + +def test_format_timestamp_converts_aware_value_to_utc(): + value = datetime( + 2026, + 6, + 27, + 17, + 45, + 0, + 123456, + tzinfo=timezone(timedelta(hours=5, minutes=45)), + ) + + assert format_timestamp(value) == "2026-06-27T12:00:00.123456Z" diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index 02106462..9e3db5a4 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -1,4 +1,6 @@ +import datetime import unittest +from typing import get_type_hints from unittest import mock from parameterized import parameterized @@ -168,6 +170,11 @@ def test_group_identify_distinct_id_defaults_to_none(self): call_kwargs = self.mock_client.group_identify.call_args[1] self.assertIsNone(call_kwargs["distinct_id"]) + @parameterized.expand([("group_identify",), ("alias",)]) + def test_timestamp_annotation_accepts_datetime_and_string(self, function_name): + timestamp_type = get_type_hints(getattr(posthog, function_name))["timestamp"] + self.assertEqual(timestamp_type, datetime.datetime | str | None) + @parameterized.expand( [ ("get_all_flags", "get_all_flags"), diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index fe7f0eca..6a1226cc 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -1,7 +1,7 @@ import json import unittest import zlib -from datetime import date, datetime +from datetime import date, datetime, timedelta from unittest import mock import pytest @@ -85,6 +85,8 @@ def test_post_sends_snake_case_sent_at(key, expected_present): data = json.loads(mock_session.post.call_args.kwargs["data"]) assert (key in data) is expected_present + if key == "sent_at": + assert datetime.fromisoformat(data[key]).utcoffset() == timedelta(0) def test_post_sends_project_api_key_field(): diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index c4383712..1bf8e211 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -1,4 +1,5 @@ import json +import os import sys import time import unittest @@ -101,6 +102,35 @@ def test_timezone_utils(self): fixed_old = utils.guess_timezone(old_naive) assert fixed_old == old_naive.replace(tzinfo=timezone.utc) + @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() + local_now = datetime(2026, 1, 15, 7, 30, 45, 123456) + utc_now = datetime(2026, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + + with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: + mock_datetime.now.side_effect = ( + lambda tz=None: local_now if tz is None else utc_now.astimezone(tz) + ) + normalized = utils.guess_timezone(local_now) + + 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() + + def test_normalize_timestamp_preserves_unparseable_string(self): + assert ( + utils._normalize_timestamp("not-an-iso-timestamp") == "not-an-iso-timestamp" + ) + def test_total_seconds(self): delta = timedelta(days=2, seconds=3, microseconds=4) assert utils.total_seconds(delta) == 172803.000004 diff --git a/posthog/utils.py b/posthog/utils.py index 63aec1aa..e5a5e56d 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -7,7 +7,7 @@ from dataclasses import asdict, is_dataclass from datetime import date, datetime, timezone, timedelta from decimal import Decimal -from typing import Any, Optional +from typing import Any, Optional, Union from uuid import UUID import sys import platform @@ -30,7 +30,7 @@ def total_seconds(delta: timedelta) -> float: def guess_timezone(dt: datetime) -> datetime: - """Attempts to convert a naive datetime to an aware datetime.""" + """Convert a datetime to UTC, guessing the timezone for naive values.""" if is_naive(dt): # attempts to guess the datetime.datetime.now() local timezone # case, and then defaults to utc @@ -38,12 +38,25 @@ def guess_timezone(dt: datetime) -> datetime: if total_seconds(delta) < 5: # pragma: no mutate # this was created using datetime.datetime.now(), # so use the current system local timezone - return dt.replace(tzinfo=datetime.now().astimezone().tzinfo) + dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo) else: # at this point, the best we can do is guess UTC - return dt.replace(tzinfo=timezone.utc) + dt = dt.replace(tzinfo=timezone.utc) - return dt + return dt.astimezone(timezone.utc) + + +def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: + """Normalize a canonical datetime or parseable ISO string to UTC.""" + parsed_timestamp: datetime + if isinstance(timestamp, str): + try: + parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + return timestamp + else: + parsed_timestamp = timestamp + return guess_timezone(parsed_timestamp).isoformat() def remove_trailing_slash(host: str) -> str: From 40d53d2e17c8445364d408d8e5984f974b6c1702 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 08:10:04 +0200 Subject: [PATCH 2/9] fix: preserve future naive timestamp values --- posthog/test/test_utils.py | 20 ++++++++++++++++++++ posthog/utils.py | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 1bf8e211..708a029b 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -126,6 +126,26 @@ def test_guess_timezone_preserves_recent_naive_local_instant_as_utc(self): 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() + now = datetime(2026, 1, 15, 12, 30, tzinfo=timezone.utc) + + with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: + 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" + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + def test_normalize_timestamp_preserves_unparseable_string(self): assert ( utils._normalize_timestamp("not-an-iso-timestamp") == "not-an-iso-timestamp" diff --git a/posthog/utils.py b/posthog/utils.py index e5a5e56d..40ac8f1e 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -34,8 +34,8 @@ def guess_timezone(dt: datetime) -> datetime: if is_naive(dt): # attempts to guess the datetime.datetime.now() local timezone # case, and then defaults to utc - delta = datetime.now() - dt - if total_seconds(delta) < 5: # pragma: no mutate + delta = total_seconds(datetime.now() - dt) + if 0 <= delta < 5: # pragma: no mutate # this was created using datetime.datetime.now(), # so use the current system local timezone dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo) From 1de13d9b9368de91411f18c02edb7597444f3736 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 08:12:34 +0200 Subject: [PATCH 3/9] chore: update timestamp public API snapshot --- posthog/client.py | 1 + references/public_api_snapshot.txt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index b295ce63..0aee03ac 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -110,6 +110,7 @@ SizeLimitedDict, clean, _normalize_timestamp, + guess_timezone as guess_timezone, system_context, ) from posthog.version import VERSION diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index ea16c689..b2adc71e 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1035,7 +1035,7 @@ function posthog.ai.utils.merge_system_prompt(kwargs: Dict[str, Any], provider: function posthog.ai.utils.merge_usage_stats(target: TokenUsage, source: TokenUsage, mode: str = 'incremental') -> None function posthog.ai.utils.serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]] function posthog.ai.utils.with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any) -function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] function posthog.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1119,7 +1119,7 @@ function posthog.get_feature_flag_payload(key: str, distinct_id: ID_TYPES, match function posthog.get_feature_flag_result(key: str, distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FeatureFlagResult] function posthog.get_remote_config_payload(key: str) function posthog.get_tags() -> Dict[str, Any] -function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) function posthog.join() -> None From 4bf4cbea82449bb095a98084758ccfa3a8849e54 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 08:44:16 +0200 Subject: [PATCH 4/9] test: satisfy timestamp mutation coverage --- posthog/test/test_utils.py | 12 +++++++++++- posthog/utils.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 708a029b..745e4a27 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -146,9 +146,19 @@ def test_normalize_timestamp_treats_future_naive_string_as_utc(self): os.environ["TZ"] = original_tz time.tzset() + def test_normalize_timestamp_converts_datetime_to_utc(self): + timestamp = datetime( + 2026, 1, 15, 7, 30, 45, 123456, tzinfo=timezone(timedelta(hours=-5)) + ) + + assert ( + utils._normalize_timestamp(timestamp) == "2026-01-15T12:30:45.123456+00:00" + ) + def test_normalize_timestamp_preserves_unparseable_string(self): assert ( - utils._normalize_timestamp("not-an-iso-timestamp") == "not-an-iso-timestamp" + utils._normalize_timestamp("not-Z-an-iso-timestamp") + == "not-Z-an-iso-timestamp" ) def test_total_seconds(self): diff --git a/posthog/utils.py b/posthog/utils.py index 40ac8f1e..37c0f1c0 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -38,7 +38,11 @@ def guess_timezone(dt: datetime) -> datetime: if 0 <= delta < 5: # pragma: no mutate # this was created using datetime.datetime.now(), # so use the current system local timezone - dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo) + # Equivalent to converting the naive value directly because this path + # only handles values created in the current local timezone. + dt = dt.replace( + tzinfo=datetime.now().astimezone().tzinfo + ) # pragma: no mutate else: # at this point, the best we can do is guess UTC dt = dt.replace(tzinfo=timezone.utc) @@ -51,7 +55,10 @@ def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: parsed_timestamp: datetime if isinstance(timestamp, str): try: - parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + # Python 3.10 needs the replacement; on 3.11+ fromisoformat accepts Z, + # making replacement mutations equivalent in the mutation-test runtime. + normalized_timestamp = timestamp.replace("Z", "+00:00") # pragma: no mutate + parsed_timestamp = datetime.fromisoformat(normalized_timestamp) except ValueError: return timestamp else: From 015688aca9e9dc2edf5f201ebb7d7913aaff3b1a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 11:30:19 +0200 Subject: [PATCH 5/9] docs: clarify UTC timestamp changeset --- .sampo/changesets/gallant-prince-ukko.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/gallant-prince-ukko.md b/.sampo/changesets/gallant-prince-ukko.md index a8acf00a..78c1e915 100644 --- a/.sampo/changesets/gallant-prince-ukko.md +++ b/.sampo/changesets/gallant-prince-ukko.md @@ -2,4 +2,4 @@ pypi/posthog: patch --- -Normalize SDK timestamps to UTC +Normalize SDK event timestamps to UTC, including datetime values and parseable ISO timestamp strings, and correct UTC serialization for exception frame timestamps From 46e2adca2081ef2e9dde81dd675b98a7a041e3da Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 13:05:47 +0200 Subject: [PATCH 6/9] fix: handle timestamp string edge cases --- posthog/test/test_utils.py | 10 ++++++++++ posthog/utils.py | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 745e4a27..5449e613 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -155,6 +155,16 @@ def test_normalize_timestamp_converts_datetime_to_utc(self): utils._normalize_timestamp(timestamp) == "2026-01-15T12:30:45.123456+00:00" ) + def test_normalize_timestamp_converts_compact_offset_to_utc(self): + assert ( + utils._normalize_timestamp("2026-06-27T12:00:00+0530") + == "2026-06-27T06:30:00+00:00" + ) + + @parameterized.expand(["2026-06-27", "20260627"]) + def test_normalize_timestamp_preserves_date_only_string(self, timestamp): + assert utils._normalize_timestamp(timestamp) == timestamp + def test_normalize_timestamp_preserves_unparseable_string(self): assert ( utils._normalize_timestamp("not-Z-an-iso-timestamp") diff --git a/posthog/utils.py b/posthog/utils.py index 37c0f1c0..26486b92 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -51,13 +51,18 @@ def guess_timezone(dt: datetime) -> datetime: def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: - """Normalize a canonical datetime or parseable ISO string to UTC.""" + """Normalize a datetime or parseable ISO datetime string to UTC.""" parsed_timestamp: datetime if isinstance(timestamp, str): + if re.fullmatch(r"\d{4}-?\d{2}-?\d{2}", timestamp): + return timestamp try: - # Python 3.10 needs the replacement; on 3.11+ fromisoformat accepts Z, - # making replacement mutations equivalent in the mutation-test runtime. + # Python 3.10 needs the replacements; on 3.11+ fromisoformat accepts Z + # and compact UTC offsets such as +0530. normalized_timestamp = timestamp.replace("Z", "+00:00") # pragma: no mutate + normalized_timestamp = re.sub( + r"([+-]\d{2})(\d{2})$", r"\1:\2", normalized_timestamp + ) parsed_timestamp = datetime.fromisoformat(normalized_timestamp) except ValueError: return timestamp From a71e50db85ecb3fc3f60050e091276c69e2c3bbe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 13:09:28 +0200 Subject: [PATCH 7/9] test: assert compact timestamp offset parsing --- posthog/test/test_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 5449e613..e3e0a524 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -156,10 +156,11 @@ def test_normalize_timestamp_converts_datetime_to_utc(self): ) def test_normalize_timestamp_converts_compact_offset_to_utc(self): - assert ( - utils._normalize_timestamp("2026-06-27T12:00:00+0530") - == "2026-06-27T06:30:00+00:00" - ) + with mock.patch("posthog.utils.datetime", wraps=datetime) as mock_datetime: + 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" @parameterized.expand(["2026-06-27", "20260627"]) def test_normalize_timestamp_preserves_date_only_string(self, timestamp): From aedc06351ad286ac75214a82d67615b852e67f66 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 13:13:49 +0200 Subject: [PATCH 8/9] fix: reject malformed event timestamps --- posthog/test/test_client.py | 13 +++++++++++++ posthog/test/test_utils.py | 13 ++++--------- posthog/utils.py | 8 ++++++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 534065ae..b7513bbb 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1697,6 +1697,19 @@ def test_capture_converts_parseable_timestamp_string_to_utc(self): msg = mock_post.call_args[1]["batch"][0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") + @parameterized.expand(["2026-06-27", "not-an-iso-timestamp"]) + def test_capture_drops_invalid_timestamp_before_sending(self, timestamp): + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + result = client.capture( + "python test event", + distinct_id="distinct_id", + timestamp=timestamp, + ) + + self.assertIsNone(result) + mock_post.assert_not_called() + def test_capture_does_not_normalize_datetime_properties(self): property_value = datetime( 2014, 9, 3, 5, 30, tzinfo=timezone(timedelta(hours=5, minutes=30)) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index e3e0a524..e075ff4a 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -162,15 +162,10 @@ def test_normalize_timestamp_converts_compact_offset_to_utc(self): mock_datetime.fromisoformat.assert_called_once_with("2026-06-27T12:00:00+05:30") assert normalized == "2026-06-27T06:30:00+00:00" - @parameterized.expand(["2026-06-27", "20260627"]) - def test_normalize_timestamp_preserves_date_only_string(self, timestamp): - assert utils._normalize_timestamp(timestamp) == timestamp - - def test_normalize_timestamp_preserves_unparseable_string(self): - assert ( - utils._normalize_timestamp("not-Z-an-iso-timestamp") - == "not-Z-an-iso-timestamp" - ) + @parameterized.expand(["2026-06-27", "20260627", "not-Z-an-iso-timestamp"]) + def test_normalize_timestamp_rejects_invalid_string(self, timestamp): + with self.assertRaisesRegex(ValueError, "Expected an ISO 8601 datetime string"): + utils._normalize_timestamp(timestamp) def test_total_seconds(self): delta = timedelta(days=2, seconds=3, microseconds=4) diff --git a/posthog/utils.py b/posthog/utils.py index 26486b92..e02037af 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -55,7 +55,9 @@ def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: parsed_timestamp: datetime if isinstance(timestamp, str): if re.fullmatch(r"\d{4}-?\d{2}-?\d{2}", timestamp): - return timestamp + raise ValueError( + f"Invalid timestamp {timestamp!r}. Expected an ISO 8601 datetime string." + ) try: # Python 3.10 needs the replacements; on 3.11+ fromisoformat accepts Z # and compact UTC offsets such as +0530. @@ -65,7 +67,9 @@ def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: ) parsed_timestamp = datetime.fromisoformat(normalized_timestamp) except ValueError: - return timestamp + raise ValueError( + f"Invalid timestamp {timestamp!r}. Expected an ISO 8601 datetime string." + ) from None else: parsed_timestamp = timestamp return guess_timezone(parsed_timestamp).isoformat() From aec2fbcfd0f03b5a17444a2f2c85ad0a00503dbd Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 14 Aug 2026 13:16:23 +0200 Subject: [PATCH 9/9] fix: fallback for invalid event timestamps --- posthog/client.py | 8 +++++++- posthog/test/test_client.py | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 0aee03ac..07dba362 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2251,7 +2251,13 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): timestamp = datetime.now(tz=timezone.utc) # add common - msg["timestamp"] = _normalize_timestamp(timestamp) + try: + msg["timestamp"] = _normalize_timestamp(timestamp) + except ValueError: + self.log.warning( + "Invalid timestamp %r. Falling back to the current UTC time.", timestamp + ) + msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() self._normalize_event_uuid(msg) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index b7513bbb..c44b699f 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1698,8 +1698,14 @@ def test_capture_converts_parseable_timestamp_string_to_utc(self): self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @parameterized.expand(["2026-06-27", "not-an-iso-timestamp"]) - def test_capture_drops_invalid_timestamp_before_sending(self, timestamp): - with mock.patch("posthog.client.batch_post") as mock_post: + 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, + mock.patch("posthog.client.datetime", wraps=datetime) as mock_datetime, + mock.patch.object(Client.log, "warning") as mock_warning, + ): + mock_datetime.now.return_value = now client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) result = client.capture( "python test event", @@ -1707,8 +1713,12 @@ def test_capture_drops_invalid_timestamp_before_sending(self, timestamp): timestamp=timestamp, ) - self.assertIsNone(result) - mock_post.assert_not_called() + self.assertIsNotNone(result) + msg = mock_post.call_args[1]["batch"][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 + ) def test_capture_does_not_normalize_datetime_properties(self): property_value = datetime(