Observability¶
A pipeline you cannot see into is a pipeline you cannot trust. Kaval.AI makes every run observable by default: each run carries its own state and trace, every node and model call is logged, and the whole history is persisted so you can reload and inspect it later — in code or in the backoffice UI.
This guide covers the why; for a hands-on tour of storage — chat history, context, sessions, the tables and writing your own backend — see the Observability & storage tutorial, and for the schema itself, table by table and column by column, see The database and data model.
What a run records¶
Every run produces a WorkflowState (see Workflows). For
observability the key fields are:
trace— the ordered list of visited node names, i.e. the exact path the run took through the graph.token_usage— a roll-up ofmodel_calls,prompt_tokens,completion_tokens, andtotal_tokens.duration_seconds— the wall-clock time of the run, set when it completes or fails and recorded on the run row asruns.duration_seconds. Every node visit records its owntasks.duration_secondsas well; the run-level figure is what a latency objective is measured against, since it includes the time between nodes and counts overlappingparallelbranches once.run_id/session_id/invocation_id— identifiers that tie logs, storage, and chat history together. The 8-charinvocation_idprefixes every log line of the run, so the log for one run can be isolated with a single search.
Persistence and logging¶
Persistence and logging are split into two pieces handed to the engine when you build it:
AgentService— agents, sessions, runs, and chat history.TaskLogger — per-node logs and model call stats.
from kavalai import WorkflowEngine
engine = WorkflowEngine.from_yaml(yaml, agent_service=..., task_logger=...)
Local vs. production databases¶
The AgentService runs against any database the ORM models support: for
local development and tests point it at in-memory SQLite
(AgentService(db_manager.get_sqlite_sessionmaker())), in production at
Postgres — the same agents / sessions / runs / chat_messages
tables either way. SqliteTaskLogger is the local counterpart of the
production PostgresTaskLogger. The same code runs against either — only
the connection changes.
AgentService¶
The engine records each run through the service: initialize_workflow_run
starts it, update_run lands the output and resolved context, and
add_chat_message / get_chat_history carry the conversation. To pull a
finished run’s conversation back:
history = await engine.agent_service.get_chat_history(UUID(state.session_id))
Per-model-call statistics come from the LLM clients themselves: every call
produces a ModelCallStat with token usage and timing, delivered through the
ModelStatsReceiver callback interface (ModelStatsLogger merely logs
them). During a run the receiver is the run’s TokenAccumulator, which adds
each call to state.token_usage and passes it to the task logger with the
agent, session and run that made it. See LLM clients.
Attempts that never produced a response are recorded too, with the provider’s status code and the error text in place of token counts — so a rate-limit storm or an outage leaves a trace instead of a suspiciously healthy-looking table.
Why there is no cost column¶
The runtime records usage, not spend, and that is a deliberate choice rather than a gap.
Providers do not return a price. OpenAI, Anthropic and Gemini return token
counts; only aggregators like OpenRouter put a cost on the response. So any
framework that reports money carries a price table, and prices change often
enough that every project which takes this seriously keeps that table outside
the library — LiteLLM ships a JSON file it re-fetches at runtime, Langfuse and
LangSmith price at ingestion, Pydantic AI defers to the separate
genai-prices database.
There is a second reason, and it is the stronger one: cached input tokens are
billed at a fraction of fresh ones, so a cost derived from an
undifferentiated prompt_tokens is not merely stale, it is wrong by a
multiple. Kaval.AI therefore records cached_prompt_tokens and
reasoning_tokens alongside the totals, wherever the provider reports them,
which is exactly what a price table needs to be applied correctly:
for call in await service.get_model_call_stats(call_type="llm", limit=5):
fresh = (call.prompt_tokens or 0) - (call.cached_prompt_tokens or 0)
print(call.model, fresh, call.cached_prompt_tokens,
call.completion_tokens)
Pricing those numbers is a few lines against a table you control — and it keeps the price of a model somewhere you can correct in an afternoon, rather than inside a library release.
The trajectory: what the run actually did¶
Every node visit writes exactly one tasks row, and ordering by ``seq``
reconstructs the executed path — including the interleaving of concurrent
parallel branches, which created_at cannot (it is approximate, and ties
are unordered). Three columns carry that structure:
seqPosition in the run’s execution order.
parent_task_nameThe node that produced this row, set on the tool-call rows an agent node emits. A name rather than an id: nothing has to be allocated up front, and the readable column is the one that survives an export to a warehouse.
tool_uriThe tool this row executed, set by both function nodes and agent tool calls — so one predicate finds every call to a tool regardless of whether a human wired it into the YAML or an agent chose it at step three.
That last point is what closed a real gap. An agent node used to produce one row holding its final answer, and everything the agent actually did was built during the run and then discarded — which made an agent failure un-debuggable after the fact. Now:
seq name node_type parent_task_name tool_uri
0 begin start
1 search function python://web_search
2 research agent
3 crawl_url tool_call research python://crawl_url
4 route switch
5 summarize llm
6 finish end
Branch nodes record the decision they made, and the value they made it on:
name = "route"
inputs = {"expr": "parsed.intent", "value": "refund"}
output = {"taken": "handle_refund", "matched": true}
The value is the diagnostic. Nine times in ten a mis-route is not a routing bug:
it is the upstream classifier emitting "Refund" or "refund " or
"refunds". matched: false on a switch is the same signal
pre-computed — the model returned a label outside the enum and the run silently
took default, which the engine also warns about in the log.
The backoffice renders all of this: tool calls indented under the node that made them, and a branch as expr = value → target.
Note
tasks is the biggest table you will own, and tool payloads are exactly
where personal data lives. It grows without bound by design — the assumption
is that you export it to a warehouse and do not retain it indefinitely, so
schedule a retention job on day one (see Retention)
rather than discovering the need later.
max_payload_bytes on the task logger (256 KiB by default) replaces an
oversized payload — of a node or of a model call — with a marker carrying
its real size and a preview. It is there so one four-megabyte crawl result
does not break the writer, the row and the backoffice task list — an
operational limit, not a compliance control. The compliance controls are
described in Recording less.
Reading a trajectory without a database¶
MemoryTaskLogger records the same rows into a
list. Pass one per run to see exactly what a call did, from a notebook or a
test, with no database at all:
from kavalai.workflow.tasklog import MemoryTaskLogger
tasklog = MemoryTaskLogger()
state = await engine.run({"user_message": "hi"}, task_logger=tasklog)
for row in tasklog.records:
print(row.seq, row.name, row.node_type, row.tool_uri or "")
It overrides the engine’s logger for that run only, so one engine can serve many concurrent runs that each want their own trace — which is how a test can assert on what a run did with no database anywhere near it.
Marking non-production traffic¶
Session.external_id is a caller-supplied key: pass your own user, ticket or
thread id and the engine reuses that conversation. Evaluation runs use a
structured prefix, which is a convention worth respecting:
eval:{tag}:{case}
eval:pr-412:missing_quantity
LIKE 'eval:%' then separates test traffic from real traffic in one
predicate; LIKE 'eval:pr-412:%' narrows it to one experiment, which is what
--tag is for. The backoffice’s External ID filter turns a failing case
in a run’s output into the conversation that produced it — see
Evaluation & acceptance testing.
Warning
Do not use the eval: prefix for production session ids, or you will not
be able to tell them apart.
TaskLogger and fire-and-forget¶
TaskLogger exposes log_node, log_model_call, flush, and
close. Logging is fire-and-forget — writes happen in the background so
they never block a run. When you need the writes to land (e.g. at the end of a
test or a batch), await them explicitly:
await tasklog.flush()
Recording less¶
Everything above is recorded by default, and two parts of it are copies of the
conversation: a node’s inputs, output and prompt, and a model
call’s request_data, which is the whole prompt — chat history and retrieved
passages included. A deployer who must not keep a second copy of the
transcript, under the GDPR for example, can leave those copies out. Every task
logger takes the same options:
record_nodesWhen
False, node executions are not recorded at all. Model calls still are.record_payloadsWhen
False, a node’sinputs,outputandpromptand a model call’srequest_dataandresponse_dataare not stored. Names, timings, errors and token counts are.max_payload_bytesThe size cap described above, which applies to model-call payloads as it does to node payloads.
The engine adds one more: record_context=False keeps only the input and
the output in runs.context, instead of every value each node produced.
Each option is stated where the logger or the engine is built, so what a
deployment leaves out can be read from the code that builds it; nothing is
left out silently.
In the example below a scripted client stands in for the model, so it runs
without a provider key; its token counts are estimates (see
kavalai.testing).
from uuid import UUID
from pydantic import BaseModel
from kavalai.agent_service import AgentService
from kavalai.db import Run, db_manager
from kavalai.testing import ScriptedLlmClient
from kavalai.workflow import WorkflowBuilder
from kavalai.workflow.tasklog import SqliteTaskLogger
class Message(BaseModel):
user_message: str
class Topic(BaseModel):
topic: str
class Reply(BaseModel):
agent_response: str
await db_manager.init_sqlite()
service = AgentService(db_manager.get_sqlite_sessionmaker())
tasklog = SqliteTaskLogger("tasklog.db", record_payloads=False)
scripted = ScriptedLlmClient([
{"topic": "opening hours"}, {"agent_response": "From noon."},
{"topic": "opening hours"}, {"agent_response": "Closed on Mondays."},
])
engine = (
WorkflowBuilder("Village guide", llm_model="openai/gpt-5.6-luna")
.data_model("input", Message)
.data_model("topic", Topic)
.data_model("output", Reply)
.start("classify")
.llm("classify", prompt="Name the topic of the question.",
inputs={"message": "input"}, output="topic", next="reply")
.llm("reply", prompt="You are a concise guide to Green Village.",
inputs={"message": "input"}, output="output", next="end")
.end()
.build_engine(agent_service=service, task_logger=tasklog,
record_context=False, client_factory=scripted)
)
state = await engine.run({"user_message": "Is the pub open on Sundays?"})
for task in await tasklog.get_tasks(state.run_id):
print(task["seq"], task["name"], task["inputs"], task["output"])
for call in await tasklog.get_model_calls(state.run_id):
print(call["model"], call["request_data"], call["total_tokens"])
async with service.session_maker() as db:
run = await db.get(Run, UUID(state.run_id))
print(sorted(run.context))
0 start None None
1 classify None None
2 reply None None
3 end None None
openai/gpt-5.6-luna None 39
openai/gpt-5.6-luna None 43
['input', 'output']
The path, the timings and the token counts remain; the prompts and the answers
do not, and runs.context no longer holds the topic the first node produced.
In production the same options go to
PostgresTaskLogger(service, record_payloads=False).
Custom loggers and composition¶
A task logger is a subclass of TaskLogger with two hooks, write_node
and write_model_call. The base class calls them in the background, after
the recording options and the payload cap have been applied, and passes
write_model_call the Pydantic ModelCallStat together with the
agent_id, session_id and run_id of the call. A logger with a
purpose of its own does not subclass the database logger. It sits beside it,
through TeeTaskLogger, which hands every record to each of its loggers;
each keeps its own options. A meter that counts each conversation’s tokens for
billing, beside the logger of the example above, for a second turn:
from collections import Counter
from kavalai.workflow.tasklog import TaskLogger, TeeTaskLogger
class Meter(TaskLogger):
"""Counts the tokens each conversation used, for billing."""
def __init__(self):
super().__init__(record_nodes=False, record_payloads=False)
self.tokens = Counter()
async def write_node(self, **record):
"""Not called: the meter records no nodes."""
async def write_model_call(self, stats, *, agent_id, session_id,
run_id):
self.tokens[session_id] += stats.total_tokens or 0
meter = Meter()
both = TeeTaskLogger(tasklog, meter)
later = await engine.run({"user_message": "And on Mondays?"},
session_id=state.session_id, task_logger=both)
await both.flush()
print(meter.tokens[later.session_id], later.token_usage["total_tokens"])
91 91
The meter’s count equals the second run’s token_usage: task_logger= on
run replaces the engine’s logger for that run only, so the meter saw the
second turn and not the first.
Cost per run and per conversation¶
Every model call row carries the agent_id, session_id and run_id
of the run that made it. The engine gives the run’s TokenAccumulator those
ids as soon as the run is recorded, and a rag_query node hands the same
accumulator to its RAG service, so the embedding of a query is attributed in
the same way. What a run used is then one query:
SELECT model,
count(*) AS calls,
sum(prompt_tokens) AS prompt_tokens,
coalesce(sum(cached_prompt_tokens), 0) AS cached_tokens,
sum(completion_tokens) AS completion_tokens
FROM model_call_stats
WHERE run_id = :run_id
GROUP BY model;
For the first run above, and — with session_id = :session_id in the
WHERE clause — for the whole conversation after the second turn:
model calls prompt_tokens cached_tokens completion_tokens
openai/gpt-5.6-luna 2 67 0 15
model calls prompt_tokens cached_tokens completion_tokens
openai/gpt-5.6-luna 4 141 0 32
The query reads the same columns in the file of a SqliteTaskLogger, as
here, and in the agent database that PostgresTaskLogger writes to, where
service.get_model_call_stats(run_id=…) and session_id=… return the rows
themselves. None of the three ids is a foreign key, so the rows outlive the
conversation they belong to. Pricing the four columns remains outside the
library, for the reasons given above.
Retention¶
Nothing in the runtime store is deleted on its own, and tasks and
chat_messages grow with every turn.
purge_sessions() deletes the
sessions whose last activity — sessions.updated_at, the time of the last
run — is older than a cutoff:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=90)
async for batch in service.purge_sessions(cutoff, batch_size=500):
print(f"deleted {len(batch)} sessions")
Against a database holding 1,200 sessions idle for 100 days, it prints:
deleted 500 sessions
deleted 500 sessions
deleted 200 sessions
Each batch is one transaction, oldest sessions first, and its session ids are
yielded once it is committed, so a caller can delete rows of its own keyed by
session in step. A session’s runs, tasks and chat messages go with it through
the foreign keys. Its model calls stay: the token counts are a cost record,
while the payloads are the conversation again, so a purge sets
request_data and response_data to NULL and keeps the rest.
(call,) = await service.get_model_call_stats(limit=1)
print(call.total_tokens, call.request_data)
27 None
agent_ids restricts a purge to some agents — the agents of one tenant, for
example. None means every agent, and an empty list purges nothing, so a
list computed from an empty selection cannot purge the whole database.
delete_history_for_session treats a single session’s model calls in the
same way, while keeping the session and its runs.
The backoffice UI¶
All of this surfaces in the backoffice as Conversations -> Runs -> Tasks, plus Metrics and Model Calls pages — letting you drill from a conversation down to an individual node or model call. See Using the Backoffice UI.