Skip to content

Commit bde87ff

Browse files
fix: Fix non-UTC timestamps (#3461)
Fixes a bug where all `datetime` timestamps in an event payload were serialized as if they were UTC timestamps, even if they were non-UTC timestamps, completely ignoring the timezone. Now, we convert all datetime objects to UTC before formatting them as a UTC timestamp. Fixes #3453
1 parent ad39086 commit bde87ff

File tree

2 files changed

+48
-3
lines changed

2 files changed

+48
-3
lines changed

sentry_sdk/utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import threading
1212
import time
1313
from collections import namedtuple
14-
from datetime import datetime
14+
from datetime import datetime, timezone
1515
from decimal import Decimal
1616
from functools import partial, partialmethod, wraps
1717
from numbers import Real
@@ -228,7 +228,15 @@ def to_timestamp(value):
228228

229229
def format_timestamp(value):
230230
# type: (datetime) -> str
231-
return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
231+
"""Formats a timestamp in RFC 3339 format.
232+
233+
Any datetime objects with a non-UTC timezone are converted to UTC, so that all timestamps are formatted in UTC.
234+
"""
235+
utctime = value.astimezone(timezone.utc)
236+
237+
# We use this custom formatting rather than isoformat for backwards compatibility (we have used this format for
238+
# several years now), and isoformat is slightly different.
239+
return utctime.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
232240

233241

234242
def event_hint_with_exc_info(exc_info=None):

tests/test_utils.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import threading
22
import re
33
import sys
4-
from datetime import timedelta
4+
from datetime import timedelta, datetime, timezone
55
from unittest import mock
66

77
import pytest
@@ -13,6 +13,7 @@
1313
Components,
1414
Dsn,
1515
env_to_bool,
16+
format_timestamp,
1617
get_current_thread_meta,
1718
get_default_release,
1819
get_error_message,
@@ -950,3 +951,39 @@ def target():
950951
thread.start()
951952
thread.join()
952953
assert (main_thread.ident, main_thread.name) == results.get(timeout=1)
954+
955+
956+
@pytest.mark.parametrize(
957+
("datetime_object", "expected_output"),
958+
(
959+
(
960+
datetime(2021, 1, 1, tzinfo=timezone.utc),
961+
"2021-01-01T00:00:00.000000Z",
962+
), # UTC time
963+
(
964+
datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=2))),
965+
"2020-12-31T22:00:00.000000Z",
966+
), # UTC+2 time
967+
(
968+
datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=-7))),
969+
"2021-01-01T07:00:00.000000Z",
970+
), # UTC-7 time
971+
(
972+
datetime(2021, 2, 3, 4, 56, 7, 890123, tzinfo=timezone.utc),
973+
"2021-02-03T04:56:07.890123Z",
974+
), # UTC time all non-zero fields
975+
),
976+
)
977+
def test_format_timestamp(datetime_object, expected_output):
978+
formatted = format_timestamp(datetime_object)
979+
980+
assert formatted == expected_output
981+
982+
983+
def test_format_timestamp_naive():
984+
datetime_object = datetime(2021, 1, 1)
985+
timestamp_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z"
986+
987+
# Ensure that some timestamp is returned, without error. We currently treat these as local time, but this is an
988+
# implementation detail which we should not assert here.
989+
assert re.fullmatch(timestamp_regex, format_timestamp(datetime_object))

0 commit comments

Comments
 (0)