fix(otel): correct operation spans across replay - #591
Conversation
This comment has been minimized.
This comment has been minimized.
| @@ -373,6 +385,8 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: | |||
| def on_operation_start(self, info: OperationStartInfo) -> None: | |||
There was a problem hiding this comment.
more meta comment about the plugin interface in Python: does this mean that given an operation that completes with a terminal status in the first invocation, and there are 5 invocations in total. Will the on_operation_start be triggered with OperationStartInfo 5 times for that operation?
There was a problem hiding this comment.
Yeah, that is the behavior without this fix
There was a problem hiding this comment.
hmm in TS, the plugin interface onOperationStart behaves in the following manner:
- SDK calls onOperationStart with isFirst=true when the operation has started for the first time.
- SDK calls onOperationStart is called with isFirst=false for subsequent times the operation is processed but whose status is not terminal.
- SDK does NOT call onOperationStart when the operation status is terminal
There was a problem hiding this comment.
As a result, the Otel Plugin doesn't need this "fix" at all. Is there no way to close this gap within the SDK's plugin interface?
There was a problem hiding this comment.
Python plugin interface behaves the same except no 3:
SDK does NOT call onOperationStart when the operation status is terminal
onOperationStart is still called when its context is replayed
There was a problem hiding this comment.
hmm this is different in Java as well but rather 2.
SDK calls onOperationStart is called with isFirst=false for subsequent times the operation is processed but whose status is not terminal.
| Rule | TS | Java | Match |
|---|---|---|---|
1. First → isReplay=false |
all types | all types | ✅ |
2. Non-terminal replay → isReplay=true |
all types | STEP/CONTEXT only | |
| — WAIT / INVOKE / CALLBACK (rule 2) | fires | does not fire | ❌ |
| 3. Terminal → no call | no call | no call | ✅ |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
| name=info.name or info.operation_id, | ||
| attributes=attributes, | ||
| start_time=info.start_time, | ||
| start_time=datetime.datetime.now(datetime.UTC), |
There was a problem hiding this comment.
Operation span now mixes two clocks for start vs. end, allowing negative durations.
This changes the span start to the local Lambda wall clock (datetime.now(UTC)), but on_operation_end still ends the span at the backend-recorded info.end_time (self._end_span(info.operation_id, end_timestamp)). The two timestamps come from different clocks, so whenever info.end_time precedes the current invocation's local time the finished span has end_time < start_time — a negative duration. This is reachable under clock skew between the durable backend and the Lambda runtime for any operation that both starts and completes within this invocation (e.g. a WAIT/STEP whose backend end_timestamp lands slightly before local now()).
The pre-existing zero-duration guard no longer protects the span either: it bumps end_timestamp only when info.end_time == info.start_time, but the span's actual start is now now(), not info.start_time, so the comparison guards against a value unrelated to the real start.
Note the new assertion in test_operation_callbacks_emit_child_span_with_deterministic_span_id (invocation_span.start_time <= active_wait_span.start_time) checks only ordering vs. the invocation span; it never asserts wait_span.end_time >= wait_span.start_time, so with the test's START_TIME/END_TIME (2024) and a later now(), the wait span already has a negative duration that the suite silently accepts.
Fix: derive both endpoints from the same clock. Either keep start_time=info.start_time (and, if the goal is to avoid a child span preceding the invocation span, clamp start to max(info.start_time, invocation_span_start)), or clamp the end passed to _end_span to be >= the span's start. The ExecutionOtelPlugin.on_operation_start was intentionally left on info.start_time, so this also diverges the two plugins.
This comment has been minimized.
This comment has been minimized.
| span.set_status(StatusCode.OK) | ||
|
|
||
| end_timestamp = info.end_time | ||
| end_timestamp = None if is_continuation else info.end_time |
There was a problem hiding this comment.
Low severity — mixed clock sources can yield a negative-duration span. For an operation that both started and ended in the current invocation (is_continuation is False), the span's start was changed to datetime.datetime.now(UTC) in on_operation_start, but here the end still uses info.end_time, which is the backend-assigned durable timestamp. These come from two different clocks. When the backend's end_time precedes the local now() recorded at start (clock skew for a very short operation), the span ends before it starts.
The pre-existing guard on the next line only bumps by 1µs when end_timestamp == info.start_time, i.e. it compares against the durable start, which is no longer the span's actual start time — so it neither catches the inversion nor serves its original zero-duration purpose for this path.
Consider guarding against inversion directly (e.g. record the now() start used at span creation and clamp end_timestamp to be >= start, or fall back to None so OTel stamps the current time) instead of the == info.start_time check.
This comment has been minimized.
This comment has been minimized.
Codex AI reviewNo actionable findings. Residual risk: regression tests call plugin hooks directly rather than exercising replay/update sequencing end to end. Tests were not run per review constraints. Reviewed commit |
Claude AI reviewI reviewed the four changed files against the callback contract in Assessment (confirmed correct)Suppression logic — Timestamp change — Starting invocation-view operation spans at Tests — New parametrized tests cover all five terminal statuses in both plugins plus invocation-parent time-ordering. The Residual test risk (non-blocking)
Reviewed commit |
Summary
Root cause
on_operation_replayemits start and end callbacks for terminal checkpointed operations withis_replayed=True. The OTel plugins treated those callbacks as new continuations, so an already-successful submitter was emitted again during replay.Invocation-view operation spans also reused the durable operation timestamp from
OperationStartInfo. During continuation invocations, that timestamp predates the new invocation and causes operation spans to overlap.Conformance failures:
Testing
hatch run dev-otel:test(102 passed)hatch run types:checkhatch fmt --checkhatch run test:all(3051 passed, 2 skipped)