Skip to content

Commit d87feaa

Browse files
observability: PR #19 round-7 review followups (#20)
* observability: PR #19 round-7 review followups - Guard cross-context detach in _handle_completed and _handle_llm_event so out-of-LIFO completion under concurrent fan-out doesn't crash the runtime (proper fix in Phase 6.1). - Add call_id (UUIDv4) on _LlmEventState so concurrent LLM calls don't collide on the constant LLM-namespace key in _open_llm_spans. - Switch importorskip target to opentelemetry.sdk.trace in both observability test modules so a partial install (API but no SDK) skips cleanly instead of erroring at collection. - Drain leaf spans, LLM spans, detached roots, and synthetic subgraph dispatch spans on OTelObserver.shutdown() — now matches the docstring contract regardless of where the invocation ended. - Snapshot + restore root logger handlers/filters in test_install_log_bridge_is_idempotent so it doesn't leak state into other tests. * otel: address PR #20 review - Add inline comment in _drain_open_span's except ValueError so CodeQL/code-quality stop flagging the bare swallow. Matches the existing pattern at the four other cross-context-detach sites. - Fix shutdown() ordering. The docstring promised child->parent closure but iterating `list(map.keys())` follows insertion order (parents first), so children outlived their parents in traces. Now drains _open_llm_spans (deepest leaves) before _open_spans; sorts the depth-bearing maps (_open_spans, _detached_roots, _subgraph_spans) by -len(namespace) so deeper entries close first. _open_llm_spans (call_id keys) and _invocation_span (one entry per cid) need no within-map sort.
1 parent 3805e4f commit d87feaa

4 files changed

Lines changed: 120 additions & 23 deletions

File tree

src/openarmature/llm/providers/openai.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from __future__ import annotations
3939

4040
import json
41+
import uuid
4142
from collections.abc import Sequence
4243
from typing import Any, Literal, cast
4344

