Skip to content

Commit 55e9c51

Browse files
otel: address PR #24 review
- Fix invocation span status propagation. The OTelObserver was unconditionally calling set_status(OK) on the invocation span in _close_invocation_span; OTel doesn't auto-propagate child status to parents, so the spec §4.2 / fixture 003 contract ("invocation span ends ERROR when a child errored") wasn't satisfied. _handle_completed now sets ERROR on the invocation span when a child errors; _close_invocation_span no longer forces OK (clean invocations end UNSET, which exporters map to OK; ERROR is preserved by OTel SDK status precedence). Driver _run_fixture_003 gains the explicit invocation-status ERROR assertion + docstring fix. - Fix _signature in _run_fixture_011 to include parent_name. Previously the docstring claimed "hierarchy" and the comment said "parent-by-name" but the signature ignored span.parent entirely. Now looks up parent.span_id in a per-run by-id map and includes the parent's name; a hierarchy regression where a node reparented to a different ancestor now surfaces as a signature divergence. - Fix comment drift in _run_fixture_011_case: the translated edge condition is "equals: 1" (matching the deterministic counter == 1 input), not "equals: True".
1 parent 49b608e commit 55e9c51

2 files changed

Lines changed: 82 additions & 25 deletions

File tree

src/openarmature/observability/otel/observer.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,17 @@ def _handle_completed(self, event: NodeEvent) -> None:
297297
span.set_status(Status(StatusCode.ERROR, description=event.error.category))
298298
span.record_exception(event.error)
299299
span.set_attribute("openarmature.error.category", event.error.category)
300+
# Per spec §4.2 / fixture 003: the invocation span MUST
301+
# end with ERROR status when any child node errors. OTel
302+
# doesn't auto-propagate child status to parents — we set
303+
# it explicitly here. The OTel SDK's status-precedence
304+
# rule preserves ERROR through any subsequent
305+
# ``set_status(OK)`` calls (only UNSET → OK transitions
306+
# are honoured), so the close path's UNSET-leave still
307+
# works for clean invocations.
308+
inv_open = self._invocation_span.get(invocation_id)
309+
if inv_open is not None:
310+
inv_open.span.set_status(Status(StatusCode.ERROR, description=event.error.category))
300311
else:
301312
span.set_status(Status(StatusCode.OK))
302313
span.end()
@@ -839,11 +850,18 @@ def _close_invocation_span(self, invocation_id: str) -> None:
839850
open_span = self._invocation_span.pop(invocation_id, None)
840851
if open_span is None:
841852
return
842-
# Status defaults to OK for completed invocations; if the
843-
# engine surfaced an error, the failing node's span
844-
# already carries it and OTel propagates ERROR up the
845-
# parent chain on its own.
846-
open_span.span.set_status(Status(StatusCode.OK))
853+
# Don't unconditionally call ``set_status(OK)`` here. OTel
854+
# doesn't auto-propagate child span status to parents, so
855+
# the spec §4.2 / fixture 003 contract ("invocation span
856+
# ends ERROR when a child errored") is satisfied by
857+
# ``_handle_completed`` setting ERROR on this span when an
858+
# error event fires. Calling ``set_status(OK)`` here would
859+
# be a no-op when ERROR was already set (OTel SDK
860+
# status-precedence preserves ERROR), but it's clearer to
861+
# leave the status UNSET in the clean-completion path —
862+
# exporters map UNSET to OK by convention, and the explicit
863+
# ERROR-set in ``_handle_completed`` handles the failure
864+
# path.
847865
open_span.span.end()
848866

849867
def shutdown(self) -> None:

tests/conformance/test_observability.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,10 @@ async def _run_fixture_003(spec: Mapping[str, Any]) -> None:
318318
with the canonical category in the description, an exception
319319
event recorded, and the ``openarmature.error.category``
320320
attribute. Sibling spans before the failure stay OK; the
321-
invocation span propagates ERROR via OTel's parent-status-from-
322-
failed-children semantics."""
321+
invocation span ends ERROR (OTel doesn't auto-propagate child
322+
status to parents, so the OTelObserver explicitly sets ERROR
323+
on the invocation span when any child errors per
324+
``_handle_completed``)."""
323325
from opentelemetry.trace import StatusCode
324326

325327
from openarmature.graph import RuntimeGraphError
@@ -361,6 +363,16 @@ async def _run_fixture_003(spec: Mapping[str, Any]) -> None:
361363
f"fail_node MUST have at least one 'exception' event recorded; got {event_names}"
362364
)
363365

366+
# Invocation span ends ERROR when any child errors per spec
367+
# §4.2 / fixture 003. The OTelObserver sets ERROR explicitly in
368+
# ``_handle_completed`` (OTel doesn't auto-propagate child status
369+
# to parents).
370+
inv = by_name.get("openarmature.invocation")
371+
assert inv is not None
372+
assert inv.status.status_code == StatusCode.ERROR, (
373+
f"invocation span status MUST be ERROR when a child errored; got {inv.status.status_code}"
374+
)
375+
364376

