Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/gallant-prince-ukko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Normalize SDK event timestamps to UTC, including datetime values and parseable ISO timestamp strings, and correct UTC serialization for exception frame timestamps
19 changes: 12 additions & 7 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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

Expand Down
8 changes: 6 additions & 2 deletions posthog/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 8 additions & 10 deletions posthog/capture_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 19 additions & 8 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@
RedisFlagCache,
SizeLimitedDict,
clean,
guess_timezone,
_normalize_timestamp,
guess_timezone as guess_timezone,
system_context,
)
from posthog.version import VERSION
Expand Down Expand Up @@ -1496,7 +1497,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.
Expand Down Expand Up @@ -1748,7 +1750,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.
Expand Down Expand Up @@ -1798,7 +1801,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.
Expand Down Expand Up @@ -1858,7 +1862,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.
Expand Down Expand Up @@ -1931,7 +1936,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.
Expand Down Expand Up @@ -2245,8 +2251,13 @@ 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()
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)

Expand Down
4 changes: 3 additions & 1 deletion posthog/exception_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")


Expand Down
24 changes: 23 additions & 1 deletion posthog/test/test_capture_v1.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,6 +13,7 @@
_CAPTURE_V1_PATH,
_HEADER_ATTEMPT,
_HEADER_REQUEST_ID,
_HEADER_REQUEST_TIMESTAMP,
_HEADER_SDK_INFO,
_MAX_BACKOFF_SECONDS,
CaptureV1Error,
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 65 additions & 1 deletion posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1671,6 +1671,70 @@ 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")

@parameterized.expand(["2026-06-27", "not-an-iso-timestamp"])
def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestamp):
now = datetime(2026, 6, 27, 12, 30, tzinfo=timezone.utc)
with (
mock.patch("posthog.client.batch_post") as mock_post,
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",
distinct_id="distinct_id",
timestamp=timestamp,
)

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(
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)
Expand Down
18 changes: 18 additions & 0 deletions posthog/test/test_exception_utils.py
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions posthog/test/test_module.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import datetime
import unittest
from typing import get_type_hints
from unittest import mock

from parameterized import parameterized
Expand Down Expand Up @@ -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"),
Expand Down
Loading