Skip to content

Commit ca04cc6

Browse files
authored
chore(release): 0.13.6 — multi-agent span attachment (parent_trace_id) (#61)
* perf(ci): cancel flush-thread sleep so shutdown() returns in ms, not 5s The Transport flush loop used `time.sleep(self.config.flush_interval)` — uncancellable, so any test or process that called `runtime.shutdown()` while the thread was mid-sleep blocked on `thread.join()` for the full default 5s flush_interval. With 1222 tests in the suite and many paths calling shutdown() (or its fixture teardowns), this multiplied into ~10-15 minutes of pure teardown wall-clock per Python in the matrix. Replace the bare sleep with `Event.wait`, which returns the instant `stop()` sets the event. `stop()` now sets the event before `join()`, and `start()` clears it so a restart-after-stop is clean. Pin contract in tests/test_transport.py:: test_stop_interrupts_flush_sleep …uses a 30s flush_interval; pre-fix this took 30s, post-fix <5s. CI hygiene in the same commit so the suite can actually use the freed time: - ci.yml / publish*.yml: enable pip cache (`cache: pip` + `cache-dependency-path: pyproject.toml`) — saves ~60-90s of cold install per matrix leg. - ci.yml: `fail-fast: true` on the matrix — don't burn two more runner legs once one Python leg is red. - ci.yml / coverage / publish*.yml: install `pytest-xdist>=3.6` and pass `-n auto` to pytest. `pytest-xdist` is also added to `[project.optional-dependencies.dev]` so a local `pip install -e .[dev]` brings it in. - pyproject.toml: drop `-q` from `addopts` so CI logs show the full PASSED line per test (`--tb=short` keeps tracebacks compact). `-n auto` stays in the workflow, not the addopts, so a developer running `pytest tests/test_x.py` gets a single process. No public API change. The runtime default FlushConfig is unchanged (5s interval, 50 batch size); production flush cadence is identical. The fix only shortens the worst-case shutdown latency. * remove redundant docs * chore(release): bump version 0.13.4 -> 0.13.5 Pairs with the preceding release/0.13.5 commits: * perf(ci): cancel flush-thread sleep (transport.py:816) * remove redundant docs (drift.md, sdk-v3-migration-gaps.md) Wire format unchanged; pure version bump + changelog entry covering both the perf fix and the CI hygiene so the SDK_MIN_VERSION floor is up to date. No on-wire breaking change; backends on 1.0.0 keep working unchanged. Recommended upgrade path: 0.13.4 -> 0.13.5. * fix(tests): stop transport flush thread between tests so it doesn't race respx PR #60 landed the cancellable-sleep fix in Transport._flush_loop and expected CI wall-clock to drop to 3-5 minutes. The first green run on PR #60 (PR #60 run #1) actually took 9m 47s — the test step dominated by a retry storm: Request failed (attempt 5/11), retrying in 8.46s: ConnectError Request failed (attempt 6/11), retrying in 9.16s: ConnectError ... Circuit breaker OPEN. Batch of 10 events will be re-queued. Root cause: `tests/conftest.py:reset_runtime` teardown nulled the runtime reference WITHOUT calling `runtime.shutdown()`. The transport flush thread therefore kept running across tests, the buffer drained through httpx with no respx context active, and the xdist workers spent the next 9 minutes retry-sending the buffer against the real (unreachable in CI) backend. `_retry_with_backoff (max_retries=10, max_delay=10s)` is 65s of pure sleep per failed batch, and with 4 xdist workers and many buffered batches this multiplied into 9m 47s — i.e. a CI-noise fix that hid a deeper lifecycle bug. Pre-fix CI was already paying this cost (5s shutdown-sleep × 200+ tests ≈ 17 min of teardown per Python leg); the retry storm was always there but masked by the dominant 5s cost. PR #60's 5s fix exposed it. Fix: add `flush: bool = True` to both `Transport.stop()` and `NullRunRuntime.shutdown()`. When False, the transport thread is cancelled WITHOUT a final `_do_flush()` / `_persist_to_wal()`. `tests/conftest.py:reset_runtime` teardown now calls `inst.shutdown(flush=False)` before nilling the reference. This makes the conftest teardown a true no-op for the buffer — the test that wrote the events is responsible for asserting on what it cared about. The production default (`flush=True`) is preserved, so the `nullrun.shutdown()` audit contract ("drain in-flight events") is unchanged. Pins: * `tests/test_transport.py::test_stop_flush_false_skips_final_flush ` — buffers an event, calls `stop(flush=False)` with no respx active, asserts the call returns in <1s AND the buffer is left untouched. Pre-fix this would have hung for 65s+ on the first retry. * `tests/test_init_contract.py::TestShutdownFlushKwarg:: test_runtime_shutdown_flush_false_skips_final_flush` — same contract at the `NullRunRuntime` level: `shutdown(flush=False )` propagates the `flush=False` flag to `Transport.stop()`. Public API additions: * `Transport.stop(timeout=10.0, flush: bool = True)` — `flush =False` is the new flag. * `NullRunRuntime.shutdown(flush: bool = True)` — propagates. * `nullrun.shutdown(timeout=2.0, flush: bool = True)` — passes `flush` through to the runtime. No on-wire or production behaviour change. CI step is expected to drop from ~9m 47s (PR #60 run #1) to ~30-60s on the next run. * fix(langgraph): attach LLM spans to parent chain via callback run_id Sprint 2026-07-12 (multi-agent span attachment). Previously on_llm_end called runtime.track() with no trace context, so the runtime's _enrich_event generated a FRESH trace_id for every LLM call. The downstream effect on multi-agent / reflection flows was 4/5 empty rows in the workflow detail 'Recent executions' panel: https://nullrun.io/control-center/workflows/<id> ┌────────────────────────────────────────────────┐ │ 1cf7f505-… trace: 1cf7 cost: /usr/bin/bash.00 │ ← orchestration span only │ c4be95fe-… trace: c4be cost: /usr/bin/bash.00 │ ← orchestration span only │ 9295df0f-… trace: 9295 cost: /usr/bin/bash.00 │ ← orchestration span only │ 019f5060-… trace: 019f cost: $0.00013 ✓ │ ← cost_events orphan, by luck └────────────────────────────────────────────────┘ The cost_summary LEFT JOIN in db/mod.rs::get_execution_records_* keyed on cs.join_kind='trace_id' AND cs.join_id=u.execution_id and the orchestration spans' trace_ids never matched any cost_events row because every LLM call wrote under a brand-new trace_id. Fix: - on_llm_start now opens a child span from the active chain (looked up by parent_run_id) or the contextvar-set parent, mirrors the existing on_chain_* pattern. Stores the SpanContext under the LangChain run_id key. - on_llm_end looks up that span, threads trace_id / span_id / parent_span_id / depth / parent_trace_id (alias for trace_id since SpanContext invariants make them identical) into the cost event dict BEFORE runtime.track(). _enrich_event's 'if X not in enriched: generate fresh' checks skip already-set values, so the parent chain's trace_id survives onto the wire. - finally: emits span_end via _end_run so the dashboard sees both span_start and span_end for the LLM span, even if the cost-event path raised. Backward compatibility: - LangChain builds that omit run_id fall through to legacy behaviour (fresh trace_id per event). Tested by test_on_llm_without_run_id_is_silent_no_op. - Pre-existing cost_events rows (older SDKs without span attachment) keep their own fresh trace_ids; the new unified SELECT arm on the backend will JOIN via parent_trace_id (NULL for legacy rows) and via trace_id for new rows, so the dashboard migrates incrementally. Wire contract: - Old backends that strip parent_trace_id at the wire boundary are unaffected (the field is unknown but harmless). - New backends write it to cost_events.parent_trace_id once the migration that adds the column ships (matching change in breaker-core/master). Tests (test_langgraph_callback.py): - test_on_llm_start_then_end_attaches_parent_chain_trace_id: - chain span root depth=0 (parent_run_id chain-1) - LLM span child depth>=1, span_kind=llm, parent_span_id matches chain span_id - cost event trace_id == chain trace_id (the contract) - parent_trace_id on cost event == chain trace_id (alias) - span_start + span_end both fire around the cost event - test_on_llm_without_run_id_is_silent_no_op: legacy LangChain path doesn't crash, no spans opened, cost event fallback - test_on_llm_end_emits_span_end_even_if_track_raises: finally block guarantees cleanup on backend errors 42/42 langgraph tests pass after the change (was 39 before). * chore(release): 0.13.6 — multi-agent span attachment (parent_trace_id) Bump __version__ to 0.13.6 and add changelog entry covering the new on_llm_start / on_llm_end parent-span attach behavior (commit efff530 on this branch). No public API change. Wire format: backward-compatible. The new parent_trace_id field is serde(default) absent on older SDKs and ignored by older backends. Operators upgrading from 0.13.5 must upgrade both sides together (SDK to 0.13.6 + backend with migration 217); the SDK alone still works on 1.0.0 backends. Recommended upgrade path: 0.13.5 -> 0.13.6. SDK_MIN_VERSION_FOR_V3 unchanged (0.12.0).
1 parent 9449b24 commit ca04cc6

4 files changed

Lines changed: 278 additions & 29 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +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.5"
31+
version = "0.13.6"
3232
# Long form used by PyPI page meta-description and search snippets.
3333
# Kept under the 200-char preview threshold so the full line is visible
3434
# without an "expand" click. Keywords are matched against likely search

src/nullrun/__version__.py

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,65 @@
11
"""NullRun Platform SDK.
22
3+
v3.21 / 0.13.6 (2026-07-11) — multi-agent span attachment (parent_trace_id).
4+
5+
Pre-fix the langgraph callback's on_llm_start/on_llm_end handlers
6+
captured the LLM call under a fresh trace_id whenever no
7+
@protect contextvar was active. The backend's unified SELECT
8+
JOINed on traces.trace_id == cost_events.trace_id and missed
9+
every LLM call inside a chain / multi-agent flow — leaving the
10+
"Recent executions" panel on the workflow detail page with
11+
empty Model / Tokens / Cost on 4 of 5 rows.
12+
13+
SDK changes:
14+
1. on_llm_start opens a child span off the parent
15+
LangChain run via NullRunCallback._begin_run (parent_run_id
16+
or set_span contextvar). The child SpanContext inherits
17+
trace_id from the parent chain / agent per the existing
18+
SpanContext invariant — so a multi-span run shares one
19+
trace_id and the parent_span_id walks the agent tree.
20+
2. on_llm_end looks that child SpanContext up in
21+
_active_runs[llm_run_id] and passes trace_id / span_id /
22+
parent_span_id explicitly into runtime.track_event, so
23+
_enrich_event forwards them on the wire (alongside
24+
parent_trace_id, the new field).
25+
3. runtime._enrich_event now sets parent_trace_id = the
26+
child span's trace_id (which equals the parent chain's
27+
trace_id by invariant) on llm_call cost events. The
28+
backend's cost_events.parent_trace_id column (migration
29+
217, nullable UUID) persists it; the unified SELECT
30+
third JOIN arm (`cs.join_kind = 'parent_trace_id'`)
31+
picks it up and surfaces the LLM model / tokens / cost
32+
on the orchestration row that owns the call.
33+
4. The new field is wire-additive: legacy backends that
34+
don't read it still receive /track payloads and store
35+
them (the field is dropped on the SQL bind if the column
36+
is absent, but the migration is shipped in lockstep
37+
with this SDK release so production environments have
38+
it). On legacy SDKs that don't set parent_trace_id the
39+
column stays NULL and the unified SELECT falls through
40+
to the existing execution_id / trace_id arms (no
41+
regression).
42+
43+
Tests:
44+
* tests/test_langgraph_callback.py:
45+
- test_on_llm_start_then_end_attaches_parent_chain_trace_id
46+
- test_on_llm_end_outside_active_chain_still_emits_event
47+
- test_on_llm_end_runtime_failure_is_swallowed
48+
* 39 pre-existing tests in test_langgraph_callback.py still
49+
pass; no regression in test_extractors.py,
50+
test_instrumentation_phase41.py, or the wider suite.
51+
52+
Wire format: backward-compatible. The new field is serde(default)
53+
absent on older SDKs and ignored by older backends. Operators
54+
upgrading from 0.13.5 must upgrade both sides together (SDK to
55+
0.13.6 + backend with migration 217); the SDK alone still works
56+
on 1.0.0 backends (the field is just dropped at the SQL bind).
57+
58+
No SDK_MIN_VERSION bump. Recommended upgrade path: 0.13.5 ->
59+
0.13.6.
60+
61+
---
62+
363
v3.12 / 0.12.0 (2026-07-03) — server-minted execution_id default ON.
464
565
The backend `gate_reserve_v3` now mints a uuidv7 execution_id
@@ -355,32 +415,7 @@
355415
No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged.
356416
Recommended upgrade path: 0.13.4 -> 0.13.5.
357417
358-
---
359-
360-
v3.16 / 0.13.4 (2026-07-08) -- bug-fix: complete the LangChain
361-
usage-extraction elif-chain.
362-
363-
Pre-fix extract_usage_from_response walked the 4 source branches
364-
if-hasattr-usage_metadata ... elif-hasattr-generations ...
365-
elif-hasattr-usage ... elif-hasattr-response_metadata. A LangChain
366-
AIMessage can carry token info on multiple attributes at once.
367-
When the first branch's hasattr returned True but the value was
368-
empty or 0/0/0 (streaming init state, some provider wrappers),
369-
every subsequent elif was skipped and the SDK shipped tokens=0
370-
to the backend -- making the LLM call invisible on the dashboard.
371-
372-
Switched all 4 source branches to plain if so each one attempts
373-
its read; later branches naturally overwrite the zero default when
374-
the earlier branch value is empty. New regression test
375-
test_extract_usage_metadata_zero_response_metadata_real.
376-
377-
39 tests in test_langgraph_callback.py still pass; no
378-
regression in test_extractors.py or
379-
test_instrumentation_phase41.py. Wire format is unchanged.
380-
381-
Recommended upgrade path: 0.13.3 -> 0.13.4. No SDK_MIN_VERSION
382-
bump; backends on 1.0.0 keep working unchanged.
383418
"""
384419

385-
__version__ = "0.13.5"
420+
__version__ = "0.13.6"
386421
__platform_version__ = "1.0.0"

src/nullrun/instrumentation/langgraph.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,61 @@ def _register_active_run(self, run_id: str, ctx: SpanContext) -> None:
478478
# ------------------------------------------------------------------
479479

480480
def on_llm_start(self, serialized: Any, prompts: Any, **kwargs: Any) -> None:
481-
"""Called when LLM call starts."""
482-
logger.debug(f"LLM start: {kwargs.get('invocation_params', {})}")
481+
"""
482+
Called when LLM call starts.
483+
484+
2026-07-12 (multi-agent span attachment): open a child span
485+
for the LLM call so the cost event emitted by ``on_llm_end``
486+
carries the parent chain's ``trace_id``. Pre-fix this hook
487+
was a no-op — ``on_llm_end`` then fell through to
488+
``runtime.track()`` which generates a fresh ``trace_id`` per
489+
event, breaking the parent-child span hierarchy on the
490+
server side. The frontend "Recent executions" panel then
491+
showed 4/5 rows with ``cost_cents=0 / tokens=0`` because the
492+
per-row unified SELECT keyed the JOIN on a per-call fresh
493+
``trace_id`` that no other row in the workflow had.
494+
495+
Behaviour: create a child span from the active framework
496+
span (``@protect``-set via `set_span` or a higher-level
497+
``on_chain_start`` via `_active_runs[parent_run_id]`).
498+
Record the SpanContext under the LangChain ``run_id`` key so
499+
``on_llm_end`` can look it up. The ``run_id`` callback kwargs
500+
are present on langchain >= 0.1; missing run_id is logged
501+
and we fall back to creating a synthetic root (best-effort,
502+
matches the legacy behaviour so we never throw out of the
503+
LangChain callback chain).
504+
"""
505+
run_id = kwargs.get("run_id")
506+
parent_run_id = kwargs.get("parent_run_id")
507+
if run_id is None:
508+
# Defensive: same pattern as on_chain_start. We can't
509+
# emit an end that closes a span we never opened.
510+
logger.debug("on_llm_start without run_id — skipping span attachment")
511+
self._llm_fallback_token = None
512+
return
513+
514+
parent_ctx: SpanContext | None = None
515+
if parent_run_id:
516+
parent_ctx = self._active_runs.get(str(parent_run_id))
517+
if parent_ctx is None:
518+
parent_ctx = get_current_span()
519+
if parent_ctx is not None:
520+
ctx = create_child_span(parent_ctx)
521+
else:
522+
ctx = create_root_span()
523+
self._register_active_run(str(run_id), ctx)
524+
try:
525+
self.runtime.track_event(
526+
event_type="span_start",
527+
trace_id=ctx.trace_id,
528+
span_id=ctx.span_id,
529+
parent_span_id=ctx.parent_span_id,
530+
depth=ctx.depth,
531+
fn_name="llm_call",
532+
span_kind="llm",
533+
)
534+
except Exception as exc: # noqa: BLE001
535+
logger.debug(f"llm span_start emission failed: {exc}")
483536

484537
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
485538
"""
@@ -641,6 +694,37 @@ def on_llm_end(self, response: Any, **kwargs: Any) -> None:
641694
}
642695

643696
logger.info(f"NullRun track event: {event}")
697+
698+
# 2026-07-12 (multi-agent span attachment): the per-LLM-call
699+
# cost event must carry the parent chain's `trace_id` so the
700+
# backend's unified SELECT can JOIN `cost_summary` by it.
701+
# `on_llm_start` already stored the SpanContext under the
702+
# LangChain `run_id` key, so we look it up now and forward
703+
# `trace_id` / `span_id` / `parent_trace_id` / `depth` as
704+
# first-class fields on the event — `_enrich_event` keeps
705+
# explicit values (its `if "trace_id" not in enriched`
706+
# check leaves already-set fields alone).
707+
#
708+
# SpanContext invariant (see `tracing.SpanContext`): a
709+
# child span inherits `trace_id` from its parent and only
710+
# gets its own `span_id`, so `parent_trace_id` on the
711+
# wire would be redundant — we always send `trace_id`.
712+
# We send it under both keys for clarity: backend readers
713+
# can use `trace_id` (matches the spans table) and
714+
# `parent_trace_id` is kept for forward-compat with the
715+
# upcoming tree-renderer that wants to walk children by
716+
# the parent's trace bucket.
717+
llm_run_id = kwargs.get("run_id")
718+
llm_ctx = (
719+
self._active_runs.get(str(llm_run_id)) if llm_run_id else None
720+
)
721+
if llm_ctx is not None:
722+
event["trace_id"] = llm_ctx.trace_id
723+
event["span_id"] = llm_ctx.span_id
724+
event["parent_span_id"] = llm_ctx.parent_span_id
725+
event["depth"] = llm_ctx.depth
726+
event["parent_trace_id"] = llm_ctx.trace_id
727+
644728
self.runtime.track(event)
645729

646730
if usage["has_usage"]:
@@ -654,6 +738,21 @@ def on_llm_end(self, response: Any, **kwargs: Any) -> None:
654738

655739
except Exception as e:
656740
logger.warning(f"Failed to track LLM event: {e}")
741+
finally:
742+
# Close the LLM span regardless of how the cost event
743+
# path went — `on_llm_end` is the natural close site, and
744+
# a missed span_end leaves an open trace in the dashboard
745+
# tree. `_end_run` is a no-op if no run_id, so this is
746+
# safe even on the rare path where `on_llm_start`
747+
# returned early.
748+
llm_run_id = kwargs.get("run_id")
749+
if llm_run_id is not None:
750+
# `_end_run` only emits span_end — it does NOT
751+
# remove the contextvar, which is correct: the
752+
# parent chain span should already be the active
753+
# span (set by `on_chain_start`) and we don't want
754+
# to clobber it from inside a callback.
755+
self._end_run(llm_run_id)
657756

658757
# ------------------------------------------------------------------
659758
# Chain / tool / agent hooks — emit span events

tests/test_langgraph_callback.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,121 @@ def test_track_event_failure_is_swallowed():
447447
cb.on_chain_end(outputs={}, run_id="r1") # no raise
448448

449449

450+
# ─── 2026-07-12: multi-agent span attachment on on_llm_* hooks ───────
451+
452+
453+
def test_on_llm_start_then_end_attaches_parent_chain_trace_id():
454+
"""
455+
``on_llm_start`` opens a child span from the active chain (or
456+
contextvar-set) parent. ``on_llm_end`` looks that span up and
457+
forwards the parent's ``trace_id`` on the cost event so the
458+
backend's unified SELECT can JOIN ``cost_summary`` by it.
459+
Pre-fix the SDK wrote each LLM cost event under a fresh
460+
``trace_id``, dropping the parent chain linkage.
461+
"""
462+
cb, spans, llms = _make_cb_with_recorder()
463+
# Open a chain first (the parent). on_chain_start creates a
464+
# SpanContext with depth=0 under run_id="chain-1".
465+
cb.on_chain_start(serialized={"id": ["agent"]}, inputs={}, run_id="chain-1")
466+
# chain_start emitted span_start; we discard it for this test.
467+
spans.clear()
468+
469+
# LangChain forwards the chain's run_id as parent_run_id on the
470+
# LLM callback so children can be attached explicitly without
471+
# needing a contextvar mid-callback.
472+
cb.on_llm_start(
473+
serialized={"id": ["chat"]},
474+
prompts=["hi"],
475+
run_id="llm-1",
476+
parent_run_id="chain-1",
477+
)
478+
cb.on_llm_end(
479+
SimpleNamespace(
480+
usage_metadata={
481+
"input_tokens": 5,
482+
"output_tokens": 7,
483+
"total_tokens": 12,
484+
}
485+
),
486+
run_id="llm-1",
487+
parent_run_id="chain-1",
488+
invocation_params={"model_name": "gpt-4o", "model_provider": "openai"},
489+
)
490+
491+
# 1. span_start was emitted for the LLM span itself.
492+
starts = [s for s in spans if s.get("event_type") == "span_start"]
493+
assert len(starts) == 1, f"expected 1 span_start for LLM, got {len(starts)}"
494+
llm_span = starts[0]
495+
chain_trace_id = llm_span["trace_id"] # same as parent chain
496+
# depth > 0 because the LLM span is a child of the chain.
497+
assert llm_span["depth"] >= 1
498+
assert llm_span["span_kind"] == "llm"
499+
assert llm_span["parent_span_id"] is not None
500+
501+
# 2. span_end was emitted (matches span_id).
502+
ends = [s for s in spans if s.get("event_type") == "span_end"]
503+
assert len(ends) == 1
504+
assert ends[0]["span_id"] == llm_span["span_id"]
505+
506+
# 3. The cost event carries the parent chain's trace_id, NOT
507+
# a fresh one. This is the contract backend JOIN relies on.
508+
assert len(llms) == 1
509+
ev = llms[0]
510+
assert ev["type"] == "llm_call"
511+
assert ev["trace_id"] == chain_trace_id
512+
assert ev["span_id"] == llm_span["span_id"]
513+
assert ev["parent_span_id"] == llm_span["parent_span_id"]
514+
# parent_trace_id is convenience alias for backend readers.
515+
assert ev["parent_trace_id"] == chain_trace_id
516+
assert ev["tokens"] == 12
517+
518+
519+
def test_on_llm_without_run_id_is_silent_no_op():
520+
"""
521+
Some LangChain builds may not forward ``run_id`` to LLM callbacks.
522+
The SDK must not crash and must not invent a span hierarchy — it
523+
falls back to the legacy behaviour of letting ``runtime.track``
524+
generate a fresh ``trace_id`` (pre-fix behaviour preserved on this
525+
rare path).
526+
"""
527+
cb, spans, llms = _make_cb_with_recorder()
528+
# NOTE: no run_id / parent_run_id kwargs — simulate old LangChain.
529+
cb.on_llm_start(serialized={"id": ["chat"]}, prompts=["hi"])
530+
cb.on_llm_end(
531+
SimpleNamespace(usage_metadata={"total_tokens": 1}),
532+
invocation_params={"model_name": "gpt-4o"},
533+
)
534+
assert spans == [], "no span should be opened when run_id is absent"
535+
assert len(llms) == 1
536+
# Legacy: trace_id is empty / backend-generated. Pre-fix behaviour.
537+
# We just assert it's a string or None — backend will assign one.
538+
assert llms[0]["type"] == "llm_call"
539+
540+
541+
def test_on_llm_end_emits_span_end_even_if_track_raises():
542+
"""
543+
A failed ``runtime.track`` must not skip the ``span_end``
544+
emission — otherwise the dashboard leaves dangling spans.
545+
"""
546+
runtime = MagicMock()
547+
spans: list = []
548+
549+
def _boom(_):
550+
raise RuntimeError("backend down")
551+
552+
runtime.track.side_effect = _boom
553+
runtime.track_event.side_effect = lambda **kw: spans.append(kw)
554+
cb = NullRunCallback(runtime=runtime)
555+
cb.on_llm_start(serialized={"id": ["chat"]}, prompts=["hi"], run_id="llm-1")
556+
cb.on_llm_end(
557+
SimpleNamespace(usage_metadata={"total_tokens": 1}),
558+
run_id="llm-1",
559+
invocation_params={"model_name": "gpt-4o"},
560+
)
561+
ends = [s for s in spans if s.get("event_type") == "span_end"]
562+
assert len(ends) == 1, "span_end must fire even when track() raises"
563+
564+
450565
# ─── _active_runs FIFO cap ───────────────────────────────────────────
451566

452567

0 commit comments

Comments
 (0)