7. Logging and Cost Tracking
Goal: see what LLLM logs, read the session every call produces, track tokens and dollars, persist every tactic call with an observer, tag and query runs, debug failures from their tracebacks, and save full transcripts.
Every turn in LLLM leaves a structured trace. Each respond() produces an
AgentCallSession that records every model call, parse repair, tool round,
provider retry, and its cost. Each tactic call produces a TacticCallSession
that collects the agent sessions, the sessions of sub-tactics it called, the
result or the error with its traceback, and the total cost. An observer
hook on the tactic lets you persist these traces wherever you keep experiment
data.
This matters for research work: runs must be inspectable after the fact, costs must add up across thousands of calls, and a failure in the middle of a batch should leave enough behind to debug it.
Where Pydantic fits: sessions, messages, and costs are Pydantic models, so you
can read their fields directly, and session.summary() and Message.to_dict()
give JSON-ready views.
Prerequisites
Finish Lesson 5. The examples use this tactic:
from lllm import Tactic
class SummarizerTactic(Tactic):
name = "summarizer"
agent_group = ["writer"]
def call(self, task: str) -> str:
writer = self.agents["writer"]
writer.open("session")
writer.receive(f"Summarize this text in 3 bullet points:\n\n{task}")
return writer.respond().content
config = {
"global": {"model_name": "gpt-4o"},
"agent_configs": [{"name": "writer", "system_prompt": "You are a concise technical writer."}],
}
Step 1: Turn on Python logging
LLLM's native runtime logs through Python's standard logging module under the
lllm logger hierarchy. Configure it like any other library:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
tactic = SummarizerTactic(config)
tactic("LLLM records a session for every tactic call.")
INFO lllm.runtimes.native.core.tactic: Tactic 'summarizer' started — session_name=summarizer_<hash>_<timestamp>
INFO lllm.runtimes.native.core.tactic: Tactic 'summarizer' completed — cost=<dollars> agent_calls=1
What each level shows:
| Level | Messages |
|---|---|
DEBUG |
Every tool call with its arguments, successful agent calls, and the traceback of a failed tactic call |
INFO |
Tactic start and completion with cost and agent-call count, and one line per failed call (Tactic 'summarizer' failed: ValueError: ...) |
WARNING |
Parse repairs ([writer] Handling exception (retry 0/3): ...) and provider retries |
ERROR |
An agent call that uses up its tool rounds without a final reply ([writer] Agent call failed) |
An agent call that runs out of parse repairs, provider retries, or rate-limit
retries logs nothing at ERROR: it raises the last error, and the tactic's
INFO failure line reports it.
To see tool calls without turning on debug output for every library:
logging.getLogger("lllm").setLevel(logging.DEBUG)
Step 2: Read a tactic call session
return_session=True returns the TacticCallSession instead of the result:
session = tactic("Sessions record every agent call.", return_session=True)
print(session.tactic_name, session.tactic_path, session.state)
print(session.delivery) # what call() returned
print(session.agent_call_count) # respond() calls across all agents
print(session.summary())
| Field or property | Meaning |
|---|---|
tactic_name |
The tactic's name (a name= given to the constructor wins over the class name) |
tactic_path |
Stable identifier used for filtering (Step 9) |
state |
"success" or "failure" ("running" while in flight) |
delivery |
The value call() returned |
error, error_traceback |
Exception summary and full traceback on failure |
agent_sessions |
Agent name to the list of its AgentCallSessions |
sub_tactic_sessions |
Tactic name to the sessions of sub-tactics called inside call() (Lesson 5, Step 7) |
total_cost |
InvokeCost summed over agent and sub-tactic sessions |
agent_call_count, sub_tactic_call_count |
Counts |
summary() |
Compact dict with tactic, state, counts, and cost |
Step 3: Drill into agent calls
Each respond() inside the tactic left an AgentCallSession:
for agent_name, calls in session.agent_sessions.items():
for call in calls:
print(
agent_name,
call.state,
"repairs:", call.exception_retries_count,
"tool rounds:", len(call.interrupts),
"provider retries:", call.llm_recalls_count,
"cost:", call.cost.cost,
)
The dictionaries are keyed by the loop step (one step per tool round):
exception_retries[step]: the parse or validation errors that triggered repairs;interrupts[step]: the tool calls requested in that round;llm_recalls[step]: provider errors that were retried;invoke_traces[step]: oneInvokeResultper model call, including failed attempts, with the exactmodel_argssent, anyexecution_errors, the returnedmessage, and the provider'sraw_response.
call = session.agent_sessions["writer"][0]
for step, traces in call.invoke_traces.items():
for trace in traces:
print(step, trace.model_args, trace.has_errors, trace.message.content[:60])
call.delivery is the accepted reply, the same Message respond() returned.
Step 4: Track tokens and cost
Costs are InvokeCost objects at three levels:
reply = call.delivery
print(reply.cost) # one message
print(session.total_cost) # one tactic call
print(session.total_cost.cost) # total dollars as a float
print(session.total_cost.prompt_tokens, session.total_cost.completion_tokens)
Printing an InvokeCost gives a readable breakdown:
Tokens: <total> (Prompt: <n> [Cached: <n>], Completion: <n> [Reasoning: <n>])
Cost Breakdown: Prompt: $<x>, Completion: $<y> | Total Cost: $<z>
| Field | Meaning |
|---|---|
prompt_tokens, completion_tokens, total_tokens |
Token counts reported by the provider |
cached_prompt_tokens, reasoning_tokens |
Cached-prompt and reasoning token counts, when reported |
prompt_cost, completion_cost, cost |
Dollar amounts |
input_cost_per_token, output_cost_per_token |
Per-token rates recorded on individual messages; zero in sums |
Dollar amounts come from LiteLLM's model price list. For a model LiteLLM has no
price for, token counts are still recorded and dollar amounts are 0.0. A
dialog sums its own messages with dialog.cost
(Lesson 2).
Step 5: Persist every call with an observer
Pass an observer when you construct a tactic. LLLM calls its hooks for every
tactic call, passing the session, the task, and the tags and metadata of that
call:
on_tactic_success(*, tactic, session, task, result, span, tags, metadata)on_tactic_failure(*, tactic, session, task, exception, span, tags, metadata)- optionally
observe_tactic(*, tactic, session, task, session_name, tags, metadata), a context manager wrapped around the call; whatever it yields is passed to the other hooks asspan. Use it for timing or tracing spans.
Every hook is optional, and an exception inside a hook is logged without breaking the call. This observer appends one JSON line per call:
import contextlib
import json
import time
class JsonlRunLog:
"""Append one JSON record per tactic call."""
def __init__(self, path: str):
self.path = path
@contextlib.contextmanager
def observe_tactic(self, *, tactic, session, task, session_name, tags, metadata):
yield {"session_name": session_name, "started": time.time()}
def on_tactic_success(self, *, tactic, session, task, result, span, tags, metadata):
self._write(session, span, tags)
def on_tactic_failure(self, *, tactic, session, task, exception, span, tags, metadata):
self._write(session, span, tags)
def _write(self, session, span, tags):
cost = session.total_cost
record = {
"session_name": span["session_name"],
"seconds": round(time.time() - span["started"], 3),
"tactic": session.tactic_path,
"state": session.state,
"tags": tags or {},
"agent_calls": session.agent_call_count,
"cost_usd": cost.cost,
"prompt_tokens": cost.prompt_tokens,
"completion_tokens": cost.completion_tokens,
"error": session.error,
"traceback": session.error_traceback,
}
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(record) + "\n")
run_log = JsonlRunLog("runs.jsonl")
tactic = SummarizerTactic(config, observer=run_log)
Step 6: Tag runs and query them
Tags are string key-value pairs attached at call time. They reach the observer, so you can filter on them later:
tactic("First document ...", tags={"env": "dev", "experiment": "prompt-b"})
tactic.bcall(["Second document ...", "Third document ..."], tags={"env": "dev", "experiment": "prompt-b"})
metadata={...} works the same way for values that are not strings. Querying
the log is ordinary Python:
with open("runs.jsonl", encoding="utf-8") as f:
runs = [json.loads(line) for line in f]
prompt_b = [run for run in runs if run["tags"].get("experiment") == "prompt-b"]
print(f"{len(prompt_b)} runs, ${sum(run['cost_usd'] for run in prompt_b):.4f} total")
by_tactic = {}
for run in runs:
by_tactic[run["tactic"]] = by_tactic.get(run["tactic"], 0.0) + run["cost_usd"]
print(by_tactic)
The same observer can write to SQLite, a data warehouse, or an experiment
tracker instead of a JSONL file; only _write() changes.
Step 7: Debug failed calls
A failing call raises as usual, and the observer still receives the session,
with state="failure", the exception summary, and the full traceback:
from lllm import Tactic
class FlakyTactic(Tactic):
name = "flaky"
def call(self, task: str) -> str:
raise ValueError(f"cannot handle {task!r}")
try:
FlakyTactic(observer=run_log)("bad input", tags={"env": "dev"})
except ValueError:
pass
with open("runs.jsonl", encoding="utf-8") as f:
failures = [run for run in map(json.loads, f) if run["state"] == "failure"]
print(failures[-1]["error"]) # ValueError: cannot handle 'bad input'
print(failures[-1]["traceback"])
The session passed to on_tactic_failure also holds the agent sessions
recorded before the failure, so you can see how far the call got.
Step 8: Save full transcripts
Sessions keep the accepted replies; the complete dialogs live on the agents of
each call. The tactic passed to the observer hooks is the per-call copy, so
its agents still hold every dialog they opened:
class TranscriptLog(JsonlRunLog):
"""Also save every dialog of every agent, one JSON file per call."""
def on_tactic_success(self, *, tactic, session, task, result, span, tags, metadata):
super().on_tactic_success(
tactic=tactic, session=session, task=task, result=result,
span=span, tags=tags, metadata=metadata,
)
transcripts = {
agent_name: {alias: dialog.to_dict() for alias, dialog in agent.dialogs.items()}
for agent_name, agent in tactic.agents.items()
}
with open(f"{span['session_name']}.dialogs.json", "w", encoding="utf-8") as f:
json.dump(transcripts, f)
tactic = SummarizerTactic(config, observer=TranscriptLog("runs.jsonl"))
tactic("Save the whole conversation.")
dialog.to_dict() produces JSON-ready data with the messages (including tool
calls and parsed output), the dialog's lineage, and its latest prompt. Load a
saved dialog with Dialog.from_dict(data) to review it, replay it, or build
datasets from it (Lesson 2, Step 9).
Step 9: Use stable tactic identities
session.tactic_path identifies the tactic in logs. When a tactic is built from
a package registry it has the form "<package>::<tactic name>":
from lllm import Runtime, build_tactic
runtime = Runtime()
runtime.register_tactic("summarizer", SummarizerTactic, namespace="my_project.tactics")
packaged = build_tactic(config, name="my_project.tactics:summarizer", registry=runtime)
print(packaged("Stable ids.", return_session=True).tactic_path) # my_project::summarizer
Loading an LLLM package registers its tactics this way
(Lesson 6). The identifier does not depend on file
paths inside the package, so log queries keep working when you reorganize a
project; only renaming the package or the tactic's name changes it. A tactic
constructed directly uses its name.
Summary
| Task | API |
|---|---|
| See runtime logs | logging.basicConfig(level=logging.INFO); logging.getLogger("lllm") |
| Session of one call | tactic(task, return_session=True) |
| Per-respond details | session.agent_sessions[name][i]: retries, interrupts, invoke traces |
| Sub-tactic sessions | session.sub_tactic_sessions[name] |
| Cost | message.cost, dialog.cost, session.total_cost |
| Persist every call | MyTactic(config, observer=obj) with on_tactic_success / on_tactic_failure |
| Timing or tracing spans | observe_tactic(...) context manager on the observer |
| Tag runs | tactic(task, tags={...}, metadata={...}), tactic.bcall(tasks, tags={...}) |
| Debug failures | session.error, session.error_traceback in on_tactic_failure |
| Save transcripts | dialog.to_dict() for each agent of the per-call tactic |
| Stable identity | session.tactic_path, "<package>::<name>" for packaged tactics |
See the Logging reference for details.
Next: 8. Advanced Patterns