-
Notifications
You must be signed in to change notification settings - Fork 20
fix(otel): correct operation spans across replay #591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f5a025e
591d328
d038e49
dba1fb4
9835af3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
|
|
||
| from aws_durable_execution_sdk_python.lambda_service import ( | ||
| InvocationStatus, | ||
| OperationStatus, | ||
| OperationType, | ||
| ) | ||
| from aws_durable_execution_sdk_python.plugin import ( | ||
|
|
@@ -49,6 +50,16 @@ | |
|
|
||
| _SpanAttributes = dict[str, str | bool | int] | ||
|
|
||
| _TERMINAL_OPERATION_STATUSES = frozenset( | ||
| { | ||
| OperationStatus.SUCCEEDED, | ||
| OperationStatus.FAILED, | ||
| OperationStatus.TIMED_OUT, | ||
| OperationStatus.CANCELLED, | ||
| OperationStatus.STOPPED, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def _to_otel_timestamp(dt: datetime.datetime | None) -> int | None: | ||
| """Convert a datetime to OTel timestamp (nanoseconds since epoch), or None.""" | ||
|
|
@@ -68,8 +79,9 @@ class InvocationOtelPlugin(DurableInstrumentationPlugin): | |
| Operation IDs are converted into deterministic span IDs. The first observed | ||
| span for an operation uses that deterministic ID; later continuation spans | ||
| use newly generated span IDs and link back to the deterministic span ID so | ||
| trace viewers can relate retries and replay-created terminal spans to the | ||
| original logical operation. | ||
| trace viewers can relate retries and cross-invocation completions to the | ||
| original logical operation. Terminal operations encountered only as replay | ||
| history do not emit another span. | ||
|
|
||
| Args: | ||
| trace_provider: OpenTelemetry tracer provider used to create spans. | ||
|
|
@@ -373,6 +385,8 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: | |
| def on_operation_start(self, info: OperationStartInfo) -> None: | ||
| """Called when an operation begins. Creates a span for the operation.""" | ||
| logger.debug("Durable operation started: %s", info) | ||
| if info.is_replayed and info.status in _TERMINAL_OPERATION_STATUSES: | ||
| return | ||
| if info.operation_type is OperationType.CONTEXT: | ||
| # Context operations are tracked using on_user_function_start. | ||
| return | ||
|
|
@@ -383,7 +397,7 @@ def on_operation_start(self, info: OperationStartInfo) -> None: | |
| operation_id=info.operation_id, | ||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Operation span now mixes two clocks for start vs. end, allowing negative durations. This changes the span start to the local Lambda wall clock ( The pre-existing zero-duration guard no longer protects the span either: it bumps Note the new assertion in Fix: derive both endpoints from the same clock. Either keep |
||
| parent_span=parent_span, | ||
| existed=info.is_replayed, | ||
| ) | ||
|
|
@@ -396,13 +410,19 @@ def on_operation_end(self, info: OperationEndInfo) -> None: | |
| invocation is completing an operation that began earlier, so a short | ||
| continuation span is created and linked to the deterministic logical | ||
| operation span before being ended. | ||
|
|
||
| Terminal replay callbacks describe history that completed before this | ||
| invocation and do not produce another span. Operations completed by the | ||
| backend while suspended are delivered with ``is_replayed=False``. | ||
| """ | ||
| logger.debug("Durable operation ended: %s", info) | ||
| if info.is_replayed: | ||
| return | ||
| if info.operation_type is OperationType.CONTEXT: | ||
| # Context operations are tracked using on_user_function_end. | ||
| return | ||
| span = self._get_span(info.operation_id) | ||
| if not span: | ||
| if span is None: | ||
| # the span was not started in the current invocation, so we need to | ||
| # create a new one that links to the previous one | ||
| parent_span = self._resolve_parent_span(info.parent_id) | ||
|
|
@@ -411,7 +431,7 @@ def on_operation_end(self, info: OperationEndInfo) -> None: | |
| operation_id=info.operation_id, | ||
| name=info.name or info.operation_id, | ||
| attributes=attributes, | ||
| start_time=info.start_time, | ||
| start_time=datetime.datetime.now(datetime.UTC), | ||
| parent_span=parent_span, | ||
| existed=True, | ||
| ) | ||
|
|
@@ -426,10 +446,7 @@ def on_operation_end(self, info: OperationEndInfo) -> None: | |
| else: | ||
| span.set_status(StatusCode.OK) | ||
|
|
||
| end_timestamp = info.end_time | ||
| if end_timestamp is not None and end_timestamp == info.start_time: | ||
| end_timestamp += datetime.timedelta(microseconds=1) | ||
| self._end_span(info.operation_id, end_timestamp) | ||
| self._end_span(info.operation_id) | ||
|
|
||
| def on_user_function_start(self, info: UserFunctionStartInfo) -> None: | ||
| """Called when a context or step operation starts user code. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that is the behavior without this fix
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm in TS, the plugin interface onOperationStart behaves in the following manner:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Python plugin interface behaves the same except no 3:
onOperationStart is still called when its context is replayed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm this is different in Java as well but rather 2.
isReplay=falseisReplay=true