Skip to content

fix(sdk): wire parent_trace_id end-to-end on /track v3 + legacy batch (0.13.7)#64

Merged
maltsev-dev merged 4 commits into
masterfrom
fix/parent-trace-id-wire-end-to-end
Jul 12, 2026
Merged

fix(sdk): wire parent_trace_id end-to-end on /track v3 + legacy batch (0.13.7)#64
maltsev-dev merged 4 commits into
masterfrom
fix/parent-trace-id-wire-end-to-end

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Closes the parent_trace_id wire bug that was silently broken in 0.13.6.

langgraph.py::on_llm_end (commit efff530) set event["parent_trace_id"] on the llm_call cost event when an LLM call sat inside a chain / agent, but two leaks dropped the field on the wire:

  1. 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.
  2. runtime._build_v3_track_payload (runtime.py:2982) did NOT map parent_trace_id onto the v3 /track 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's 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's 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.

Version bump

0.13.60.13.7 (patch). No public API change, no wire-format change — the parent_trace_id field was always wire-additive (#[serde(default)] on backend TrackRequestRaw); we just stop dropping it on the SDK side.

Tests (7 new in test_drift_fixes_2026_07_04.py)

  • 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's explicit value is never overwritten.
  • test_enrich_event_leaves_parent_trace_id_blank_when_no_contextvar — legacy callers don't 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's JOIN.

Verification

  • pytest tests/test_drift_fixes_2026_07_04.py22/22 passed (15 existing + 7 new).
  • pytest tests/ -n auto -q1142 passed, 1 pre-existing flake (test_is_paused_respects_cooldown, NOT introduced by this release).
  • ruff check src/ — All checks passed.
  • mypy src/ — Success: no issues found in 34 source files.

Backend dependency

Backend must have cost_events.parent_trace_id column from migration 217 (already deployed on prod as of 2026-07-11 12:52 UTC, commit df3d884 on master). The unified SELECT's third JOIN arm (cs.join_kind = 'parent_trace_id') is in place on the backend and starts matching as soon as SDK 0.13.7 ships the field on the wire.

Public API

Unchanged. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged.

Commits in this PR (2)

  • e011ba3 fix(sdk): wire parent_trace_id end-to-end on /track v3 + legacy batch
  • 14f56a8 chore(release): 0.13.7 — parent_trace_id wire end-to-end

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).
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).
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).
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).
@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/runtime.py 0.00% 7 Missing ⚠️
src/nullrun/context.py 0.00% 6 Missing ⚠️
src/nullrun/__version__.py 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@maltsev-dev maltsev-dev merged commit 6ad9987 into master Jul 12, 2026
4 of 5 checks passed
maltsev-dev added a commit that referenced this pull request Jul 12, 2026
…3.8 hotfix #2) (#66)

Hotfix #2 for the parent_trace_id wire-add end-to-end work from
PR #64 / 0.13.7. PR #64 wired the field on the wire, but a
diagnostic script (sdk_diag.py) running on the deployed 0.13.7
revealed cost_events on the backend were missing parent_trace_id:

  trace_id=cccccccc-... parent_trace_id=NULL model=gpt-4.1-mini tokens=10

The drift was in _enrich_event's parent_trace_id fallback
(commit efff530): it used an "if not in enriched" guard, so when
langgraph.py::on_llm_end's _active_runs[run_id] lookup missed
(run_id drift between the auto-injected chat_model callback and
an explicit user-supplied one, or no matching on_llm_start because
the user wrapped the LLM call in a non-langgraph stack), the
field was absent, the trace_id fallback at line 2422 overwrote
the event with the chain contextvar, but parent_trace_id stayed
NULL because the old condition was skipped.

Override semantics: the chain contextvar is the single source of
truth for "what chain does this event belong to". Both
langgraph.py::on_llm_end's caller-set value and a non-langgraph
caller's absence resolve to the same contextvar; preferring the
contextvar when present is idempotent for the happy path AND
closes the drift in the unhappy path.

Also:
- Bump version 0.13.7 -> 0.13.8.
- Prepend v3.23 / 0.13.8 changelog entry.
- Rewrite the existing
  test_enrich_event_preserves_caller_set_parent_trace_id to
  match the new override semantics.
- Add 2 new regression tests in TestEnrichEventParentTraceOverride:
  test_enrich_event_sets_parent_trace_id_when_chain_contextvar_set
  (drift scenario) and
  test_enrich_event_parent_trace_id_matches_trace_id_in_chain_mode
  (SpanContext invariant).

Regression coverage: 24 tests in test_drift_fixes_2026_07_04.py
(22 prior + 2 new). Full critical suite 157/157 passed. ruff clean,
mypy 34/34 source files clean.

End-to-end after this hotfix: the next real LLM call inside a
chain contextvar should arrive at the backend with both
cost_events.trace_id and cost_events.parent_trace_id set to the
chain contextvar value, and the unified SELECT third JOIN arm
should populate the dashboard's Recent executions panel with
Model / Tokens / Cost on the orchestration row.
@maltsev-dev maltsev-dev deleted the fix/parent-trace-id-wire-end-to-end branch July 12, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant