Traces, spans and tools
Name a run, nest steps inside it, time tool calls with retries, and attach users, sessions and tags.
On this page
You do not need any of this to get data. Model calls, runs, sessions and tool steps are already recorded — see Automatic capture. Reach for the explicit API when you want a run named the way you think about it, a step the provider never sees, or timing around work that is not a model call.
The two work together: model calls made inside a trace() become children of
it.
trace()
import OpenAI from "openai"
import { trace, tool, setSession, setUser, flush } from "@tracehatch/sdk"
const openai = new OpenAI()
await trace("answer question", async (run) => {
setSession("conversation-42")
setUser({ id: "user-7" })
const context = await tool("search docs", () => search(question))
const answer = await openai.chat.completions.create({
model: "YOUR_EXISTING_MODEL_ID",
messages: [{ role: "user", content: `Explain: ${context}` }],
})
run.addTags("support")
return answer
})
await flush()trace(name, callback, options?) returns a promise — await it even when the
callback is synchronous. An explicit run takes precedence over the automatic
one, so the inbound request does not also produce a run of its own.
TraceOptions: agent, rootName, input, tags, metadata, sessionId,
user, signal.
startTrace()
When the run does not fit inside one callback — a batch job, a worker that hands off — take a handle and end it yourself.
import { startTrace, span, shutdown } from "@tracehatch/sdk"
const run = startTrace("import documents", { tags: ["batch"] })
try {
await run.run(() => span("parse", "custom", async () => "parsed"))
run.end({ output: { imported: 1 } })
} catch (error) {
run.end({ outcome: "error", error })
throw error
} finally {
await shutdown()
}A trace handle supports setInput, setOutput, setUser, setSession,
addTags, setMetadata, score and end. run.run(fn) makes the handle the
active run for the duration of fn, so spans created inside it attach to this
run.
span()
import { span } from "@tracehatch/sdk"
await span("rerank results", "custom", async (step) => {
const ranked = await rerank(candidates)
step.setAttributes({ "rerank.kept": ranked.length })
step.addEvent("dropped low scores", {
removed: candidates.length - ranked.length,
})
step.setOutput(ranked.slice(0, 3))
return ranked
})span(name, kind, callback, options?). The kind is one of agent,
generation, tool, retrieval, embedding, guardrail or custom — it
decides the icon, the grouping in the Spans explorer and how the step is
treated in latency breakdowns.
A span handle supports setOutput, setAttributes and
addEvent(name, attributes?, level?). SpanOptions: input, attributes,
signal.
Concurrent spans keep their own parents. A span() or tool() recorded outside
any run becomes a run of its own. Nested traces receive
metadata.parent_trace_id.
Tools
import { tool } from "@tracehatch/sdk"
const result = await tool("charge card", () => payments.charge(order), {
input: { orderId: order.id },
retries: 2,
backoffMs: 100,
})tool(name, fn, options?) records a tool span. retries: 2 makes up to three
attempts inside one span, with an event per attempt recording its timing and
error. Retries default to zero — only opt in when the operation can safely be
repeated. Provider client retries of a model call are recorded as separate
attempts of the generation instead.
Supply the tool input explicitly through options.input; the callback's return
value becomes the final output.
Pairing with a provider-requested tool
When the model asked for the tool, pass the provider's call id so your explicit span replaces the inferred one instead of sitting beside it:
await tool("search docs", () => search(args), {
attributes: { "tool.call_id": call.id },
})The explicit span wins, keeping its real timing and retry events. A matching name alone cannot identify one execution reliably.
Sessions, users, tags and metadata
import { setSession, setUser, addTags, setMetadata } from "@tracehatch/sdk"
setSession("conversation-42")
setUser({ id: "user-7" })
addTags("support", "escalated")
setMetadata({ tenant: "acme", channel: "email" })These override what capture would otherwise derive. Outside a recording run they are ignored rather than throwing.
Prefer opaque identifiers: ids are pseudonymised rather than masked when redaction would break them, and the hashes are unkeyed. See Privacy and data handling.
A provider the SDK does not recognise
If a call goes somewhere outside the supported request shapes, record it yourself. Keep your existing client, request and model:
import { trace, span } from "@tracehatch/sdk"
await trace("answer question", () =>
span("model call", "generation", async (step) => {
step.setAttributes({
"gen_ai.system": "your-provider",
"gen_ai.request.model": "your-existing-model",
})
const result = await existingModelCall()
// Map token usage from the real response when the provider reports it.
return result
})
)From a language without an SDK, the log endpoint does the same thing over HTTP.
Streams inside a trace
Keep the trace() callback open until the stream is consumed or cancelled. The
SDK reads its own copy while your code reads the response, and the run's end
waits for it — bounded by two minutes, after which an unread stream is recorded
as cancelled.
Before the process ends
trace() and span() do not send anything by themselves. A long-lived server
flushes on its own; short-lived processes must
await flush() before returning.