365377
# ---------------------------------------------------------------------------
366378
# Fixture 007 — retry attempt spans
@@ -523,10 +535,15 @@ def _classifier(exc: Exception, _state: Any, _transient: frozenset[str] = transi
523535

524536

525537
async def _run_fixture_011(spec: Mapping[str, Any]) -> None:
526-
"""Spec §8: deterministic span content (hierarchy, span names,
527-
span status, attributes minus the non-deterministic-by-design
528-
ignore list) is identical across two invocations of the same
529-
graph with the same input."""
538+
"""Spec §8: deterministic span content is identical across two
539+
invocations of the same graph with the same input. The
540+
signature compared per-span:
541+
``(name, status_code, parent_name, attrs ∖ ignored_set)``.
542+
Parent linkage is encoded as the parent span's NAME rather
543+
than its span_id (span_ids are non-deterministic per OTel SDK's
544+
default RandomIdGenerator); a hierarchy regression where a
545+
node reparented to a different ancestor surfaces as a
546+
parent_name divergence."""
530547
cases = cast("list[dict[str, Any]]", spec["cases"])
531548
for case in cases:
532549
case_name = cast("str", case["name"])
@@ -539,14 +556,16 @@ async def _run_fixture_011(spec: Mapping[str, Any]) -> None:
539556
async def _run_fixture_011_case(case: Mapping[str, Any]) -> None:
540557
# Translate the fixture's ``when:`` conditional-edge syntax
541558
# (``when: {field: counter, gt: 0}``) into the adapter's
542-
# ``condition: {if_field, equals, then, else}`` shape via a
543-
# closure-based equality predicate. This case's ``gt: 0``
544-
# against an int field is equivalent to ``equals: True`` once
545-
# we project the gt-comparison into a state-derived bool — the
559+
# ``condition: {if_field, equals, then, else}`` shape. The
546560
# adapter doesn't have a ``gt`` builder, but the deterministic
547-
# input here means the same branch always fires, so the
548-
# determinism comparison doesn't depend on which adapter
549-
# construct represents the edge.
561+
# input means ``counter == 1`` always — so ``gt: 0`` is
562+
# functionally equivalent to ``equals: 1`` for this fixture's
563+
# flow. The determinism comparison itself doesn't depend on
564+
# which adapter construct represents the edge; the same
565+
# branch always fires under identical inputs. (Generic
566+
# ``gt``/``lt``/etc. edge-condition support is tracked under
567+
# the Harness backlog in
568+
# ``openarmature-coord/docs/phase-6-1-conformance-fillin.md``.)
550569
case_for_build = _translate_011_when_edges(case)
551570

552571
invocations = int(case.get("invocations", 2))
@@ -569,23 +588,43 @@ async def _run_fixture_011_case(case: Mapping[str, Any]) -> None:
569588
f"deterministic input MUST produce equal span counts; got {len(runs[0])} vs {len(runs[1])}"
570589
)
571590

572-
# Compare structurally. Group each run's spans by name, then
573-
# within each name-bucket compare deterministic attributes +
574-
# status + parent-by-name (parent span_ids are
575-
# non-deterministic, so we walk by name).
576-
def _signature(span: Any) -> tuple[str, str, tuple[tuple[str, Any], ...]]:
591+
# Compare each span's structural signature across runs. Span
592+
# span_ids are non-deterministic, so we encode the parent
593+
# linkage by looking up parent.span_id in the same run's
594+
# by-id map and including the parent's NAME in the signature.
595+
# That way a hierarchy regression (e.g., a node reparented
596+
# from invocation to a sibling) shows up as a signature
597+
# difference even though both spans' own attributes are
598+
# unchanged.
599+
def _signature(
600+
span: Any, by_id: Mapping[int, Any]
601+
) -> tuple[str, str, str | None, tuple[tuple[str, Any], ...]]:
577602
attrs = dict(span.attributes or {})
578603
deterministic_items = sorted(
579604
(k, _normalize_attr_value(v)) for k, v in attrs.items() if k not in _DETERMINISM_IGNORED_ATTRS
580605
)
606+
parent_name: str | None = None
607+
if span.parent is not None:
608+
parent_span = by_id.get(span.parent.span_id)
609+
if parent_span is not None:
610+
parent_name = cast("str", parent_span.name)
581611
return (
582612
cast("str", span.name),
583613
str(span.status.status_code),
614+
parent_name,
584615
tuple(deterministic_items),
585616
)
586617

587-
sig_run_0 = sorted(_signature(s) for s in runs[0])
588-
sig_run_1 = sorted(_signature(s) for s in runs[1])
618+
by_id_run_0: dict[int, Any] = {}
619+
for s in runs[0]:
620+
if s.context is not None:
621+
by_id_run_0[s.context.span_id] = s
622+
by_id_run_1: dict[int, Any] = {}
623+
for s in runs[1]:
624+
if s.context is not None:
625+
by_id_run_1[s.context.span_id] = s
626+
sig_run_0 = sorted(_signature(s, by_id_run_0) for s in runs[0])
627+
sig_run_1 = sorted(_signature(s, by_id_run_1) for s in runs[1])
589628
assert sig_run_0 == sig_run_1, (
590629
f"deterministic span content MUST match across runs; "
591630
f"first divergence: run_0={sig_run_0!r} vs run_1={sig_run_1!r}"

0 commit comments

Comments
 (0)