|
16 | 16 | - **009-correlation-id-cross-cutting** (Phase 6.0) — every span |
17 | 17 | carries ``openarmature.correlation_id``; back-to-back |
18 | 18 | 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. |
19 | 24 | - **011-determinism** (PR-C) — deterministic span content |
20 | 25 | (hierarchy, names, status, attributes minus the canonical |
21 | 26 | non-deterministic-by-design list) is identical across runs. |
22 | 27 |
|
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 | | -
|
37 | 28 | Per-fixture wiring notes live in |
38 | 29 | ``docs/phase-6-1-conformance-fillin.md``. |
39 | 30 | """ |
|
76 | 67 | "007-otel-retry-attempt-spans", |
77 | 68 | "008-otel-detached-trace-mode", |
78 | 69 | "009-otel-correlation-id-cross-cutting", |
| 70 | + "010-otel-log-correlation", |
79 | 71 | "011-otel-determinism", |
80 | 72 | } |
81 | 73 | ) |
82 | 74 |
|
83 | 75 |
|
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] = {} |
92 | 77 |
|
93 | 78 |
|
94 | 79 | # 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: |
143 | 128 | await _run_fixture_008(spec) |
144 | 129 | elif fixture_id == "009-otel-correlation-id-cross-cutting": |
145 | 130 | await _run_fixture_009(spec) |
| 131 | + elif fixture_id == "010-otel-log-correlation": |
| 132 | + await _run_fixture_010(spec) |
146 | 133 | elif fixture_id == "011-otel-determinism": |
147 | 134 | await _run_fixture_011(spec) |
148 | 135 | else: |
@@ -1427,3 +1414,313 @@ async def delete(self, invocation_id: str) -> None: |
1427 | 1414 | f"original and resumed runs MUST produce DIFFERENT trace_ids " |
1428 | 1415 | f"(per §10.4 step 4 + §5.1); got {len(trace_ids)} distinct trace_ids" |
1429 | 1416 | ) |
| 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