Use knotel / Debug and alert
Sessions, people and agents
Tie spans to a browser visit, an end user, or an AI agent's conversation, and read them back.
A span can say who it was for and what it was part of: a browser visit, an end user, an AI agent's conversation. Set the attributes below and the Sessions and Agents pages fill in; set none and they stay empty and cost nothing.
Sessions
The browser script sets session.id on every visit, kept in sessionStorage, so nothing is needed to get sessions from a website. Backend spans don't carry it - nothing propagates a session across a network hop - but they share a trace with the browser span that called them, so a session's traces bring the backend work along.
From another SDK, set session.id on the span or on the resource; both are read.
People
Call identify after sign-in, and with null on sign-out:
knotel.identify("u_8fc21", { name: "Linh Tran" }); // after sign-in
knotel.identify(null); // on sign-outIn a single-page app the user usually arrives from an API call that can finish before the deferred script has loaded, when window.knotel doesn't exist yet. Keep the last id and apply it again once the script loads:
let current: { id: string; name?: string } | null = null;
export function setTracingUser(user: { id: string; name?: string } | null) {
current = user;
try {
window.knotel?.identify(user?.id ?? null, user?.name ? { name: user.name } : undefined);
} catch {} // tracing must never break the app
}
// Where you add the script tag, or once at startup:
document.querySelector<HTMLScriptElement>('script[src$="/t.js"]')
?.addEventListener("load", () => current && setTracingUser(current));Call it wherever the signed-in user changes - after the "who am I" request resolves, on sign-in, and with null on sign-out.
The name is optional. When it is there, Sessions and People show it with the id beside it; otherwise they show the id alone. It is sent as user.full_name, so a backend can set that attribute too.
Spans already queued are sent with the id too: they belong to the same visit, and a page load that ends in a sign-in is the part worth attributing. On the server, set user.id (or enduser.id) on the span in your auth middleware; in a Worker, call identify(userId) from knotel/worker, which sets it on the request's root span.
Who and where, on every trace
A trace page opens with who it was for and where they were: the user, their session, their city and network, their browser and device, and the page they were on. Each span's details repeat it under Initiated by, since a query three services deep was still caused by that person, from that place. Nothing needs setting up: it is read from whichever span in the trace says it - the browser span's session and user, the location the Worker SDK and ingest record - and from the session's web visit. A backend that the browser called directly is covered by the browser's span, whatever language it is written in.
user.hash and enduser.pseudo.id; both are read as the user.Both fields are filterable anywhere: session.id and user.id work in queries, on the Traces page and in outlier attribution, where "the slow requests are all one customer" is a finding worth having.
Agent sessions
Spans following the OpenTelemetry GenAI conventions are read as agent sessions: a conversation is every span sharing a gen_ai.conversation.id. The Agents page shows each one's model calls, tool calls, tokens, estimated cost and where its wall-clock time went; opening one lists every step in order, each linking to its trace.
The two attributes that matter:
| Attribute | What it does |
|---|---|
gen_ai.conversation.id | Groups spans into one session. Without it there is no session to show. |
gen_ai.operation.name | What the span did: chat, execute_tool, invoke_agent, embeddings. Decides whether it counts as a model call, a tool call or agent overhead. |
Everything else is optional and read when present:
- Tokens: the five buckets -
gen_ai.usage.input_tokens,output_tokens,cache_read.input_tokens,cache_write.input_tokensandreasoning.output_tokens. Reasoning tokens are left out of the total, since providers already count them inside output. - Model:
gen_ai.response.model, elsegen_ai.request.model. - Tool:
gen_ai.tool.nameonexecute_toolspans, which is what the tool table groups by.
The conventions are still marked Development upstream, and most frameworks shipped their own keys first, so older spellings (gen_ai.usage.prompt_tokens, the Vercel AI SDK's ai.usage.*) are read too.
What cost means
OpenTelemetry defines no cost attribute. If your span carries one (gen_ai.usage.cost), that is what's shown - you know your own contract. Otherwise cost is estimated from tokens against published list prices for the models knotel knows, matched by prefix so dated and provider-prefixed ids follow their family.
A model with no published price here reports tokens and no cost, rather than a number that would be wrong. The table is in src/lib/gen-ai.ts; it is a plain list, and adding your own models to it is the intended fix.