Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions src/openarmature/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from __future__ import annotations

import json
import uuid
from collections.abc import Sequence
from typing import Any, Literal, cast

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

try:
response = await self._do_complete(body)
except Exception as exc:
if dispatch is not None:
dispatch(_make_llm_event("completed", model=self.model, error=exc))
dispatch(_make_llm_event("completed", call_id=call_id, model=self.model, error=exc))
raise

if dispatch is not None:
dispatch(
_make_llm_event(
"completed",
call_id=call_id,
model=self.model,
finish_reason=response.finish_reason,
usage=response.usage,
Expand Down Expand Up @@ -537,8 +547,16 @@ class _LlmEventState(State):
Langfuse / Datadog adapters) recognize the
``("openarmature.llm.complete",)`` namespace sentinel and read
these fields directly via attribute access.

``call_id`` is the per-call disambiguator: a UUIDv4 minted in
``OpenAIProvider.complete`` and shared between the started /
completed event pair. Backend observers key their in-flight
LLM-span maps by it so concurrent ``complete()`` calls (e.g.,
fan-out instances each calling the provider) don't collide on
a single sentinel-namespace key.
"""

call_id: str
model: str
finish_reason: str | None = None
prompt_tokens: int | None = None
Expand All @@ -558,6 +576,7 @@ class _LlmEventState(State):
def _make_llm_event(
phase: Literal["started", "completed"],
*,
call_id: str,
model: str,
finish_reason: FinishReason | None = None,
usage: Usage | None = None,
Expand All @@ -569,6 +588,9 @@ def _make_llm_event(
span instead of a node span. Backend-specific attribute extraction
reads ``model``, ``finish_reason``, and ``usage`` from
``pre_state`` directly via attribute access.

``call_id`` MUST be the same string on the started/completed
pair so the observer can match them under concurrency.
"""
error_type: str | None = None
error_message: str | None = None
Expand All @@ -580,6 +602,7 @@ def _make_llm_event(
if isinstance(category, str):
error_category = category
payload = _LlmEventState(
call_id=call_id,
model=model,
finish_reason=finish_reason,
prompt_tokens=usage.prompt_tokens if usage is not None else None,
Expand Down
78 changes: 67 additions & 11 deletions src/openarmature/observability/otel/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@
_invocation_span: dict[str, _OpenSpan] = field(
init=False, repr=False, default_factory=dict[str, _OpenSpan]
)
# Per-LLM-call span tracking, keyed by ``call_id`` (UUIDv4
# minted in ``OpenAIProvider.complete`` per call). Concurrent
# ``complete()`` calls under fan-out instances would collide on
# a constant ``_LLM_NAMESPACE`` key in ``_open_spans``; the
# call_id-keyed dict disambiguates them. The deeper
# parent-attribution issue under concurrency (calling node's
# context not threaded through) is Phase 6.1 work.
_open_llm_spans: dict[str, _OpenSpan] = field(
init=False, repr=False, default_factory=dict[str, _OpenSpan]
)
# Synthetic subgraph dispatch spans: the engine wrapper for
# ``add_subgraph_node`` is transparent (graph-engine fixture 013
# — no started/completed events of its own), but observability
Expand Down Expand Up @@ -306,7 +316,19 @@
span.end()
token = open_span.token
if token is not None:
detach(cast("Any", token))
try:
detach(cast("Any", token))
except ValueError:
# Out-of-LIFO detach: under interleaved
# fan-out-instance events, ``completed`` for an
# earlier-started span can arrive while a
# later-started span's token is on top of the OTel
# context stack. The proper fix is Phase 6.1's
# "don't hold attach tokens across event
# boundaries"; the guard here keeps Phase 6.0
# robust to the interleave without corrupting
# subsequent span attribution.
pass
# If this was a detached root, drop the root entry so a
# subsequent re-entry mints a fresh trace.
self._detached_roots.pop(event.namespace, None)
Expand Down Expand Up @@ -371,9 +393,9 @@
attributes=attrs,
)
token = attach(set_span_in_context(span))
self._open_spans[self._llm_key()] = _OpenSpan(span=span, token=token)
self._open_llm_spans[payload.call_id] = _OpenSpan(span=span, token=token)
elif event.phase == "completed":
open_span = self._open_spans.pop(self._llm_key(), None)
open_span = self._open_llm_spans.pop(payload.call_id, None)
if open_span is None:
return
span = open_span.span
Expand All @@ -397,7 +419,16 @@
else:
span.set_status(Status(StatusCode.OK))
span.end()
detach(cast("Any", open_span.token))
try:
detach(cast("Any", open_span.token))
except ValueError:
# See ``_handle_completed`` for the rationale —
# out-of-LIFO detach under concurrent fan-out
# instance events is a known Phase 6.0 limitation;
# the proper fix is Phase 6.1's
# "don't hold attach tokens across event
# boundaries."
pass

# ------------------------------------------------------------------
# Helpers
Expand Down Expand Up @@ -432,11 +463,6 @@
def _key_for(self, event: NodeEvent) -> _StackKey:
return (event.namespace, event.attempt_index, event.fan_out_index)

def _llm_key(self) -> _StackKey:
"""LLM events are unique within a node body — only one LLM
call can be open at a time per active span scope."""
return (_LLM_NAMESPACE, 0, None)

def _resolve_parent_context(self, event: NodeEvent) -> object:
"""Return the OTel context to use as the parent for this
event's span. Walks namespace ancestors finding the
Expand Down Expand Up @@ -718,6 +744,21 @@
# context entry leaks cosmetically.
pass

@staticmethod
def _drain_open_span(open_span: _OpenSpan) -> None:
"""Close an open span as an orphan during shutdown: OK
status, end, and try-detach the token. No paired completed
event will arrive, so we don't have an error category to
record. Cross-context detach is swallowed (the worker that
owns the context eventually unwinds)."""
open_span.span.set_status(Status(StatusCode.OK))
open_span.span.end()
if open_span.token is not None:
try:
detach(cast("Any", open_span.token))
except ValueError:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass

def _fan_out_node_span_key(self, prefix: tuple[str, ...]) -> _StackKey:
"""Build the lookup key for a fan-out node's own span (the
parent dispatch span). Fan-out node has no attempt_index ≠ 0
Expand Down Expand Up @@ -782,8 +823,23 @@
open_span.span.end()

def shutdown(self) -> None:
"""Close any still-open invocation/detached spans and shut
down the underlying provider."""
"""Close any still-open spans (leaf node spans, LLM spans,
detached roots, synthetic subgraph dispatch spans, invocation
spans) and shut down the underlying provider. Walks state
maps in child→parent order so OTel parent/child reporting
stays coherent. Idempotent."""
for key in list(self._open_spans.keys()):
open_span = self._open_spans.pop(key, None)
if open_span is not None:
self._drain_open_span(open_span)
for call_id in list(self._open_llm_spans.keys()):
Comment thread
chris-colinsky marked this conversation as resolved.
Outdated
open_span = self._open_llm_spans.pop(call_id, None)
if open_span is not None:
self._drain_open_span(open_span)
for prefix in list(self._detached_roots.keys()):
self._close_detached_root(prefix)
for prefix in list(self._subgraph_spans.keys()):
self._close_subgraph_span(prefix)
for cid in list(self._invocation_span.keys()):
self._close_invocation_span(cid)
self._provider.shutdown()
Expand Down
2 changes: 1 addition & 1 deletion tests/conformance/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
# import time when the extras are missing, which would fail
# collection rather than skipping cleanly. Mirrors the pattern in
# ``tests/unit/test_observability_otel.py``.
pytest.importorskip("opentelemetry.trace")
pytest.importorskip("opentelemetry.sdk.trace")

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

Expand Down
28 changes: 19 additions & 9 deletions tests/unit/test_observability_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import pytest

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

from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
Expand Down Expand Up @@ -274,7 +274,7 @@ async def test_disable_llm_spans_skips_llm_provider_span() -> None:
namespace=("openarmature.llm.complete",),
step=-1,
phase="started",
pre_state=_LlmEventState(model="test-m"),
pre_state=_LlmEventState(call_id="test-call-1", model="test-m"),
post_state=None,
error=None,
parent_states=(),
Expand All @@ -284,7 +284,7 @@ async def test_disable_llm_spans_skips_llm_provider_span() -> None:
namespace=("openarmature.llm.complete",),
step=-1,
phase="completed",
pre_state=_LlmEventState(model="test-m", finish_reason="stop"),
pre_state=_LlmEventState(call_id="test-call-1", model="test-m", finish_reason="stop"),
post_state=None,
error=None,
parent_states=(),
Expand Down Expand Up @@ -349,9 +349,19 @@ def test_install_log_bridge_is_idempotent() -> None:
LoggingHandler on the root logger."""
from opentelemetry.sdk._logs import LoggerProvider

provider = LoggerProvider()
install_log_bridge(provider)
handler_count_before = len(logging.getLogger().handlers)
install_log_bridge(provider)
handler_count_after = len(logging.getLogger().handlers)
assert handler_count_before == handler_count_after
root = logging.getLogger()
prior_handlers = list(root.handlers)
prior_filters = list(root.filters)
try:
provider = LoggerProvider()
install_log_bridge(provider)
handler_count_before = len(root.handlers)
install_log_bridge(provider)
handler_count_after = len(root.handlers)
assert handler_count_before == handler_count_after
finally:
# install_log_bridge mutates the process-wide root logger;
# restore the prior handler + filter set so this test does
# not leak state into others.
root.handlers[:] = prior_handlers
root.filters[:] = prior_filters
Loading