From 72432dd729d9d75a5bddc08d4454fb924a519b20 Mon Sep 17 00:00:00 2001 From: Aikins Laryea Date: Tue, 27 Oct 2026 11:36:08 +0000 Subject: [PATCH] fix(sdk-python): keep the end of truncated tracebacks A Python traceback prints the innermost frame and the exception line last, so truncating exception.stacktrace to max_attribute_length by keeping its start dropped the most useful part. The stack trace now keeps its end, with the truncation marker first. Other attributes and the TypeScript core, whose stacks start with the message line, still keep their start. --- docs/sdk-conformance.md | 5 ++-- sdks/python/README.md | 3 ++- sdks/python/src/telemetry_dev/_client.py | 3 ++- sdks/python/src/telemetry_dev/_serialize.py | 23 ++++++++++++++---- sdks/python/src/telemetry_dev/_spans.py | 9 ++++--- sdks/python/tests/test_masking.py | 27 ++++++++++++++++++++- 6 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/sdk-conformance.md b/docs/sdk-conformance.md index 284c9a8..5796d32 100644 --- a/docs/sdk-conformance.md +++ b/docs/sdk-conformance.md @@ -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 | diff --git a/sdks/python/README.md b/sdks/python/README.md index ce1bf3c..8d7619e 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -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. diff --git a/sdks/python/src/telemetry_dev/_client.py b/sdks/python/src/telemetry_dev/_client.py index 4498d98..b583714 100644 --- a/sdks/python/src/telemetry_dev/_client.py +++ b/sdks/python/src/telemetry_dev/_client.py @@ -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: diff --git a/sdks/python/src/telemetry_dev/_serialize.py b/sdks/python/src/telemetry_dev/_serialize.py index 550d0f2..dcf1321 100644 --- a/sdks/python/src/telemetry_dev/_serialize.py +++ b/sdks/python/src/telemetry_dev/_serialize.py @@ -30,9 +30,14 @@ 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") @@ -40,7 +45,14 @@ def truncate(text: str, max_len: int) -> str: 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: @@ -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: @@ -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)) diff --git a/sdks/python/src/telemetry_dev/_spans.py b/sdks/python/src/telemetry_dev/_spans.py index 8f01b21..0964c89 100644 --- a/sdks/python/src/telemetry_dev/_spans.py +++ b/sdks/python/src/telemetry_dev/_spans.py @@ -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) diff --git a/sdks/python/tests/test_masking.py b/sdks/python/tests/test_masking.py index 775979f..cec59ea 100644 --- a/sdks/python/tests/test_masking.py +++ b/sdks/python/tests/test_masking.py @@ -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( @@ -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 )