Skip to content

Commit 80ca91d

Browse files
prepare-sync: drive fixture 010 (log correlation)
Step 5 of PR-C.3. Promotes ``010-otel-log-correlation`` from ``_DEFERRED_FIXTURES`` to ``_SUPPORTED_FIXTURES`` and adds a hand-built driver covering both YAML sub-cases. Driver is hand-built rather than going through the conformance adapter — fixture 010's ``emits_log:`` directive isn't an adapter primitive (the adapter recognizes ``update_pure``, ``subgraph``, etc., and silently ignores anything else), and the sub-cases are small enough that hand-built python is clearer than threading a new directive through the adapter. Sub-case 1 (``log_records_carry_trace_span_correlation_ids``): two nodes ``a`` → ``b``, both emit a log on the FIRST line of their body (before any ``await`` — the load-bearing case ``prepare_sync`` exists to cover). Asserts all logs share a trace_id, each log's span_id matches the active node span at emission, and all carry the invocation's correlation_id. Sub-case 2 (``detached_subgraph_log_uses_detached_trace_id...``): outer invocation has a detached subgraph; logs across the boundary land in different traces but share the correlation_id. Outer log fires from per-node middleware on the SubgraphNode wrapper (SubgraphNode wrappers don't get ``prepare_sync`` per spec — the inner detached node handles attach for itself). Asserts trace_ids differ + correlation_id flows unchanged. Helpers ``_setup_isolated_log_bridge`` and ``_restore_log_state`` snapshot/restore root-logger handler+filter+factory state so the process-global ``install_log_bridge`` mutations don't bleed into neighboring tests. ``_enable_test_logger_at_info`` walks the fixture-010 logger up to ``INFO`` so YAML's ``level: INFO`` records actually flow through Python's logger-level filter to the bridge handler — undone on exit.
1 parent 95212aa commit 80ca91d

1 file changed

Lines changed: 319 additions & 22 deletions

File tree

tests/conformance/test_observability.py

Lines changed: 319 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,15 @@
1616
- **009-correlation-id-cross-cutting** (Phase 6.0) — every span
1717
carries ``openarmature.correlation_id``; back-to-back
1818
invocations get distinct UUIDv4s.
19+
- **010-log-correlation** (PR-C.3) — log records emitted from
20+
inside node bodies pick up the active node span's
21+
``trace_id``/``span_id`` via the engine-side
22+
``prepare_sync`` → OTel context attach pipeline; both nested
23+
and detached-trace cases.
1924
- **011-determinism** (PR-C) — deterministic span content
2025
(hierarchy, names, status, attributes minus the canonical
2126
non-deterministic-by-design list) is identical across runs.
2227
23-
Deferred:
24-
25-
- **004-routing-error-attribution** — needs the proposal-0012
26-
ordering swap (completed dispatch after edge eval) so the
27-
preceding node's ``completed`` event carries the routing-error
28-
status. Lands in PR-C.1 once v0.9.0 ships.
29-
- **006-fan-out-instance-attribution** — needs non-detached
30-
fan-out per-instance dispatch span synthesis + ``FanOutConfig``
31-
metadata surfacing. Lands in PR-C.2.
32-
- **010-log-correlation** — needs the synchronous observer prep
33-
hook (``prepare_sync``) so the engine task can attach the
34-
observer's span to OTel context for the duration of node-body
35-
execution. Lands in PR-C.3.
36-
3728
Per-fixture wiring notes live in
3829
``docs/phase-6-1-conformance-fillin.md``.
3930
"""
@@ -76,19 +67,13 @@
7667
"007-otel-retry-attempt-spans",
7768
"008-otel-detached-trace-mode",
7869
"009-otel-correlation-id-cross-cutting",
70+
"010-otel-log-correlation",
7971
"011-otel-determinism",
8072
}
8173
)
8274

8375

84-
_DEFERRED_FIXTURES: dict[str, str] = {
85-
"010-otel-log-correlation": (
86-
"Needs synchronous observer prep hook (prepare_sync) so the engine task can "
87-
"attach the observer's span to OTel context for the duration of node-body "
88-
"execution — observer span creation runs on the worker task today and isn't "
89-
"available synchronously after _dispatch_started. Lands in PR-C.3."
90-
),
91-
}
76+
_DEFERRED_FIXTURES: dict[str, str] = {}
9277

9378

9479
# UUIDv4 canonical form: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (where y in {8,9,a,b}).
@@ -143,6 +128,8 @@ async def test_observability_fixture(fixture_path: Path) -> None:
143128
await _run_fixture_008(spec)
144129
elif fixture_id == "009-otel-correlation-id-cross-cutting":
145130
await _run_fixture_009(spec)
131+
elif fixture_id == "010-otel-log-correlation":
132+
await _run_fixture_010(spec)
146133
elif fixture_id == "011-otel-determinism":
147134
await _run_fixture_011(spec)
148135
else:
@@ -1427,3 +1414,313 @@ async def delete(self, invocation_id: str) -> None:
14271414
f"original and resumed runs MUST produce DIFFERENT trace_ids "
14281415
f"(per §10.4 step 4 + §5.1); got {len(trace_ids)} distinct trace_ids"
14291416
)
1417+
1418+
1419+
# ---------------------------------------------------------------------------
1420+
# Fixture 010 — log correlation (PR-C.3)
1421+
#
1422+
# Two sub-cases. Both build the graph by hand rather than going through the
1423+
# adapter — fixture 010's ``emits_log:`` directive isn't an adapter primitive
1424+
# (the adapter recognizes ``update_pure``, ``subgraph``, etc., and silently
1425+
# ignores anything else), and the sub-cases are small enough that hand-built
1426+
# python is clearer than threading a new directive through the adapter.
1427+
# ---------------------------------------------------------------------------
1428+
1429+
1430+
def _setup_isolated_log_bridge() -> tuple[Any, Any, Any]:
1431+
"""Spin up an OTel ``LoggerProvider`` + ``InMemoryLogRecordExporter`` and
1432+
install the log bridge against the root logger, snapshotting the prior
1433+
log state so the caller can restore it in ``finally`` (the bridge mutates
1434+
process-global ``logging`` state — handlers, factory).
1435+
1436+
Returns ``(exporter, provider, restore_state)`` where ``restore_state``
1437+
is a snapshot to pass to :func:`_restore_log_state`.
1438+
"""
1439+
import logging as _logging # noqa: PLC0415
1440+
1441+
from opentelemetry.sdk._logs import LoggerProvider # noqa: PLC0415
1442+
from opentelemetry.sdk._logs.export import ( # noqa: PLC0415
1443+
InMemoryLogRecordExporter,
1444+
SimpleLogRecordProcessor,
1445+
)
1446+
1447+
from openarmature.observability.otel import install_log_bridge # noqa: PLC0415
1448+
1449+
root = _logging.getLogger()
1450+
snapshot = (list(root.handlers), list(root.filters), _logging.getLogRecordFactory())
1451+
1452+
exporter = InMemoryLogRecordExporter()
1453+
provider = LoggerProvider()
1454+
provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
1455+
install_log_bridge(provider)
1456+
return exporter, provider, snapshot
1457+
1458+
1459+
def _restore_log_state(snapshot: Any) -> None:
1460+
"""Pair to :func:`_setup_isolated_log_bridge` — restores the root logger's
1461+
handler list, filters, and ``LogRecord`` factory to the snapshot taken
1462+
before ``install_log_bridge`` ran."""
1463+
import logging as _logging # noqa: PLC0415
1464+
1465+
handlers, filters, factory = snapshot
1466+
root = _logging.getLogger()
1467+
root.handlers[:] = handlers
1468+
root.filters[:] = filters
1469+
_logging.setLogRecordFactory(factory)
1470+
1471+
1472+
def _enable_test_logger_at_info() -> tuple[Any, int]:
1473+
"""Bring the fixture-010 test logger up to ``INFO`` so YAML's
1474+
``level: INFO`` records actually flow through Python's logger-level
1475+
filter to the bridge handler. Returns ``(logger, prior_level)`` to
1476+
pair with a restore in ``finally``."""
1477+
import logging as _logging # noqa: PLC0415
1478+
1479+
test_logger = _logging.getLogger("openarmature.test.fixture_010")
1480+
prior_level = test_logger.level
1481+
test_logger.setLevel(_logging.INFO)
1482+
return test_logger, prior_level
1483+
1484+
1485+
async def _run_fixture_010(spec: Mapping[str, Any]) -> None:
1486+
"""Two sub-cases: nested-trace log correlation (single graph, all logs
1487+
share the parent trace_id) and detached-subgraph log correlation
1488+
(logs across the detached boundary carry distinct trace_ids but the
1489+
same correlation_id)."""
1490+
cases = cast("list[dict[str, Any]]", spec["cases"])
1491+
for case in cases:
1492+
case_name = cast("str", case["name"])
1493+
try:
1494+
await _run_fixture_010_case(case)
1495+
except AssertionError as e:
1496+
raise AssertionError(f"case {case_name!r}: {e}") from e
1497+
1498+
1499+
async def _run_fixture_010_case(case: Mapping[str, Any]) -> None:
1500+
case_name = cast("str", case["name"])
1501+
if case_name == "log_records_carry_trace_span_correlation_ids":
1502+
await _run_fixture_010_nested_trace(case)
1503+
elif case_name == "detached_subgraph_log_uses_detached_trace_id_keeps_correlation_id":
1504+
await _run_fixture_010_detached(case)
1505+
else:
1506+
raise AssertionError(f"unknown fixture 010 sub-case: {case_name!r}")
1507+
1508+
1509+
async def _run_fixture_010_nested_trace(case: Mapping[str, Any]) -> None:
1510+
"""Sub-case 1: 2 nodes ``a`` → ``b``, both emit logs from the FIRST line
1511+
of their body. The log bridge MUST report all logs in the parent
1512+
trace_id, with each log's span_id matching the active node span at
1513+
emission, and all carrying the invocation's correlation_id."""
1514+
from openarmature.graph import END, GraphBuilder, State # noqa: PLC0415
1515+
1516+
nodes_spec = cast("dict[str, Any]", case["nodes"])
1517+
correlation_id = cast("str", case["caller_correlation_id"])
1518+
1519+
class _S(State):
1520+
x: int = 0
1521+
1522+
test_logger, prior_level = _enable_test_logger_at_info()
1523+
1524+
def _make_body(node_name: str) -> Any:
1525+
spec = cast("dict[str, Any]", nodes_spec[node_name])
1526+
emit_msg = cast("str", spec["emits_log"]["message"])
1527+
update = cast("dict[str, Any]", spec["update_pure"])
1528+
1529+
async def body(_s: _S) -> dict[str, Any]:
1530+
# FIRST line, before any await — the load-bearing case
1531+
# the engine attach via ``prepare_sync`` exists to cover.
1532+
test_logger.info(emit_msg)
1533+
return dict(update)
1534+
1535+
return body
1536+
1537+
builder = GraphBuilder(_S)
1538+
for node_name in nodes_spec:
1539+
builder.add_node(node_name, _make_body(node_name))
1540+
for edge in cast("list[dict[str, Any]]", case["edges"]):
1541+
from_node = cast("str", edge["from"])
1542+
to = edge["to"]
1543+
builder.add_edge(from_node, END if to == "END" else cast("str", to))
1544+
builder.set_entry(cast("str", case["entry"]))
1545+
compiled = builder.compile()
1546+
1547+
observer, span_exporter = _build_observer()
1548+
log_exporter, log_provider, snapshot = _setup_isolated_log_bridge()
1549+
try:
1550+
compiled.attach_observer(observer)
1551+
await compiled.invoke(_S(), correlation_id=correlation_id)
1552+
await compiled.drain()
1553+
observer.shutdown()
1554+
log_provider.force_flush()
1555+
1556+
records = log_exporter.get_finished_logs()
1557+
# Filter to OUR test logger so concurrent test setup noise
1558+
# doesn't contaminate the assertions.
1559+
ours = [r for r in records if str(r.log_record.body) in {"node a executing", "node b executing"}]
1560+
assert len(ours) == 2, (
1561+
f"expected 2 log records (one per node body); got {len(ours)}: "
1562+
f"{[str(r.log_record.body) for r in ours]}"
1563+
)
1564+
1565+
# Group by body for predictable lookup.
1566+
by_body = {str(r.log_record.body): r for r in ours}
1567+
a_log = by_body["node a executing"]
1568+
b_log = by_body["node b executing"]
1569+
1570+
# Invariant: all_logs_same_trace_id.
1571+
trace_ids = {a_log.log_record.trace_id, b_log.log_record.trace_id}
1572+
assert len(trace_ids) == 1, f"all logs MUST share a trace_id (single nested trace); got {trace_ids}"
1573+
1574+
# Invariant: log_span_ids_match_active_span_at_emission.
1575+
spans = span_exporter.get_finished_spans()
1576+
node_span_ids: dict[str, int] = {}
1577+
for s in spans:
1578+
if s.name in {"a", "b"}:
1579+
node_span_ids[s.name] = s.context.span_id
1580+
assert a_log.log_record.span_id == node_span_ids["a"], (
1581+
f"node-a log MUST carry node-a span's span_id; "
1582+
f"got log span_id={a_log.log_record.span_id}, span={node_span_ids['a']}"
1583+
)
1584+
assert b_log.log_record.span_id == node_span_ids["b"], (
1585+
f"node-b log MUST carry node-b span's span_id; "
1586+
f"got log span_id={b_log.log_record.span_id}, span={node_span_ids['b']}"
1587+
)
1588+
1589+
# Invariant: all_logs_carry_correlation_id.
1590+
for r in ours:
1591+
attrs = dict(r.log_record.attributes or {})
1592+
assert attrs.get("openarmature.correlation_id") == correlation_id, (
1593+
f"every log MUST carry openarmature.correlation_id={correlation_id!r}; "
1594+
f"got {attrs.get('openarmature.correlation_id')!r}"
1595+
)
1596+
finally:
1597+
test_logger.setLevel(prior_level)
1598+
_restore_log_state(snapshot)
1599+
1600+
1601+
async def _run_fixture_010_detached(case: Mapping[str, Any]) -> None:
1602+
"""Sub-case 2: outer invocation has a detached subgraph. Logs emitted
1603+
inside the detached subgraph carry the DETACHED trace's trace_id —
1604+
NOT the parent's — while the correlation_id flows unchanged across
1605+
the boundary."""
1606+
from openarmature.graph import END, GraphBuilder, State # noqa: PLC0415
1607+
1608+
correlation_id = cast("str", case["caller_correlation_id"])
1609+
sub_specs = cast("dict[str, Any]", case["subgraphs"])
1610+
inner_spec = cast("dict[str, Any]", sub_specs["detached_inner"])
1611+
outer_nodes = cast("dict[str, Any]", case["nodes"])
1612+
1613+
# Detached subgraph identity → wrapper-node-name translation, same
1614+
# convention as fixture 008. The fixture YAML lists subgraph identities
1615+
# in ``detached_subgraphs:``; OTelObserver keys on the wrapper node's
1616+
# name in the parent graph.
1617+
detached_identities = set(cast("list[str]", case.get("detached_subgraphs") or []))
1618+
wrapper_names: set[str] = set()
1619+
for wrapper_name, node_spec in outer_nodes.items():
1620+
sub_id = cast("dict[str, Any]", node_spec).get("subgraph")
1621+
if isinstance(sub_id, str) and sub_id in detached_identities:
1622+
wrapper_names.add(wrapper_name)
1623+
detached_subgraphs = frozenset(wrapper_names)
1624+
1625+
test_logger, prior_level = _enable_test_logger_at_info()
1626+
1627+
# Inner subgraph (detached_inner): 1 node ``inner`` with
1628+
# ``update_pure: {y: 1}`` + ``emits_log: "inside detached subgraph"``.
1629+
class _Inner(State):
1630+
y: int = 0
1631+
1632+
inner_node_spec = cast("dict[str, Any]", inner_spec["nodes"]["inner"])
1633+
inner_emit = cast("str", inner_node_spec["emits_log"]["message"])
1634+
inner_update = cast("dict[str, Any]", inner_node_spec["update_pure"])
1635+
1636+
async def _inner_body(_s: _Inner) -> dict[str, Any]:
1637+
test_logger.info(inner_emit)
1638+
return dict(inner_update)
1639+
1640+
inner_compiled = (
1641+
GraphBuilder(_Inner)
1642+
.add_node("inner", _inner_body)
1643+
.add_edge("inner", END)
1644+
.set_entry("inner")
1645+
.compile()
1646+
)
1647+
1648+
# Outer graph: ``outer_dispatch`` is a SubgraphNode wrapper around
1649+
# ``inner_compiled`` AND emits a log "before subgraph dispatch".
1650+
# SubgraphNode wrappers don't get ``prepare_sync`` per spec — the
1651+
# outer log is emitted via per-node middleware that fires inside
1652+
# the wrapper's chain. Without an attached span at wrapper scope,
1653+
# the outer log's trace_id is OTel's "no active span" sentinel
1654+
# (0); the inner log's trace_id is the detached trace's. The
1655+
# invariant ``log_trace_ids_differ_when_detached`` holds either
1656+
# way.
1657+
class _Outer(State):
1658+
z: int = 0
1659+
1660+
outer_node_spec = cast("dict[str, Any]", outer_nodes["outer_dispatch"])
1661+
outer_emit = cast("str", outer_node_spec["emits_log"]["message"])
1662+
1663+
async def _outer_log_middleware(s: Any, next_call: Any) -> Mapping[str, Any]:
1664+
test_logger.info(outer_emit)
1665+
return cast("Mapping[str, Any]", await next_call(s))
1666+
1667+
outer_compiled = (
1668+
GraphBuilder(_Outer)
1669+
.add_subgraph_node("outer_dispatch", inner_compiled, middleware=[_outer_log_middleware])
1670+
.add_edge("outer_dispatch", END)
1671+
.set_entry("outer_dispatch")
1672+
.compile()
1673+
)
1674+
1675+
observer, _span_exporter = _build_observer_with_detached(detached_subgraphs)
1676+
log_exporter, log_provider, snapshot = _setup_isolated_log_bridge()
1677+
try:
1678+
outer_compiled.attach_observer(observer)
1679+
await outer_compiled.invoke(_Outer(), correlation_id=correlation_id)
1680+
await outer_compiled.drain()
1681+
observer.shutdown()
1682+
log_provider.force_flush()
1683+
1684+
records = log_exporter.get_finished_logs()
1685+
ours = [r for r in records if str(r.log_record.body) in {outer_emit, inner_emit}]
1686+
assert len(ours) == 2, (
1687+
f"expected 2 log records (outer + inner); got {len(ours)}: "
1688+
f"{[str(r.log_record.body) for r in ours]}"
1689+
)
1690+
1691+
by_body = {str(r.log_record.body): r for r in ours}
1692+
outer_log = by_body[outer_emit]
1693+
inner_log = by_body[inner_emit]
1694+
1695+
# Invariant: log_trace_ids_differ_when_detached.
1696+
assert outer_log.log_record.trace_id != inner_log.log_record.trace_id, (
1697+
f"detached-subgraph log MUST carry the detached trace's trace_id, "
1698+
f"DIFFERENT from the parent log; both got {outer_log.log_record.trace_id}"
1699+
)
1700+
1701+
# Invariant: all_logs_carry_correlation_id.
1702+
for r in ours:
1703+
attrs = dict(r.log_record.attributes or {})
1704+
assert attrs.get("openarmature.correlation_id") == correlation_id, (
1705+
f"every log MUST carry openarmature.correlation_id={correlation_id!r}; "
1706+
f"got {attrs.get('openarmature.correlation_id')!r}"
1707+
)
1708+
finally:
1709+
test_logger.setLevel(prior_level)
1710+
_restore_log_state(snapshot)
1711+
1712+
1713+
def _build_observer_with_detached(detached_subgraphs: frozenset[str]) -> tuple[OTelObserver, Any]:
1714+
"""Variant of :func:`_build_observer` that takes a detached_subgraphs
1715+
set — needed for fixture 010 sub-case 2."""
1716+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: PLC0415
1717+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: PLC0415
1718+
InMemorySpanExporter,
1719+
)
1720+
1721+
exporter = InMemorySpanExporter()
1722+
observer = OTelObserver(
1723+
span_processor=SimpleSpanProcessor(exporter),
1724+
detached_subgraphs=detached_subgraphs,
1725+
)
1726+
return observer, exporter

0 commit comments

Comments
 (0)