Skip to content

Commit 6b93692

Browse files
committed
fix(logs): Sanitize lone surrogates in LogBatcher to prevent Relay drops (#8144)
When file names, environment variables, or arguments containing non-UTF-8 bytes are decoded with surrogateescape (e.g. via os.fsdecode), strings contain lone surrogates. When LogBatcher serializes logs with json.dumps(..., ensure_ascii=True), these lone surrogates are output as hex escapes (e.g. \udcff). Relay rejects lone surrogates in hex escapes with 'lone leading surrogate in hex escape', causing Relay to drop the entire log batch container. Sanitize strings in LogBatcher._to_transport_format by replacing lone surrogates with the Unicode replacement character (U+FFFD) across the log body, attribute keys, and string attribute values (including string arrays). Add regression tests verifying lone surrogates are replaced and JSON payloads remain valid UTF-8. Fixes #8144
1 parent 7ad4e1a commit 6b93692

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

‎sentry_sdk/_log_batcher.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,27 @@
66
if TYPE_CHECKING:
77
from typing import Any
88

9-
from sentry_sdk._types import Log
9+
from sentry_sdk._types import Log, SerializedAttributeValue
10+
11+
12+
def _sanitize_surrogates(s: str) -> str:
13+
try:
14+
s.encode("utf-8")
15+
return s
16+
except UnicodeEncodeError:
17+
return s.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
18+
19+
20+
def _serialize_and_sanitize_attribute(val: "Any") -> "SerializedAttributeValue":
21+
serialized = serialize_attribute(val)
22+
s_val = serialized["value"]
23+
if isinstance(s_val, str):
24+
serialized["value"] = _sanitize_surrogates(s_val)
25+
elif isinstance(s_val, list):
26+
serialized["value"] = [
27+
_sanitize_surrogates(x) if isinstance(x, str) else x for x in s_val
28+
]
29+
return serialized
1030

1131

1232
class LogBatcher(Batcher["Log"]):
@@ -28,9 +48,10 @@ def _to_transport_format(item: "Log") -> "Any":
2848
res = {
2949
"timestamp": int(item["time_unix_nano"]) / 1.0e9,
3050
"level": str(item["severity_text"]),
31-
"body": str(item["body"]),
51+
"body": _sanitize_surrogates(str(item["body"])),
3252
"attributes": {
33-
k: serialize_attribute(v) for (k, v) in item["attributes"].items()
53+
_sanitize_surrogates(str(k)): _serialize_and_sanitize_attribute(v)
54+
for (k, v) in item["attributes"].items()
3455
},
3556
}
3657

‎tests/test_logs.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,3 +1010,56 @@ def _flush(self):
10101010
batcher._flush_loop()
10111011

10121012
assert calls == [1]
1013+
1014+
1015+
@minimum_python_37
1016+
def test_logs_lone_surrogates(sentry_init, capture_items):
1017+
import logging
1018+
1019+
from sentry_sdk._log_batcher import LogBatcher
1020+
from sentry_sdk.utils import json_dumps
1021+
1022+
sentry_init(enable_logs=True)
1023+
items = capture_items("log")
1024+
1025+
# String with a lone surrogate, e.g. from os.fsdecode on non-UTF-8 bytes
1026+
name_with_surrogate = b"report-\xff.csv".decode("utf-8", "surrogateescape")
1027+
attr_key_with_surrogate = b"key-\xff".decode("utf-8", "surrogateescape")
1028+
1029+
sentry_sdk.logger.info("opened {name}", name=name_with_surrogate)
1030+
logging.getLogger("app").warning("could not read %s", name_with_surrogate)
1031+
1032+
get_client().flush()
1033+
1034+
assert len(items) == 2
1035+
1036+
for item in items:
1037+
encoded = json_dumps(item.payload)
1038+
assert b"\\udc" not in encoded
1039+
assert b"\\ufffd" in encoded or "\ufffd".encode() in encoded
1040+
1041+
# Direct unit test on LogBatcher._to_transport_format
1042+
raw_log = {
1043+
"severity_text": "info",
1044+
"severity_number": 9,
1045+
"time_unix_nano": 1700000000000000000,
1046+
"body": f"file: {name_with_surrogate}",
1047+
"attributes": {
1048+
attr_key_with_surrogate: name_with_surrogate,
1049+
"paths": [name_with_surrogate, "normal.csv"],
1050+
},
1051+
}
1052+
1053+
res = LogBatcher._to_transport_format(raw_log)
1054+
assert "\ufffd" in res["body"]
1055+
assert "\udcff" not in res["body"]
1056+
assert "key-\ufffd" in res["attributes"]
1057+
assert res["attributes"]["key-\ufffd"]["value"] == "report-\ufffd.csv"
1058+
assert res["attributes"]["paths"]["value"] == [
1059+
"report-\ufffd.csv",
1060+
"normal.csv",
1061+
]
1062+
1063+
encoded_direct = json_dumps(res)
1064+
assert b"\\udc" not in encoded_direct
1065+
assert b"\\ufffd" in encoded_direct

0 commit comments

Comments
 (0)