Skip to content

[Bug]: Align OTel exception stacktrace emission with OpenTelemetry JS #886

Open

Description

@zhongkechen

Expected Behavior

The Python OTel package should follow OpenTelemetry JS's stacktrace-presence rule: emit exception.stacktrace only when the supplied error contains a non-empty stack string. Preserve available exception type and message without manufacturing a stack from a newly constructed carrier exception.

Supplied ErrorObject.stack_trace Expected event
None, [], or a list whose joined string is empty Preserve existing type/message semantics; omit exception.stacktrace
A list whose joined string is non-empty Preserve the existing joined stack string exactly

This issue changes the OTel package only. Keep the core SDK's exception capture and checkpoint persistence policies unchanged. It does not require recovering handler frames that were never supplied to the plugin.

Actual Behavior

At commit 4625d01bbfa97da67050014851af7eb41c61a842, a raised RuntimeError("boom") becomes an ErrorObject without a stored stack. The OTel event is then:

exception.type       = RuntimeError
exception.message    = boom
exception.stacktrace = Exception: boom\n

The stack describes a newly constructed Exception, contradicting the recorded original type and containing no handler frames.

In exception_events.py, the plugin constructs an unraised Exception(message) and calls record_exception. It supplies explicit type/message attributes, but only overrides the stack when ErrorObject.stack_trace is present. OpenTelemetry Python otherwise formats the carrier's traceback.

The core's longstanding choice not to capture ordinary exception stacks is not classified as a core bug by this issue.

Steps to Reproduce

Using the core and OTel packages from the commit above, with opentelemetry-sdk installed:

from aws_durable_execution_sdk_python.lambda_service import ErrorObject
from aws_durable_execution_sdk_python_otel.exception_events import (
    record_error_object_exception,
)
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
    InMemorySpanExporter,
)

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))

try:
    raise RuntimeError("boom")
except RuntimeError as exc:
    error = ErrorObject.from_exception(exc)

with provider.get_tracer("repro").start_as_current_span("operation") as span:
    record_error_object_exception(span, error)

attrs = exporter.get_finished_spans()[0].events[0].attributes
provider.shutdown()
assert attrs["exception.type"] == "RuntimeError"
assert attrs["exception.message"] == "boom"
assert "exception.stacktrace" not in attrs  # Fails on the reported commit.

Independent local runs through the public SDK API also reproduced the carrier stack for step, child-context, condition-check, and durable chained-invoke target failures in both Execution and Invocation views: eight combinations, twelve exception events. The existing test_status_mapping.py and lambda_service_test.py suites passed all 315 tests. No deployed Lambda/Dash0 validation is claimed here.

Proposed Implementation

Emit the standard exception event directly with span.add_event("exception", attributes=...), using the serialized metadata already available to the plugin. Remove the synthetic carrier from the recording path rather than trying to reconstruct the application exception class.

  • Build the stack string using the existing newline-join format; include it only when that resulting string is non-empty. Do not strip or otherwise rewrite supplied stack content.
  • Preserve unrelated type/message and fallback behavior, auxiliary attributes such as the currently emitted exception.escaped, event-emission/suppression rules, span status, and event timestamps.
  • Do not change core ErrorObject.from_exception, checkpoint contents, plugin interfaces, or replay behavior. Do not supplement metadata through sys.exception() or other ambient exception state.

Acceptance Criteria

  • Cover both Execution and Invocation views, for operation and user-function/attempt exception events.
  • A genuinely raised RuntimeError converted through the unchanged core SDK exports the correct type/message and no synthetic stacktrace.
  • None, [], and [""] omit the stacktrace attribute; non-empty joined strings are preserved exactly, including whitespace/newlines.
  • Supplied stack metadata survives an ErrorObject serialization round trip and replay-delivered events. Old checkpoints and callback failures without stacks do not gain fabricated frames.
  • Existing type/message semantics and unrelated event attributes, presence/count, status, and timestamp behavior remain intact. Retain the no-diagnostics suppression and normalized timestamps from [OTel]: Report terminal failures and keep exception events within span bounds #853 / fix: correct terminal telemetry status and timing #865 when integrating with that work.
  • No application exception constructors/imports run merely to emit telemetry; core capture and persistence behavior remain unchanged.
  • Document that the plugin records supplied stack metadata and does not automatically recover ordinary Python handler frames. Do not describe this as complete JS Durable SDK behavior parity.
  • Run the repository's focused OTel tests, type checks, formatting, and applicable installed-wheel validation; identify any changed existing test expectations.

SDK Version

Source commit 4625d01bbfa97da67050014851af7eb41c61a842; OpenTelemetry Python SDK 1.45.2.

Python Version

3.13.18.

Is this a regression?

No known working version. The carrier behavior predates #812; that change preserved the original type/message and exposed the remaining stacktrace inconsistency.

Additional Context: JS Comparison and Related Work

OpenTelemetry JS @opentelemetry/sdk-trace 2.12.0 records the supplied stack conditionally:

if (exception.stack) {
  attributes[ATTR_EXCEPTION_STACKTRACE] = exception.stack;
}

This is the rule to match. It is not a claim of identical end-to-end behavior with the JS Durable SDK: live JS step hooks pass the original Error, while serialized errors can be reconstructed as wrappers with locally generated recovery stacks. Those differences are outside this issue.

Related: #804 / #812 preserve original exception identity; #853 / #865 handle terminal status, exception timing, and absent diagnostics. This follow-up addresses present diagnostic errors with absent stack metadata, without reopening those other behaviors. Any shared conformance requirement changes should be coordinated separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugneeds-triageIssue needs triagepkg:otelPackage: aws-durable-execution-sdk-python-otel

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions