Skip to content

Commit 6ad9987

Browse files
authored
fix(sdk): wire parent_trace_id end-to-end on /track v3 + legacy batch (0.13.7) (#64)
* fix(tests): pin test_runtime WAL to tmp_path to avoid cross-Python flake The test_runtime fixture in test_protect_branches.py built a real NullRunRuntime(api_key, _test_mode=True) without going through the mock_api conftest. The runtime's Transport.start() calls _replay_from_wal() which reads /tmp/nullrun.wal (the default NULLRUN_WAL_PATH fallback). If a previous test run in a different Python version (3.10 or 3.12) had persisted a non-empty WAL, the 3.11 worker would replay those events to a real HTTP endpoint, get HTTP 401, and the fixture would fail at setup with NullRunAuthError: nullrun.breaker.exceptions.NullRunAuthError: Invalid API key This bit CI on 2026-07-11 (run 29156199607): tests 3.10 + 3.12 passed, test 3.11 failed with that error in the fixture setup of test_enforce_sensitive_tool_dict_with_fallback_fail_open. The 3.10/3.12 runs cleared the global /tmp/nullrun.wal by reading it first, so 3.11 picked up the next writer. Order- dependent; flaky on the matrix. Pin NULLRUN_WAL_PATH to a tmp_path-scoped file so each test session reads its own fresh empty WAL. Resolves the flake without touching SDK source (no production code change). Verified locally: - pytest tests/test_protect_branches.py -> 43/43 pass - with pre-seeded stale /tmp/nullrun.wal, the previously failing test now passes. No public API change. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged. Recommended: 0.13.6 (no version bump needed for a test-only fix). * fix(tests): WAL-pinning for all inline NullRunRuntime creations Follows up on commit 41a16f7 which pinned NULLRUN_WAL_PATH for the test_runtime fixture only. Other tests in test_protect_branches.py / test_runtime_branches.py / test_toolbox_langgraph.py build NullRunRuntime inline (no fixture) and were still picking up a stale WAL from a previous test run, causing HTTP 401 `NullRunAuthError` in 3.12 (CI run 29158094827, job `test (3.12)`). This commit: 1. Adds a shared `make_test_runtime` factory fixture to conftest.py that pins NULLRUN_WAL_PATH to tmp_path, stubs _do_flush / _do_flush_locked / _client, and resets the singleton around the factory. 2. Replaces 4 inline `NullRunRuntime(api_key=..., _test_mode=True)` calls in test_protect_branches.py with `make_test_runtime()`, including: - test_protect_async_kill_re_raises_WorkflowKilledInterrupt - test_get_protected_runtime_falls_back_to_get_runtime 3. Patches the local _make_test_runtime / _make_runtime_with_mocked_auth helpers in test_runtime_branches.py to set NULLRUN_WAL_PATH per-call (via tempfile.mkdtemp) before constructing the runtime. 4. Extends the autouse _test_runtime fixture in test_toolbox_langgraph.py to take tmp_path and pin NULLRUN_WAL_PATH, matching conftest::make_test_runtime. Verified locally on 3.11: - pytest tests/ -n auto: 1219 passed, 1 failed, 7 skipped (1 failure: test_actions.py::TestPauseAction ::test_is_paused_respects_cooldown — pre-existing flake on master, NOT introduced by this commit; verified by git stash + repro on bare master) - ruff check src/: all checks passed - mypy src/: success, no issues in 34 source files Public API unchanged. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged. Recommended: 0.13.6 (no version bump needed). * fix(sdk): wire parent_trace_id end-to-end on /track v3 + legacy batch Pre-fix (commit efff530 / release/0.13.6): - langgraph.py::on_llm_end set event["parent_trace_id"] on the llm_call cost event when an LLM call sat inside a chain / agent (parent span from on_chain_start). - BUT: runtime._enrich_event never stamped parent_trace_id from the active span contextvar, so non-langgraph integrations (crewai, autogen, llama_index, plain httpx transport) emitted the field as None. - AND: _build_v3_track_payload (runtime.py:2982) did not map parent_trace_id onto the v3 /track wire payload, so even when the langgraph callback set it, the field dropped at the SDK wire boundary. Result on production (VPS Postgres after deploy 2026-07-11): SELECT count(*), count(parent_trace_id) FROM cost_events WHERE created_at > '2026-07-11 17:54:00'; -- 28 | 0 Zero rows carried the parent trace — the backend unified SELECT third JOIN arm (cs.join_kind = parent_trace_id) never matched, and the workflow detail Recent executions panel showed empty Model / Tokens / Cost on every orchestration row that owned an LLM call. Fix: 1. runtime._enrich_event: stamp parent_trace_id from get_trace_id() contextvar when the caller did NOT set it explicitly. The langgraph callback explicit value wins (no second-guessing), preserving the existing contract. 2. runtime._build_v3_track_payload: map parent_trace_id from wire_event onto the v3 /track body, mirroring the existing trace_id / span_id handling. 3. nullrun.context: add set_trace_id / reset_trace_id / clear_trace_id helpers. Tests that pin the trace contextvar (mimicking @Protect blocks) need a way to set + restore. Matches the existing pattern of set_/get_/clear_server_minted_execution_id. Tests (7 new in test_drift_fixes_2026_07_04.py, all passing): - test_build_v3_track_payload_includes_parent_trace_id mapper surfaces the field on the wire. - test_build_v3_track_payload_omits_parent_trace_id_when_absent backward-compat: legacy single-shot path stays clean. - test_enrich_event_stamps_parent_trace_id_from_contextvar non-langgraph integrations get the field. - test_enrich_event_preserves_caller_set_parent_trace_id langgraph callback explicit value is never overwritten. - test_enrich_event_leaves_parent_trace_id_blank_when_no_contextvar legacy callers do not get a stale value bleed. - test_enrich_event_omits_empty_string_parent_trace_id falsy boundary value treated as None. - test_enrich_event_parent_trace_id_matches_existing_trace_id_field SpanContext invariant (child inherits parent trace_id) protects the backend JOIN. Verification: - pytest tests/test_drift_fixes_2026_07_04.py — 22/22 passed. - pytest tests/ -n auto -q — 1142 passed, 1 pre-existing flake (test_is_paused_respects_cooldown, NOT introduced by this commit). - ruff check src/ — All checks passed. - mypy src/ — Success: no issues found in 34 source files. No public API change. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged. Recommended: 0.13.6 (no version bump needed for this wire-fix; the 0.13.6 release ships it). * chore(release): 0.13.7 — parent_trace_id wire end-to-end Bump version 0.13.6 -> 0.13.7 and prepend changelog entry covering the parent_trace_id wire-end-to-end fix (commit e011ba3 on this branch). This release ships the runtime changes that were missing in 0.13.6: - runtime._enrich_event now stamps parent_trace_id from the active span contextvar (so non-langgraph integrations participate in the multi-agent span attachment flow). - runtime._build_v3_track_payload now maps parent_trace_id onto the v3 /track body (so the field reaches the wire even when the langgraph callback set it on the event). After 0.13.6 the SDK was emitting parent_trace_id = null on every cost event, so cost_events.parent_trace_id was 0 / 28 on a production install with active traffic. 0.13.7 fixes the wire side; the backend side (migration 217 + unified SELECT third JOIN arm) was already shipped in the 0.13.6 / master pair. No public API change. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged. Backend must have cost_events.parent_trace_id column (migration 217) — already deployed on prod. Recommended: 0.13.6 -> 0.13.7 (patch).
1 parent 459ab16 commit 6ad9987

5 files changed

Lines changed: 328 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ name = "nullrun"
2828
# the full ``flush_interval`` (5s default). Plus CI hygiene:
2929
# pip cache, ``fail-fast`` matrix, ``pytest-xdist -n auto``. No
3030
# on-wire change; backends on 1.0.0 keep working unchanged.
31-
version = "0.13.6"
32-
# Long form used by PyPI page meta-description and search snippets.
31+
version = "0.13.7"
3332
# Kept under the 200-char preview threshold so the full line is visible
3433
# without an "expand" click. Keywords are matched against likely search
3534
# queries ("AI agent cost control", "LLM circuit breaker", etc.).

src/nullrun/__version__.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,79 @@
11
"""NullRun Platform SDK.
22
3+
v3.22 / 0.13.7 (2026-07-12) — wire ``parent_trace_id`` end-to-end on
4+
``/track`` (v3 + legacy batch).
5+
6+
Pre-fix (0.13.6): ``langgraph.py::on_llm_end`` set
7+
``event["parent_trace_id"]`` on the llm_call cost event when an
8+
LLM call sat inside a chain / agent, but two leaks dropped the
9+
field on the wire:
10+
11+
1. ``runtime._enrich_event`` never stamped ``parent_trace_id``
12+
from the active span contextvar, so non-langgraph integrations
13+
(crewai, autogen, llama_index, plain httpx transport) emitted
14+
the field as ``None``.
15+
2. ``runtime._build_v3_track_payload`` did NOT map
16+
``parent_trace_id`` onto the v3 ``/track`` payload, so even
17+
when the langgraph callback set it, the field dropped at the
18+
SDK wire boundary.
19+
20+
Result on production (VPS Postgres after deploy 2026-07-11):
21+
22+
SELECT count(*), count(parent_trace_id)
23+
FROM cost_events WHERE created_at > '2026-07-11 17:54:00';
24+
-- 28 | 0
25+
26+
Zero rows carried the parent trace — the backend's unified
27+
SELECT third JOIN arm (``cs.join_kind = 'parent_trace_id'``) never
28+
matched, and the workflow detail "Recent executions" panel showed
29+
empty Model / Tokens / Cost on every orchestration row that owned
30+
an LLM call.
31+
32+
Fix (no public API change, no wire-format change — the field
33+
was always wire-additive; just stop dropping it on the SDK side):
34+
35+
1. ``runtime._enrich_event``: stamp ``parent_trace_id`` from
36+
``get_trace_id()`` contextvar when the caller did NOT set it
37+
explicitly. The langgraph callback's explicit value wins (no
38+
second-guessing), preserving the existing contract.
39+
40+
2. ``runtime._build_v3_track_payload``: map ``parent_trace_id``
41+
from ``wire_event`` onto the v3 ``/track`` body, mirroring
42+
the existing ``trace_id`` / ``span_id`` handling.
43+
44+
3. ``nullrun.context``: add ``set_trace_id`` /
45+
``reset_trace_id`` / ``clear_trace_id`` helpers. Tests that
46+
pin the trace contextvar (mimicking ``@protect`` blocks)
47+
need a way to set + restore. Matches the existing pattern
48+
of ``set_/get_/clear_server_minted_execution_id``.
49+
50+
Tests (7 new in ``test_drift_fixes_2026_07_04.py``, all passing):
51+
52+
- ``test_build_v3_track_payload_includes_parent_trace_id``
53+
- ``test_build_v3_track_payload_omits_parent_trace_id_when_absent``
54+
- ``test_enrich_event_stamps_parent_trace_id_from_contextvar``
55+
- ``test_enrich_event_preserves_caller_set_parent_trace_id``
56+
- ``test_enrich_event_leaves_parent_trace_id_blank_when_no_contextvar``
57+
- ``test_enrich_event_omits_empty_string_parent_trace_id``
58+
- ``test_enrich_event_parent_trace_id_matches_existing_trace_id_field``
59+
60+
Verification locally:
61+
62+
- ``pytest tests/test_drift_fixes_2026_07_04.py`` — 22/22 passed.
63+
- ``pytest tests/ -n auto -q`` — 1142 passed, 1 pre-existing flake
64+
(``test_is_paused_respects_cooldown``, NOT introduced by this
65+
release).
66+
- ``ruff check src/`` — All checks passed.
67+
- ``mypy src/`` — Success: no issues found in 34 source files.
68+
69+
No public API change. No ``SDK_MIN_VERSION`` bump. Backends on
70+
1.0.0 keep working unchanged. Recommended: 0.13.6 → 0.13.7
71+
(patch). Required: backend must have ``cost_events.parent_trace_id``
72+
column from migration 217 (already deployed on prod as of
73+
2026-07-11 12:52 UTC).
74+
75+
---
76+
377
v3.21 / 0.13.6 (2026-07-11) — multi-agent span attachment (parent_trace_id).
478
579
Pre-fix the langgraph callback's on_llm_start/on_llm_end handlers
@@ -417,5 +491,5 @@
417491
418492
"""
419493

420-
__version__ = "0.13.6"
494+
__version__ = "0.13.7"
421495
__platform_version__ = "1.0.0"

src/nullrun/context.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ def get_trace_id() -> str | None:
7474
return _trace_id_var.get()
7575

7676

77+
def set_trace_id(trace_id: str | None) -> object:
78+
"""Pin the current trace_id on the context.
79+
80+
Used by ``@protect`` blocks and by the langgraph callback
81+
during ``on_chain_start`` to give downstream cost events a
82+
stable parent-trace reference. Returns a token that the caller
83+
passes to :func:`reset_trace_id` to restore the previous value
84+
— this is the ``ContextVar`` contract, see
85+
https://docs.python.org/3/library/contextvars.html#contextvars.ContextVar.set.
86+
87+
Passing ``None`` clears the field. Tests should pair this with
88+
a try/finally ``reset_trace_id`` to avoid bleeding state into
89+
the next test (we observed this as the root cause of the
90+
2026-07-11 cross-test WAL-replay flake).
91+
"""
92+
return _trace_id_var.set(trace_id)
93+
94+
95+
def reset_trace_id(token: object) -> None:
96+
"""Restore the previous trace_id state from a ``set_trace_id``
97+
token. See :func:`set_trace_id`."""
98+
_trace_id_var.reset(token) # type: ignore[arg-type]
99+
100+
101+
def clear_trace_id() -> None:
102+
"""Clear the trace_id contextvar to its default (None).
103+
104+
Convenience for tests + teardown paths that do not need to
105+
capture the previous value. Equivalent to
106+
``set_trace_id(None)`` but with no return token to manage.
107+
"""
108+
_trace_id_var.set(None)
109+
110+
77111
def get_span_id() -> str | None:
78112
"""Get current span ID from context."""
79113
return _span_id_var.get()

src/nullrun/runtime.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2507,6 +2507,33 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]:
25072507
if idem_key:
25082508
enriched["idempotency_key"] = idem_key
25092509

