deer-flow/backend/tests/test_llm_call_telemetry.py
subhash7476 20bd989fc1
feat(runtime): add per-LLM-call telemetry and summarization no-op event (#6236)
* feat(runtime): add P0 per-LLM-call telemetry and summarization no-op counter

Measurement only; no runtime behavior changes.

- RunJournal enriches llm.ai.response / llm.error metadata with
  langchain_run_id, caller_category, provider, model, stop_reason, status,
  token counts and rendered request size (chars). Unavailable values are None.
- Summarization middleware emits a middleware:summarize event per summarizer
  call and counts outputs identical to the prior summary_text. The call is
  never skipped.
- All telemetry fails soft.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(runtime): register summarize middleware tag, document telemetry contract

- Add MIDDLEWARE_SUMMARIZE_TAG to the middleware event catalog and the
  contract's known_tags; emit the middleware:summarize event with it.
- Document the optional per-call telemetry metadata on llm.ai.response and
  llm.error in contracts/run_event_stream_contract.json.
- Measure request size by walking string leaves instead of json.dumps on the
  event-loop thread.
- Initialise summarization call/no-op counters in __init__.
- Move RunJournal telemetry docs to the runtime guide.
- Add tests: llm.error without a start, replayed on_llm_end, and a DB-store
  metadata round trip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(runtime): refresh token counters on usage replay

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-04 14:52:04 +08:00

292 lines
13 KiB
Python

"""P0 per-LLM-call telemetry: observation only, fail-soft."""
from __future__ import annotations
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.outputs import ChatGeneration, LLMResult
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
def _response(*, usage=None, response_metadata=None, additional_kwargs=None) -> LLMResult:
message = AIMessage(content="hello", usage_metadata=usage, response_metadata=response_metadata or {}, additional_kwargs=additional_kwargs or {})
return LLMResult(generations=[[ChatGeneration(message=message)]])
async def _events(journal: RunJournal, store: MemoryRunEventStore, event_type: str) -> list[dict]:
await journal.flush()
return [e for e in await store.list_events("t1", "r1") if e["event_type"] == event_type]
@pytest.mark.anyio
async def test_normal_call_records_telemetry_and_rendered_request_size():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid = uuid4()
messages = [[SystemMessage(content="sys"), HumanMessage(content="question")]]
journal.on_chat_model_start(
{"id": ["langchain", "ChatAnthropic"]},
messages,
run_id=rid,
tags=["lead_agent"],
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-x"},
invocation_params={"tools": [{"name": "t", "description": "d"}]},
)
journal.on_llm_end(
_response(usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, response_metadata={"stop_reason": "end_turn", "model_name": "claude-x-1"}),
run_id=rid,
tags=["lead_agent"],
)
(event,) = await _events(journal, store, "llm.ai.response")
meta = event["metadata"]
assert meta["langchain_run_id"] == str(rid)
assert meta["caller"] == "lead_agent"
assert meta["caller_category"] == "lead_agent"
assert meta["provider"] == "anthropic"
assert meta["model"] == "claude-x-1"
assert meta["stop_reason"] == "end_turn"
assert (meta["input_tokens"], meta["output_tokens"], meta["total_tokens"]) == (10, 5, 15)
assert meta["request_message_chars"] == len("sys") + len("question")
assert meta["request_message_count"] == 2
assert meta["request_tools_chars"] > 0
assert meta["request_chars"] == meta["request_message_chars"] + meta["request_tools_chars"]
assert event["thread_id"] == "t1" and event["run_id"] == "r1" and event["created_at"]
@pytest.mark.anyio
async def test_unavailable_values_are_none_not_fabricated():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid = uuid4()
journal.on_llm_end(_response(), run_id=rid, tags=None) # no on_chat_model_start
(event,) = await _events(journal, store, "llm.ai.response")
meta = event["metadata"]
assert meta["stop_reason"] is None
assert meta["input_tokens"] is None
assert meta["provider"] is None
assert "request_chars" not in meta
@pytest.mark.anyio
async def test_middleware_and_fallback_caller_classification():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
mid, fb = uuid4(), uuid4()
journal.on_llm_end(_response(), run_id=mid, tags=["middleware:summarize"])
journal.on_llm_end(_response(additional_kwargs={"deerflow_error_fallback": True}), run_id=fb, tags=["lead_agent"])
events = await _events(journal, store, "llm.ai.response")
by_run = {e["metadata"]["langchain_run_id"]: e["metadata"] for e in events}
assert by_run[str(mid)]["caller_category"] == "middleware"
assert by_run[str(fb)]["caller_category"] == "fallback"
assert by_run[str(fb)]["status"] == "fallback"
@pytest.mark.anyio
async def test_llm_error_is_recorded_with_status():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid = uuid4()
journal.on_chat_model_start({}, [[HumanMessage(content="abc")]], run_id=rid, tags=["middleware:title"])
journal.on_llm_error(RuntimeError("boom"), run_id=rid, tags=["middleware:title"])
(event,) = await _events(journal, store, "llm.error")
assert event["metadata"]["status"] == "error"
assert event["metadata"]["caller_category"] == "middleware"
assert event["metadata"]["request_chars"] == 3
assert event["content"] == "boom"
@pytest.mark.anyio
async def test_telemetry_failure_does_not_affect_call_or_event(monkeypatch):
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
def explode(*_a, **_k):
raise RuntimeError("telemetry broken")
monkeypatch.setattr("deerflow.runtime.journal._rendered_request_size", explode)
monkeypatch.setattr("deerflow.runtime.journal._llm_caller_category", explode)
rid = uuid4()
journal.on_chat_model_start({}, [[HumanMessage(content="hi")]], run_id=rid, tags=["lead_agent"]) # must not raise
usage = {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5}
journal.on_llm_end(_response(usage=usage), run_id=rid, tags=["lead_agent"]) # must not raise
(event,) = await _events(journal, store, "llm.ai.response")
assert event["metadata"]["usage"] == usage # pre-existing fields intact
assert event["metadata"]["caller"] == "lead_agent"
assert "langchain_run_id" not in event["metadata"]
assert journal._total_tokens == 5
@pytest.mark.anyio
async def test_llm_error_without_prior_start_records_unavailable_request_size():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid = uuid4()
journal.on_llm_error(ValueError("bad"), run_id=rid, tags=["subagent:general-purpose"]) # no on_chat_model_start
(event,) = await _events(journal, store, "llm.error")
meta = event["metadata"]
assert meta["status"] == "error"
assert meta["caller"] == "subagent:general-purpose"
assert meta["caller_category"] == "subagent"
assert meta["error_type"] == "ValueError"
assert meta["provider"] is None and meta["model"] is None
assert "request_chars" not in meta # unavailable, not fabricated
@pytest.mark.anyio
@pytest.mark.parametrize(
"initial_usage",
[None, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}],
ids=["missing-usage", "zero-usage"],
)
async def test_replayed_on_llm_end_keeps_canonical_request_metadata(initial_usage):
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid, parent_id = uuid4(), uuid4()
journal.on_chat_model_start(
{},
[[HumanMessage(content="question")]],
run_id=rid,
tags=["lead_agent"],
metadata={"ls_provider": "anthropic", "ls_model_name": "requested-model"},
)
response_metadata = {"model_name": "canonical-model", "finish_reason": "stop"}
# The first callback has missing or zero usage; the adjacent replay fills it in.
journal.on_llm_end(_response(usage=initial_usage, response_metadata=response_metadata), run_id=rid, parent_run_id=parent_id, tags=["lead_agent"])
assert journal._pending_llm_response is not None
canonical_metadata = dict(journal._pending_llm_response.events[0]["metadata"])
usage = {"input_tokens": 7, "output_tokens": 3, "total_tokens": 10}
journal.on_llm_end(
_response(
usage=usage,
response_metadata={"model_name": "replay-model", "finish_reason": "length"},
additional_kwargs={"deerflow_error_fallback": True, "error_detail": "replay fallback"},
),
run_id=rid,
parent_run_id=uuid4(),
tags=["middleware:summarize"],
)
(event,) = await _events(journal, store, "llm.ai.response")
meta = event["metadata"]
assert meta == {**canonical_metadata, "usage": usage, **usage}
assert event["content"]["usage_metadata"] == usage
assert event["content"]["response_metadata"] == response_metadata
assert meta["request_message_chars"] == len("question") # canonical request size preserved
assert meta["request_message_count"] == 1
assert meta["langchain_run_id"] == str(rid)
assert meta["langchain_parent_run_id"] == str(parent_id)
assert meta["caller_category"] == "lead_agent" and meta["status"] == "ok"
assert meta["model"] == "canonical-model" and meta["stop_reason"] == "stop"
assert not journal.had_llm_error_fallback
assert journal.get_completion_data()["total_tokens"] == 10
@pytest.mark.anyio
@pytest.mark.parametrize(
"initial_usage",
[None, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}],
ids=["missing-usage", "zero-usage"],
)
async def test_usage_replay_after_flush_keeps_committed_telemetry(initial_usage):
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
rid = uuid4()
journal.on_llm_end(_response(usage=initial_usage), run_id=rid, tags=["lead_agent"])
(canonical_event,) = await _events(journal, store, "llm.ai.response")
canonical_event = deepcopy(canonical_event)
usage = {"input_tokens": 7, "output_tokens": 3, "total_tokens": 10}
journal.on_llm_end(_response(usage=usage), run_id=rid, tags=["lead_agent"])
(event,) = await _events(journal, store, "llm.ai.response")
assert event == canonical_event
assert journal.get_completion_data()["total_tokens"] == 10
@pytest.mark.anyio
async def test_telemetry_metadata_round_trips_through_db_store(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'test.db'}", sqlite_dir=str(tmp_path))
try:
store = DbRunEventStore(get_session_factory())
journal = RunJournal("r1", "t1", store, flush_threshold=100)
ok_id, err_id = uuid4(), uuid4()
journal.on_chat_model_start({}, [[HumanMessage(content="hello")]], run_id=ok_id, tags=["lead_agent"], metadata={"ls_provider": "anthropic"})
journal.on_llm_end(_response(usage={"input_tokens": 2, "output_tokens": 1, "total_tokens": 3}, response_metadata={"stop_reason": "end_turn"}), run_id=ok_id, tags=["lead_agent"])
journal.on_chat_model_start({}, [[HumanMessage(content="abc")]], run_id=err_id, tags=["middleware:title"])
journal.on_llm_error(RuntimeError("boom"), run_id=err_id, tags=["middleware:title"])
journal.record_middleware("summarize", name="DeerFlowSummarizationMiddleware", hook="before_model", action="summary_result", changes={"noop": False})
await journal.flush()
events = await store.list_events("t1", "r1")
by_type = {e["event_type"]: e for e in events}
ai = by_type["llm.ai.response"]["metadata"]
assert ai["langchain_run_id"] == str(ok_id) and ai["provider"] == "anthropic" and ai["stop_reason"] == "end_turn"
assert ai["input_tokens"] == 2 and ai["request_chars"] == len("hello")
err = by_type["llm.error"]["metadata"]
assert err["status"] == "error" and err["caller_category"] == "middleware" and err["request_chars"] == 3
assert by_type["middleware:summarize"]["content"]["changes"] == {"noop": False}
finally:
await close_engine()
def _summarizer(text: str) -> DeerFlowSummarizationMiddleware:
model = MagicMock()
model.invoke.return_value = SimpleNamespace(text=text)
model.ainvoke = AsyncMock(return_value=SimpleNamespace(text=text))
model.with_config.return_value = model
return DeerFlowSummarizationMiddleware(model=model, trigger=("messages", 4), keep=("messages", 2), token_counter=len)
def _state(summary: str | None) -> dict:
messages = [HumanMessage(content="u1"), AIMessage(content="a1"), HumanMessage(content="u2"), AIMessage(content="a2")]
return {"messages": messages, "summary_text": summary}
@pytest.mark.anyio
async def test_summarization_noop_detected_and_call_not_skipped():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
runtime = SimpleNamespace(context={"thread_id": "t1", "__run_journal": journal})
middleware = _summarizer("same summary")
result = middleware.compact_state(_state("same summary"), runtime, force=True)
assert result is not None and result.summary_text == "same summary" # behavior unchanged
assert middleware.model.invoke.call_count == 1 # call NOT skipped
assert middleware.summary_noop_count == 1
middleware.compact_state(_state("different"), runtime, force=True)
middleware.compact_state(_state(None), runtime, force=True)
assert middleware.summary_noop_count == 1
assert middleware.summary_call_count == 3
events = await _events(journal, store, "middleware:summarize")
assert [e["content"]["changes"]["noop"] for e in events] == [True, False, False]
def test_summarization_telemetry_failure_does_not_break_compaction():
journal = MagicMock()
journal.record_middleware.side_effect = RuntimeError("journal down")
runtime = SimpleNamespace(context={"thread_id": "t1", "__run_journal": journal})
middleware = _summarizer("s")
result = middleware.compact_state(_state(None), runtime, force=True)
assert result is not None and result.summary_text == "s"