|
17 | 17 | - context is attached inside the synchronous ``on_user_function_*`` hooks and |
18 | 18 | log correlation is handled by :mod:`log_filter`), the hook wiring mirrors the |
19 | 19 | existing :class:`~aws_durable_execution_sdk_python_otel.invocation_plugin.InvocationOtelPlugin`. |
| 20 | +
|
| 21 | +Every context the plugin attaches is tracked by its token and detached at the |
| 22 | +matching lifecycle end: a user-function scope is released in |
| 23 | +``on_user_function_end`` and the invocation scope in ``on_invocation_end``, so |
| 24 | +the plugin never leaves an ended or suspended span current. |
20 | 25 | """ |
21 | 26 |
|
22 | 27 | from __future__ import annotations |
|
75 | 80 | # Registry key for the invocation span (operations use their operation_id). |
76 | 81 | _INVOCATION_KEY = "__invocation__" |
77 | 82 |
|
| 83 | +# Token key for the invocation-level context scope attached at invocation start. |
| 84 | +_INVOCATION_CONTEXT_KEY = "__invocation_context__" |
| 85 | + |
78 | 86 |
|
79 | 87 | def _to_otel_timestamp(dt: datetime.datetime | None) -> int | None: |
80 | 88 | """Convert a datetime to an OTel timestamp (ns since epoch), or None.""" |
@@ -114,6 +122,11 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None: |
114 | 122 | self._workflow_span: Span | None = None |
115 | 123 | self._invocation_span: Span | None = None |
116 | 124 | self._operation_spans: dict[str, Span] = {} |
| 125 | + # Tokens returned by context.attach(), keyed by the span registry key, |
| 126 | + # paired with the thread that attached them. Every attach the plugin |
| 127 | + # owns is released through _detach_context so the plugin never leaves a |
| 128 | + # scope on the context stack. |
| 129 | + self._context_tokens: dict[str, tuple[int, object]] = {} |
117 | 130 | self._lock = threading.RLock() |
118 | 131 | self._tracing_enabled = False |
119 | 132 |
|
@@ -157,6 +170,46 @@ def _pop_span(self, key: str) -> Span | None: |
157 | 170 | def _attempt_key(info: UserFunctionStartInfo | UserFunctionEndInfo) -> str: |
158 | 171 | return f"{info.operation_id}:attempt:{info.attempt or 1}" |
159 | 172 |
|
| 173 | + # ------------------------------------------------------------------ |
| 174 | + # Context scope helpers |
| 175 | + # ------------------------------------------------------------------ |
| 176 | + def _attach_context(self, key: str, new_context: Context) -> None: |
| 177 | + """Attach a context and remember its token under ``key``.""" |
| 178 | + with self._lock: |
| 179 | + self._context_tokens[key] = ( |
| 180 | + threading.get_ident(), |
| 181 | + otel_context.attach(new_context), |
| 182 | + ) |
| 183 | + |
| 184 | + def _detach_context(self, key: str) -> None: |
| 185 | + """Detach the context attached under ``key``, restoring its predecessor. |
| 186 | +
|
| 187 | + A context token can only be reset on the thread that created it, so a |
| 188 | + token recorded on another thread is dropped instead of detached (OTel |
| 189 | + logs an error for a cross-thread reset). In practice the pairs always |
| 190 | + line up: invocation hooks run on the Lambda handler thread and |
| 191 | + user-function hooks run on the thread executing user code. |
| 192 | + """ |
| 193 | + with self._lock: |
| 194 | + entry = self._context_tokens.pop(key, None) |
| 195 | + if entry is None: |
| 196 | + return |
| 197 | + thread_ident, token = entry |
| 198 | + if thread_ident == threading.get_ident(): |
| 199 | + otel_context.detach(token) # type: ignore[arg-type] |
| 200 | + |
| 201 | + def _detach_remaining_contexts(self) -> None: |
| 202 | + """Release scopes still open, newest first, so nothing outlives the plugin. |
| 203 | +
|
| 204 | + Reached when a lifecycle end hook never fires -- for example a user |
| 205 | + function that suspends, or a warm invocation that starts before the |
| 206 | + previous one was cleaned up. |
| 207 | + """ |
| 208 | + with self._lock: |
| 209 | + keys = list(reversed(self._context_tokens)) |
| 210 | + for key in keys: |
| 211 | + self._detach_context(key) |
| 212 | + |
160 | 213 | def get_current_span_context(self) -> SpanContext | None: |
161 | 214 | """Return the active span context for log correlation (see log_filter).""" |
162 | 215 | span_context = trace.get_current_span().get_span_context() |
@@ -225,10 +278,13 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None: |
225 | 278 | self._start_invocation_span(info) |
226 | 279 |
|
227 | 280 | # Make the Workflow span the active span so auto-instrumented spans |
228 | | - # created during the invocation become its children. |
| 281 | + # created during the invocation become its children. The token is |
| 282 | + # released in _reset_state at invocation end, restoring the context that |
| 283 | + # was active before the invocation started. |
229 | 284 | if self._workflow_span is not None: |
230 | | - otel_context.attach( |
231 | | - trace.set_span_in_context(self._workflow_span, self._extracted_context) |
| 285 | + self._attach_context( |
| 286 | + _INVOCATION_CONTEXT_KEY, |
| 287 | + trace.set_span_in_context(self._workflow_span, self._extracted_context), |
232 | 288 | ) |
233 | 289 |
|
234 | 290 | def _start_workflow_span(self, info: InvocationStartInfo) -> None: |
@@ -324,6 +380,7 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: |
324 | 380 | logger.exception("force_flush failed at invocation end") |
325 | 381 |
|
326 | 382 | def _reset_state(self) -> None: |
| 383 | + self._detach_remaining_contexts() |
327 | 384 | self._execution_arn = "" |
328 | 385 | self._execution_trace_id = None |
329 | 386 | self._extracted_context = None |
@@ -439,25 +496,29 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None: |
439 | 496 | info.parent_id |
440 | 497 | ) |
441 | 498 | name = f"{info.name or info.operation_id} attempt {info.attempt or 1}" |
| 499 | + key = self._attempt_key(info) |
442 | 500 | span = self._start_span( |
443 | 501 | operation_id=info.operation_id, |
444 | 502 | name=name, |
445 | 503 | info=info, |
446 | 504 | parent=parent, |
447 | 505 | start_time=info.start_time, |
448 | | - span_key=self._attempt_key(info), |
| 506 | + span_key=key, |
449 | 507 | deterministic=False, |
450 | 508 | ) |
451 | 509 | else: # CONTEXT |
452 | 510 | parent = self._resolve_parent(info.parent_id) |
| 511 | + key = info.operation_id |
453 | 512 | span = self._start_span( |
454 | 513 | operation_id=info.operation_id, |
455 | 514 | name=info.name or info.operation_id, |
456 | 515 | info=info, |
457 | 516 | parent=parent, |
458 | 517 | start_time=info.start_time, |
459 | 518 | ) |
460 | | - otel_context.attach(trace.set_span_in_context(span, self._extracted_context)) |
| 519 | + self._attach_context( |
| 520 | + key, trace.set_span_in_context(span, self._extracted_context) |
| 521 | + ) |
461 | 522 |
|
462 | 523 | def on_user_function_end(self, info: UserFunctionEndInfo) -> None: |
463 | 524 | logger.debug("Durable user function ended: %s", info) |
@@ -500,16 +561,11 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None: |
500 | 561 | if popped is not None: |
501 | 562 | popped.end(end_time=_to_otel_timestamp(end_time)) |
502 | 563 |
|
503 | | - # Restore the enclosing span as active (parent op, else invocation/workflow). |
504 | | - enclosing = ( |
505 | | - self._get_span(info.parent_id) |
506 | | - or self._invocation_span |
507 | | - or self._workflow_span |
508 | | - ) |
509 | | - if enclosing is not None: |
510 | | - otel_context.attach( |
511 | | - trace.set_span_in_context(enclosing, self._extracted_context) |
512 | | - ) |
| 564 | + # Restore the enclosing context by releasing the scope this user |
| 565 | + # function attached, so the parent operation (or, at the top level, the |
| 566 | + # context that was active before the operation) becomes current again |
| 567 | + # without stacking another scope. |
| 568 | + self._detach_context(key) |
513 | 569 |
|
514 | 570 | # ------------------------------------------------------------------ |
515 | 571 | # Attributes |
|
0 commit comments