Repository: getsentry/sentry-python

Issue:
Fix `datetime` serialization for non-UTC timestamps
### How do you use Sentry?

Sentry Saas (sentry.io)

### Version

2.13.0

### Steps to Reproduce

The events at https://xyz.sentry.io/issues/123/events/abc/ lose datetimes' timezone.

### Expected Result

The following code with `.isoformat()`
```
from zoneinfo import ZoneInfo
sthlm = datetime.datetime(2024, 3, 1, tzinfo=ZoneInfo("Europe/Stockholm"))
new_york = datetime.datetime(2024, 3, 1, tzinfo=ZoneInfo("America/New_York"))
sentry_sdk.set_context(
    "extra_context",
    {
        "sthlm": sthlm.isoformat(),
        "new_york": new_york.isoformat(),
    },
)
Hub.current.capture_event(...)
```
results in the screenshot below

![Image](https://github.com/user-attachments/assets/625f11b7-bd67-4cfa-babf-c3a88f6fe108)



### Actual Result

The following code
```
from zoneinfo import ZoneInfo
sthlm = datetime.datetime(2024, 3, 1, tzinfo=ZoneInfo("Europe/Stockholm"))
new_york = datetime.datetime(2024, 3, 1, tzinfo=ZoneInfo("America/New_York"))
sentry_sdk.set_context(
    "extra_context",
    {
        "sthlm": sthlm,
        "new_york": new_york,
    },
)
Hub.current.capture_event(...)
```
will lose timezone. See screenshot below

![Image](https://github.com/user-attachments/assets/e3f39d50-ba23-4bae-87c2-3caec2f358dd)


Discussion:
codecov[bot]: ### :x: 10 Tests Failed:
| Tests completed | Failed | Passed | Skipped |
|---|---|---|---|
| 3687 | 10 | 3677 | 425 |
<details><summary>View the top 3 failed tests by shortest run time</summary>

> 
> ```
> tests.test_utils test_format_timestamp[2021-01-01T12:00:00.000000-07:00]
> ```
> 
> <details><summary>Stack Traces | 0.001s run time</summary>
> 
> > 
> > ```
> > tests/test_utils.py:972: in test_format_timestamp
> >     assert formatted == timestamp_string
> > E   AssertionError: assert '2021-01-01T12:00:00.000000%:z' == '2021-01-01T12:00:00.000000-07:00'
> > E     - 2021-01-01T12:00:00.000000-07:00
> > E     ?                           ^^^ ^^
> > E     + 2021-01-01T12:00:00.000000%:z
> > E     ?                           ^ ^
> > ```
> 
> </details>


> 
> ```
> tests.test_utils test_format_timestamp[2021-01-01T12:00:00.000000Z]
> ```
> 
> <details><summary>Stack Traces | 0.001s run time</summary>
> 
> > 
> > ```
> > tests/test_utils.py:969: in test_format_timestamp
> >     datetime_object = datetime.fromisoformat(timestamp_string)
> > E   AttributeError: type object 'datetime.datetime' has no attribute 'fromisoformat'
> > ```
> 
> </details>


> 
> ```
> tests.test_utils test_format_timestamp[2021-01-01T12:00:00.000000]
> ```
> 
> <details><summary>Stack Traces | 0.001s run time</summary>
> 
> > 
> > ```
> > tests/test_utils.py:972: in test_format_timestamp
> >     assert formatted == timestamp_string
> > E   AssertionError: assert '2021-01-01T12:00:00.000000%:z' == '2021-01-01T12:00:00.000000'
> > E     - 2021-01-01T12:00:00.000000
> > E     + 2021-01-01T12:00:00.000000%:z
> > E     ?                           +++
> > ```
> 
> </details>

</details>

To view individual test run time comparison to the main branch, go to the [Test Analytics Dashboard](https://app.codecov.io/gh/getsentry/sentry-python/tests/szokeasaurusrex/fix-timestamps)
szokeasaurusrex: Looks like this maybe does not work on Python <3.12
szokeasaurusrex: Yeah, seems that `%:z` was introduced in 3.12, since it is not mentioned in the [3.11 docs](https://docs.python.org/3.11/library/datetime.html#strftime-and-strptime-behavior)

Patch:
diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index 664b96f9..9f49b947 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -11,7 +11,7 @@ import sys
 import threading
 import time
 from collections import namedtuple
-from datetime import datetime
+from datetime import datetime, timezone
 from decimal import Decimal
 from functools import partial, partialmethod, wraps
 from numbers import Real
@@ -228,7 +228,15 @@ def to_timestamp(value):
 
 def format_timestamp(value):
     # type: (datetime) -> str
-    return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+    """Formats a timestamp in RFC 3339 format.
+
+    Any datetime objects with a non-UTC timezone are converted to UTC, so that all timestamps are formatted in UTC.
+    """
+    utctime = value.astimezone(timezone.utc)
+
+    # We use this custom formatting rather than isoformat for backwards compatibility (we have used this format for
+    # several years now), and isoformat is slightly different.
+    return utctime.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
 
 
 def event_hint_with_exc_info(exc_info=None):


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 100c7f86..4df343a3 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
 import threading
 import re
 import sys
-from datetime import timedelta
+from datetime import timedelta, datetime, timezone
 from unittest import mock
 
 import pytest
@@ -13,6 +13,7 @@ from sentry_sdk.utils import (
     Components,
     Dsn,
     env_to_bool,
+    format_timestamp,
     get_current_thread_meta,
     get_default_release,
     get_error_message,
@@ -950,3 +951,39 @@ def test_get_current_thread_meta_failed_to_get_main_thread():
     thread.start()
     thread.join()
     assert (main_thread.ident, main_thread.name) == results.get(timeout=1)
+
+
+@pytest.mark.parametrize(
+    ("datetime_object", "expected_output"),
+    (
+        (
+            datetime(2021, 1, 1, tzinfo=timezone.utc),
+            "2021-01-01T00:00:00.000000Z",
+        ),  # UTC time
+        (
+            datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=2))),
+            "2020-12-31T22:00:00.000000Z",
+        ),  # UTC+2 time
+        (
+            datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=-7))),
+            "2021-01-01T07:00:00.000000Z",
+        ),  # UTC-7 time
+        (
+            datetime(2021, 2, 3, 4, 56, 7, 890123, tzinfo=timezone.utc),
+            "2021-02-03T04:56:07.890123Z",
+        ),  # UTC time all non-zero fields
+    ),
+)
+def test_format_timestamp(datetime_object, expected_output):
+    formatted = format_timestamp(datetime_object)
+
+    assert formatted == expected_output
+
+
+def test_format_timestamp_naive():
+    datetime_object = datetime(2021, 1, 1)
+    timestamp_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z"
+
+    # Ensure that some timestamp is returned, without error. We currently treat these as local time, but this is an
+    # implementation detail which we should not assert here.
+    assert re.fullmatch(timestamp_regex, format_timestamp(datetime_object))