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: 3 additions & 2 deletions docs/sdk-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,9 @@ eventName: "e2e.inside")` inside it
Exception content follows the effective per-span capture policy in both SDKs. If either input
or output capture is off, C14 retains error status/type and the exception event but omits its
message and stack. With both enabled, C16 applies the content mask and truncation to
`exception.message` and `exception.stacktrace` before export. Mask failures omit the affected
field. The policy is per span, so an enclosing span with capture on records an error that propagates
`exception.message` and `exception.stacktrace` before export. A truncated stack keeps the part where
the exception line normally is: TypeScript keeps the start of the stack, and Python keeps the end
of the traceback, with the marker first. Mask failures omit the affected field. The policy is per span, so an enclosing span with capture on records an error that propagates
to it. These rules do not sanitize raw OpenTelemetry events or arbitrary attributes.

| # | Assertion |
Expand Down
3 changes: 2 additions & 1 deletion sdks/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ Core SDK error recording keeps failure status, error type, and an exception even
`capture_output` are true, including per-span overrides: exceptions can echo either inputs or
outputs. The policy is per span: when an error propagates to an enclosing span whose capture is on,
that span records its message and stack. Included fields pass through `mask` with their exception
attribute keys, then serialization and truncation. Mask and serialization failures drop the affected field and report generic errors
attribute keys, then serialization and truncation. A truncated `exception.stacktrace` keeps its
end, where Python normally puts the innermost frame and the exception line. Mask and serialization failures drop the affected field and report generic errors
to `on_error` and SDK diagnostics without the original message, traceback, or exception chain.
The original application error is raised unchanged. These controls do not scrub error type names,
metadata, identifiers, resource attributes, or events written directly through raw OpenTelemetry APIs.
Expand Down
3 changes: 2 additions & 1 deletion sdks/python/src/telemetry_dev/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,14 @@ def __init__(
def report(self, message: str, exc: BaseException) -> None:
report_error(self.on_error, message, exc)

def serialize(self, value: Any, key: str) -> str | None:
def serialize(self, value: Any, key: str, *, keep_end: bool = False) -> str | None:
return serialize_content(
value,
key=key,
mask=self.mask,
max_len=self.max_attribute_length,
on_error=self.on_error,
keep_end=keep_end,
)

def flush(self, timeout_s: float = 10.0) -> None:
Expand Down
23 changes: 18 additions & 5 deletions sdks/python/src/telemetry_dev/_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,29 @@ class MaskContext:
Mask = Callable[[Any, MaskContext], Any]


def truncate(text: str, max_len: int) -> str:
def truncate(text: str, max_len: int, *, keep_end: bool = False) -> str:
"""Cap ``text`` at ``max_len`` UTF-16 units, marking the cut.

``keep_end`` keeps the tail and puts the marker first, for text whose most useful part
comes last.
"""
# Count UTF-16 units like JavaScript, but back off at a split surrogate pair
# rather than emitting the lone high surrogate that String.slice would retain.
# rather than emitting the lone surrogate that String.slice would retain.
if len(text) * 2 <= max_len:
return text
encoded = text.encode("utf-16-le")
if len(encoded) <= max_len * 2:
return text
if max_len < len(TRUNCATION_MARKER):
return TRUNCATION_MARKER[: max(max_len, 0)]
head = encoded[: (max_len - len(TRUNCATION_MARKER)) * 2]
kept = (max_len - len(TRUNCATION_MARKER)) * 2
if keep_end:
tail = encoded[len(encoded) - kept :]
try:
return TRUNCATION_MARKER + tail.decode("utf-16-le")
except UnicodeDecodeError:
return TRUNCATION_MARKER + tail[2:].decode("utf-16-le")
head = encoded[:kept]
try:
return head.decode("utf-16-le") + TRUNCATION_MARKER
except UnicodeDecodeError:
Expand Down Expand Up @@ -155,11 +167,12 @@ def serialize_content(
mask: Mask | None,
max_len: int,
on_error: Callable[[BaseException], None] | None = None,
keep_end: bool = False,
) -> str | None:
"""The single content funnel: mask -> JSON stringify -> truncate.

Returns None (content dropped, matching the TypeScript SDK) when the mask hook raises
or the value cannot be stringified.
or the value cannot be stringified. ``keep_end`` is passed to ``truncate``.
"""
if mask is not None:
try:
Expand All @@ -170,7 +183,7 @@ def serialize_content(
report_error(on_error, message, RuntimeError(message))
return None
try:
return truncate(stringify(value), max_len)
return truncate(stringify(value), max_len, keep_end=keep_end)
except BaseException:
message = "failed to serialize attribute; content dropped"
report_error(on_error, message, ValueError(message))
Expand Down
9 changes: 6 additions & 3 deletions sdks/python/src/telemetry_dev/_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,17 @@ def _record_error_on_span(
}
# Exceptions can echo either inputs or outputs; both must allow content capture.
if state.capture_input and state.capture_output:
for key, value in (
("exception.message", str(error)),
# A Python traceback ends with the innermost frame and the exception line, so a
# truncated one keeps its end.
for key, value, keep_end in (
("exception.message", str(error), False),
(
"exception.stacktrace",
"".join(traceback.format_exception(type(error), error, error.__traceback__)),
True,
),
):
serialized = client.serialize(value, key)
serialized = client.serialize(value, key, keep_end=keep_end)
if serialized is not None:
attributes[key] = serialized
span.add_event("exception", attributes)
Expand Down
27 changes: 26 additions & 1 deletion sdks/python/tests/test_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,31 @@ def test_truncation_counts_utf16_units(memory: SimpleNamespace) -> None:
def test_truncation_never_splits_a_surrogate_pair() -> None:
# keep = 6 UTF-16 units lands mid-emoji; the partial pair is dropped, not emitted.
assert truncate("a" + "\U0001f916" * 40, 20) == "a" + "\U0001f916" * 2 + TRUNCATION_MARKER
assert truncate("\U0001f916" * 40 + "a", 20, keep_end=True) == (
TRUNCATION_MARKER + "\U0001f916" * 2 + "a"
)
marker_len = len(TRUNCATION_MARKER)
assert truncate("a" * 30, marker_len, keep_end=True) == TRUNCATION_MARKER
assert truncate("x" * 20 + "\U0001f916", marker_len + 1, keep_end=True) == TRUNCATION_MARKER


def test_truncated_stacktrace_keeps_the_innermost_frame(make: MakeClient) -> None:
env = make(max_attribute_length=300)

def fail(depth: int) -> None:
if depth == 0:
raise ValueError("innermost failure")
fail(depth - 1)

with pytest.raises(ValueError), telemetry_dev.start_span("deep"):
fail(30)
event_attrs = dict(only_span(env).events[0].attributes or {})
stack = str(event_attrs["exception.stacktrace"])
assert len(stack) == 300
assert stack.startswith(TRUNCATION_MARKER)
assert stack.endswith("ValueError: innermost failure\n")
assert 'raise ValueError("innermost failure")' in stack
assert event_attrs["exception.message"] == "innermost failure"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -371,7 +396,7 @@ def mask(value: Any, ctx: MaskContext) -> Any:
assert event_attrs.get("exception.stacktrace") == (
"safe stack"
if mode == "redact"
else "x" * 36 + TRUNCATION_MARKER
else TRUNCATION_MARKER + "x" * 36
if mode == "truncate"
else None
)
Expand Down
Loading