@@ -203,21 +204,30 @@ async def complete(
203204
# ``None`` outside an openarmature invocation (direct
204205
# provider use in scripts/tests), in which case the call
205206
# proceeds without span emission.
207+
#
208+
# ``call_id`` is minted once per ``complete()`` call and
209+
# threaded through both events of the pair. Backend
210+
# observers key their in-flight LLM-span maps by it so
211+
# concurrent ``complete()`` calls (e.g., fan-out instances
212+
# each calling this provider) don't collide on the
213+
# constant ``("openarmature.llm.complete",)`` sentinel.
206214
dispatch = current_dispatch()
215+
call_id = str(uuid.uuid4())
207216
if dispatch is not None:
208-
dispatch(_make_llm_event("started", model=self.model))
217+
dispatch(_make_llm_event("started", call_id=call_id, model=self.model))
209218

210219
try:
211220
response = await self._do_complete(body)
212221
except Exception as exc:
213222
if dispatch is not None:
214-
dispatch(_make_llm_event("completed", model=self.model, error=exc))
223+
dispatch(_make_llm_event("completed", call_id=call_id, model=self.model, error=exc))
215224
raise
216225

217226
if dispatch is not None:
218227
dispatch(
219228
_make_llm_event(
220229
"completed",
230+
call_id=call_id,
221231
model=self.model,
222232
finish_reason=response.finish_reason,
223233
usage=response.usage,
@@ -537,8 +547,16 @@ class _LlmEventState(State):
537547
Langfuse / Datadog adapters) recognize the
538548
``("openarmature.llm.complete",)`` namespace sentinel and read
539549
these fields directly via attribute access.
550+
551+
``call_id`` is the per-call disambiguator: a UUIDv4 minted in
552+
``OpenAIProvider.complete`` and shared between the started /
553+
completed event pair. Backend observers key their in-flight
554+
LLM-span maps by it so concurrent ``complete()`` calls (e.g.,
555+
fan-out instances each calling the provider) don't collide on
556+
a single sentinel-namespace key.
540557
"""
541558

559+
call_id: str
542560
model: str
543561
finish_reason: str | None = None
544562
prompt_tokens: int | None = None
@@ -558,6 +576,7 @@ class _LlmEventState(State):
558576
def _make_llm_event(
559577
phase: Literal["started", "completed"],
560578
*,
579+
call_id: str,
561580
model: str,
562581
finish_reason: FinishReason | None = None,
563582
usage: Usage | None = None,
@@ -569,6 +588,9 @@ def _make_llm_event(
569588
span instead of a node span. Backend-specific attribute extraction
570589
reads ``model``, ``finish_reason``, and ``usage`` from
571590
``pre_state`` directly via attribute access.
591+
592+
``call_id`` MUST be the same string on the started/completed
593+
pair so the observer can match them under concurrency.
572594
"""
573595
error_type: str | None = None
574596
error_message: str | None = None
@@ -580,6 +602,7 @@ def _make_llm_event(
580602
if isinstance(category, str):
581603
error_category = category
582604
payload = _LlmEventState(
605+
call_id=call_id,
583606
model=model,
584607
finish_reason=finish_reason,
585608
prompt_tokens=usage.prompt_tokens if usage is not None else None,

src/openarmature/observability/otel/observer.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,16 @@ class OTelObserver:
181181
_invocation_span: dict[str, _OpenSpan] = field(
182182
init=False, repr=False, default_factory=dict[str, _OpenSpan]
183183
)
184+
# Per-LLM-call span tracking, keyed by ``call_id`` (UUIDv4
185+
# minted in ``OpenAIProvider.complete`` per call). Concurrent
186+
# ``complete()`` calls under fan-out instances would collide on
187+
# a constant ``_LLM_NAMESPACE`` key in ``_open_spans``; the
188+
# call_id-keyed dict disambiguates them. The deeper
189+
# parent-attribution issue under concurrency (calling node's
190+
# context not threaded through) is Phase 6.1 work.
191+
_open_llm_spans: dict[str, _OpenSpan] = field(
192+
init=False, repr=False, default_factory=dict[str, _OpenSpan]
193+
)
184194
# Synthetic subgraph dispatch spans: the engine wrapper for
185195
# ``add_subgraph_node`` is transparent (graph-engine fixture 013
186196
# — no started/completed events of its own), but observability
@@ -306,7 +316,19 @@ def _handle_completed(self, event: NodeEvent) -> None:
306316
span.end()
307317
token = open_span.token
308318
if token is not None:
309-
detach(cast("Any", token))
319+
try:
320+
detach(cast("Any", token))
321+
except ValueError:
322+
# Out-of-LIFO detach: under interleaved
323+
# fan-out-instance events, ``completed`` for an
324+
# earlier-started span can arrive while a
325+
# later-started span's token is on top of the OTel
326+
# context stack. The proper fix is Phase 6.1's
327+
# "don't hold attach tokens across event
328+
# boundaries"; the guard here keeps Phase 6.0
329+
# robust to the interleave without corrupting
330+
# subsequent span attribution.
331+
pass
310332
# If this was a detached root, drop the root entry so a
311333
# subsequent re-entry mints a fresh trace.
312334
self._detached_roots.pop(event.namespace, None)
@@ -371,9 +393,9 @@ def _handle_llm_event(self, event: NodeEvent) -> None:
371393
attributes=attrs,
372394
)
373395
token = attach(set_span_in_context(span))
374-
self._open_spans[self._llm_key()] = _OpenSpan(span=span, token=token)
396+
self._open_llm_spans[payload.call_id] = _OpenSpan(span=span, token=token)
375397
elif event.phase == "completed":
376-
open_span = self._open_spans.pop(self._llm_key(), None)
398+
open_span = self._open_llm_spans.pop(payload.call_id, None)
377399
if open_span is None:
378400
return
379401
span = open_span.span
@@ -397,7 +419,16 @@ def _handle_llm_event(self, event: NodeEvent) -> None:
397419
else:
398420
span.set_status(Status(StatusCode.OK))
399421
span.end()
400-
detach(cast("Any", open_span.token))
422+
try:
423+
detach(cast("Any", open_span.token))
424+
except ValueError:
425+
# See ``_handle_completed`` for the rationale —
426+
# out-of-LIFO detach under concurrent fan-out
427+
# instance events is a known Phase 6.0 limitation;
428+
# the proper fix is Phase 6.1's
429+
# "don't hold attach tokens across event
430+
# boundaries."
431+
pass
401432

402433
# ------------------------------------------------------------------
403434
# Helpers
@@ -432,11 +463,6 @@ def _open_invocation_span(self, correlation_id: str, event: NodeEvent) -> None:
432463
def _key_for(self, event: NodeEvent) -> _StackKey:
433464
return (event.namespace, event.attempt_index, event.fan_out_index)
434465

435-
def _llm_key(self) -> _StackKey:
436-
"""LLM events are unique within a node body — only one LLM
437-
call can be open at a time per active span scope."""
438-
return (_LLM_NAMESPACE, 0, None)
439-
440466
def _resolve_parent_context(self, event: NodeEvent) -> object:
441467
"""Return the OTel context to use as the parent for this
442468
event's span. Walks namespace ancestors finding the
@@ -718,6 +744,24 @@ def _close_detached_root(self, prefix: tuple[str, ...]) -> None:
718744
# context entry leaks cosmetically.
719745
pass
720746

747+
@staticmethod
748+
def _drain_open_span(open_span: _OpenSpan) -> None:
749+
"""Close an open span as an orphan during shutdown: OK
750+
status, end, and try-detach the token. No paired completed
751+
event will arrive, so we don't have an error category to
752+
record. Cross-context detach is swallowed (the worker that
753+
owns the context eventually unwinds)."""
754+
open_span.span.set_status(Status(StatusCode.OK))
755+
open_span.span.end()
756+
if open_span.token is not None:
757+
try:
758+
detach(cast("Any", open_span.token))
759+
except ValueError:
760+
# Cross-context detach during shutdown — token
761+
# created in a different OTel context. Span has
762+
# ended; ignore.
763+
pass
764+
721765
def _fan_out_node_span_key(self, prefix: tuple[str, ...]) -> _StackKey:
722766
"""Build the lookup key for a fan-out node's own span (the
723767
parent dispatch span). Fan-out node has no attempt_index ≠ 0
@@ -782,8 +826,28 @@ def _close_invocation_span(self, correlation_id: str) -> None:
782826
open_span.span.end()
783827

784828
def shutdown(self) -> None:
785-
"""Close any still-open invocation/detached spans and shut
786-
down the underlying provider."""
829+
"""Close any still-open spans and shut down the underlying
830+
provider. Walks state maps in child→parent order — LLM
831+
spans (deepest leaves) before leaf node spans before
832+
subgraph dispatch / detached roots before invocation spans
833+
— and within each depth-bearing map sorts by namespace
834+
depth (deepest first) so children end before their
835+
parents. ``_open_llm_spans`` (call_id keys) and
836+
``_invocation_span`` (one entry per correlation_id) carry
837+
no internal nesting and drain in insertion order.
838+
Idempotent."""
839+
for call_id in list(self._open_llm_spans.keys()):
840+
open_span = self._open_llm_spans.pop(call_id, None)
841+
if open_span is not None:
842+
self._drain_open_span(open_span)
843+
for key in sorted(self._open_spans.keys(), key=lambda k: -len(k[0])):
844+
open_span = self._open_spans.pop(key, None)
845+
if open_span is not None:
846+
self._drain_open_span(open_span)
847+
for prefix in sorted(self._detached_roots.keys(), key=lambda k: -len(k)):
848+
self._close_detached_root(prefix)
849+
for prefix in sorted(self._subgraph_spans.keys(), key=lambda k: -len(k)):
850+
self._close_subgraph_span(prefix)
787851
for cid in list(self._invocation_span.keys()):
788852
self._close_invocation_span(cid)
789853
self._provider.shutdown()

tests/conformance/test_observability.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
# import time when the extras are missing, which would fail
3939
# collection rather than skipping cleanly. Mirrors the pattern in
4040
# ``tests/unit/test_observability_otel.py``.
41-
pytest.importorskip("opentelemetry.trace")
41+
pytest.importorskip("opentelemetry.sdk.trace")
4242

4343
from openarmature.observability.otel import OTelObserver # noqa: E402
4444

tests/unit/test_observability_otel.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import pytest
2727

2828
# Skip the entire module if otel extras aren't installed.
29-
pytest.importorskip("opentelemetry.trace")
29+
pytest.importorskip("opentelemetry.sdk.trace")
3030

3131
from opentelemetry import trace as otel_trace
3232
from opentelemetry.sdk.trace import TracerProvider
@@ -274,7 +274,7 @@ async def test_disable_llm_spans_skips_llm_provider_span() -> None:
274274
namespace=("openarmature.llm.complete",),
275275
step=-1,
276276
phase="started",
277-
pre_state=_LlmEventState(model="test-m"),
277+
pre_state=_LlmEventState(call_id="test-call-1", model="test-m"),
278278
post_state=None,
279279
error=None,
280280
parent_states=(),
@@ -284,7 +284,7 @@ async def test_disable_llm_spans_skips_llm_provider_span() -> None:
284284
namespace=("openarmature.llm.complete",),
285285
step=-1,
286286
phase="completed",
287-
pre_state=_LlmEventState(model="test-m", finish_reason="stop"),
287+
pre_state=_LlmEventState(call_id="test-call-1", model="test-m", finish_reason="stop"),
288288
post_state=None,
289289
error=None,
290290
parent_states=(),
@@ -349,9 +349,19 @@ def test_install_log_bridge_is_idempotent() -> None:
349349
LoggingHandler on the root logger."""
350350
from opentelemetry.sdk._logs import LoggerProvider
351351

352-
provider = LoggerProvider()
353-
install_log_bridge(provider)
354-
handler_count_before = len(logging.getLogger().handlers)
355-
install_log_bridge(provider)
356-
handler_count_after = len(logging.getLogger().handlers)
357-
assert handler_count_before == handler_count_after
352+
root = logging.getLogger()
353+
prior_handlers = list(root.handlers)
354+
prior_filters = list(root.filters)
355+
try:
356+
provider = LoggerProvider()
357+
install_log_bridge(provider)
358+
handler_count_before = len(root.handlers)
359+
install_log_bridge(provider)
360+
handler_count_after = len(root.handlers)
361+
assert handler_count_before == handler_count_after
362+
finally:
363+
# install_log_bridge mutates the process-wide root logger;
364+
# restore the prior handler + filter set so this test does
365+
# not leak state into others.
366+
root.handlers[:] = prior_handlers
367+
root.filters[:] = prior_filters

0 commit comments

Comments
 (0)