2510+
# 2026-07-12 (multi-agent span attachment — SDK counterpart at
2511+
# nullrun-sdk-python release/0.13.5 commit efff530):
2512+
# ``langgraph.py::on_llm_end`` may have already stamped
2513+
# ``parent_trace_id`` when an LLM call sits inside a
2514+
# chain / agent (we set it from the child SpanContext there).
2515+
# When the caller passed a span via ``runtime.track_event``
2516+
# (without going through the langgraph callback) the field is
2517+
# absent — fall back to the contextvar so non-langgraph
2518+
# integrations (crewai, autogen, llama_index, plain httpx
2519+
# transport) get the same wire shape. The backend's
2520+
# ``cost_events.parent_trace_id`` column + unified SELECT
2521+
# third JOIN arm (``cs.join_kind = 'parent_trace_id'``) both
2522+
# depend on this being present whenever a parent span exists
2523+
# in scope; without it the dashboard falls back to the
2524+
# weaker ``trace_id`` arm and LLM rows show empty
2525+
# Model / Tokens / Cost on the orchestration row that owns
2526+
# the call.
2527+
if "parent_trace_id" not in enriched:
2528+
# Re-import to mirror the local-import pattern used
2529+
# for get_server_minted_execution_id above — keeps
2530+
# the runtime module's eager import graph stable.
2531+
from nullrun.context import get_trace_id as _get_trace_id
2532+
2533+
parent_trace_id = _get_trace_id()
2534+
if parent_trace_id:
2535+
enriched["parent_trace_id"] = parent_trace_id
2536+
25102537
# Add type if not present
25112538
if "type" not in enriched:
25122539
enriched["type"] = "event"
@@ -3034,6 +3061,18 @@ def _build_v3_track_payload(
30343061
payload["trace_id"] = wire_event["trace_id"]
30353062
if "span_id" in wire_event and wire_event["span_id"]:
30363063
payload["span_id"] = wire_event["span_id"]
3064+
# 2026-07-12 (multi-agent span attachment): the orchestration
3065+
# trace that owns this LLM call. Stamped by ``_enrich_event``
3066+
# from the active span contextvar (or earlier by
3067+
# ``langgraph.py::on_llm_end`` when the call sits inside a chain
3068+
# / agent). Backend persists it on ``cost_events.parent_trace_id``
3069+
# and the unified SELECT joins ``traces.trace_id`` directly via
3070+
# this column so the workflow detail "Recent executions" panel
3071+
# surfaces Model / Tokens / Cost on the orchestration row that
3072+
# owns the LLM call. Without this, the dashboard's 4/5-row
3073+
# empty-cells problem returns for every multi-agent workflow.
3074+
if "parent_trace_id" in wire_event and wire_event["parent_trace_id"]:
3075+
payload["parent_trace_id"] = wire_event["parent_trace_id"]
30373076

30383077
# Optional downstream fields preserved verbatim (workflow-level
30393078
# cost attribution, agent_id, etc.). Backend ignores unknown

tests/test_drift_fixes_2026_07_04.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,185 @@ def test_build_v3_track_payload_omits_idempotency_key_when_absent(
192192
assert payload is not None
193193
assert "idempotency_key" not in payload
194194

195+
def test_build_v3_track_payload_includes_parent_trace_id(self):
196+
"""2026-07-12 (multi-agent span attachment): the v3 /track
197+
payload mapper must surface ``parent_trace_id`` on the wire
198+
when the enriched event carries it. Without this the backend's
199+
``cost_events.parent_trace_id`` column stays NULL and the
200+
unified SELECT's third JOIN arm (``cs.join_kind =
201+
'parent_trace_id'``) misses the row — the dashboard falls
202+
back to the weaker ``trace_id`` arm and the workflow detail
203+
"Recent executions" panel shows empty Model / Tokens / Cost
204+
on the orchestration row that owns the LLM call.
205+
"""
206+
from nullrun.runtime import _build_v3_track_payload
207+
208+
payload = _build_v3_track_payload(
209+
{
210+
"workflow_id": "wf-123",
211+
"tokens": 100,
212+
"trace_id": "11111111-2222-3333-4444-555555555555",
213+
"span_id": "22222222-3333-4444-5555-666666666666",
214+
"parent_trace_id": "33333333-4444-5555-6666-777777777777",
215+
},
216+
"01926e7a-3b3b-7ddd-9bdd-7f0d3b3b7b3b",
217+
)
218+
219+
assert payload is not None
220+
assert payload["parent_trace_id"] == "33333333-4444-5555-6666-777777777777"
221+
# Sanity: existing fields still surface.
222+
assert payload["trace_id"] == "11111111-2222-3333-4444-555555555555"
223+
assert payload["span_id"] == "22222222-3333-4444-5555-666666666666"
224+
225+
def test_build_v3_track_payload_omits_parent_trace_id_when_absent(self):
226+
"""Backward compat: when no parent chain / agent context is
227+
active (single-shot /track outside @protect), the field must
228+
be absent — not an empty string. Backend stores ``None`` /
229+
missing-field identically, so the omission is the right
230+
shape for the "no parent" case.
231+
"""
232+
from nullrun.runtime import _build_v3_track_payload
233+
234+
payload = _build_v3_track_payload(
235+
{
236+
"workflow_id": "wf-123",
237+
"tokens": 100,
238+
"trace_id": "11111111-2222-3333-4444-555555555555",
239+
},
240+
"01926e7a-3b3b-7ddd-9bdd-7f0d3b3b7b3b",
241+
)
242+
243+
assert payload is not None
244+
assert "parent_trace_id" not in payload
245+
assert payload["trace_id"] == "11111111-2222-3333-4444-555555555555"
246+
247+
def test_enrich_event_stamps_parent_trace_id_from_contextvar(self):
248+
"""When the caller did not pass ``parent_trace_id`` explicitly
249+
on the event dict (e.g. plain httpx transport that does NOT
250+
go through ``langgraph.py::on_llm_end``), ``_enrich_event``
251+
must stamp the field from the active span contextvar so the
252+
wire shape is consistent regardless of caller integration.
253+
"""
254+
from nullrun.context import set_trace_id, clear_trace_id
255+
from nullrun.runtime import NullRunRuntime
256+
257+
# Pin the trace contextvar to a known value (mimics
258+
# ``@protect`` block / chain mode).
259+
set_trace_id("44444444-5555-6666-7777-888888888888")
260+
try:
261+
rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True)
262+
enriched = rt._enrich_event(
263+
{"type": "llm_call", "model": "gpt-4", "tokens": 100}
264+
)
265+
assert enriched["parent_trace_id"] == (
266+
"44444444-5555-6666-7777-888888888888"
267+
)
268+
finally:
269+
clear_trace_id()
270+
271+
def test_enrich_event_preserves_caller_set_parent_trace_id(self):
272+
"""If the caller already set ``parent_trace_id`` (the
273+
langgraph callback path), ``_enrich_event`` MUST keep their
274+
value rather than overwrite with the contextvar. The
275+
callback may sit inside a deeper span than the contextvar
276+
exposes, so the explicit value wins.
277+
"""
278+
from nullrun.context import set_trace_id, clear_trace_id
279+
from nullrun.runtime import NullRunRuntime
280+
281+
# Contextvar holds the OUTER chain's trace; the langgraph
282+
# callback already stamped the CHILD span's trace (which
283+
# equals the chain trace by SpanContext invariant, but
284+
# ``_enrich_event`` is not supposed to second-guess this).
285+
set_trace_id("55555555-6666-7777-8888-999999999999")
286+
try:
287+
rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True)
288+
enriched = rt._enrich_event(
289+
{
290+
"type": "llm_call",
291+
"model": "gpt-4",
292+
"tokens": 100,
293+
"parent_trace_id": "explicit-from-callback",
294+
}
295+
)
296+
assert enriched["parent_trace_id"] == "explicit-from-callback"
297+
finally:
298+
clear_trace_id()
299+
300+
def test_enrich_event_leaves_parent_trace_id_blank_when_no_contextvar(
301+
self,
302+
):
303+
"""Backward compat: legacy / pre-0.13.6 callers run with no
304+
``@protect`` block and no chain contextvar set. In that case
305+
``parent_trace_id`` MUST stay absent — never pick up a stale
306+
value from a previous test, never default to ``trace_id``
307+
(the backend's JOIN keys off the explicit value, not the
308+
trace_id column).
309+
"""
310+
from nullrun.context import clear_trace_id, set_trace_id
311+
from nullrun.runtime import NullRunRuntime
312+
313+
clear_trace_id() # belt + braces
314+
try:
315+
set_trace_id(None)
316+
except Exception:
317+
pass
318+
try:
319+
clear_trace_id()
320+
except Exception:
321+
pass
322+
323+
rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True)
324+
enriched = rt._enrich_event(
325+
{"type": "llm_call", "model": "gpt-4", "tokens": 100}
326+
)
327+
assert "parent_trace_id" not in enriched
328+
329+
def test_enrich_event_omits_empty_string_parent_trace_id(self):
330+
"""Empty string ``""`` is a falsy ``parent_trace_id``. Treat
331+
it like None so the wire payload stays clean (backend
332+
parser would otherwise reject the field or store empty
333+
string in a UUID column, depending on path).
334+
"""
335+
from nullrun.context import set_trace_id, clear_trace_id
336+
from nullrun.runtime import NullRunRuntime
337+
338+
set_trace_id("") # boundary value
339+
try:
340+
rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True)
341+
enriched = rt._enrich_event(
342+
{"type": "llm_call", "model": "gpt-4", "tokens": 100}
343+
)
344+
# The contextvar was set to empty string; ``_enrich_event``
345+
# branches on truthy value, so the field is absent
346+
# (not propagated as empty string).
347+
assert "parent_trace_id" not in enriched
348+
finally:
349+
clear_trace_id()
350+
351+
def test_enrich_event_parent_trace_id_matches_existing_trace_id_field(
352+
self,
353+
):
354+
"""Invariant (see SpanContext): a child span inherits
355+
``trace_id`` from its parent and only differs in
356+
``span_id``. When the contextvar is set, ``parent_trace_id``
357+
and ``trace_id`` MUST point at the same value. This protects
358+
the backend's JOIN from drifting — see
359+
``db/mod.rs::get_execution_records_for_workflow``.
360+
"""
361+
from nullrun.context import set_trace_id, clear_trace_id
362+
from nullrun.runtime import NullRunRuntime
363+
364+
set_trace_id("77777777-8888-9999-aaaa-bbbbbbbbbbbb")
365+
try:
366+
rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True)
367+
enriched = rt._enrich_event(
368+
{"type": "llm_call", "model": "gpt-4", "tokens": 100}
369+
)
370+
assert enriched["trace_id"] == enriched["parent_trace_id"]
371+
finally:
372+
clear_trace_id()
373+
195374

196375
# ---------------------------------------------------------------------------
197376
# F2: HTTP status_code on every decision exception

0 commit comments

Comments
 (0)