Agents and tool calls
An agent run is many LLM calls, some tool executions, and one logical operation. By default the SDK traces each call separately — correct for a single request, wrong for an agent, where you end up with five unrelated rows instead of one run.
This page shows the one extra line that fixes that.
Start from an agent you already have
Here's an ordinary tool-calling agent. No Observra anywhere:
import Groq from "groq-sdk";
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
export async function runAgent(task: string) {
const messages = [{ role: "user", content: task }];
for (let turn = 0; turn < 5; turn++) {
const res = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages,
tools: TOOLS,
});
const assistant = res.choices[0].message;
messages.push(assistant);
const calls = assistant.tool_calls ?? [];
if (calls.length === 0) return assistant.content;
for (const call of calls) {
const result = runTool(call.function.name, JSON.parse(call.function.arguments));
messages.push({
role: "tool",
tool_call_id: call.id,
name: call.function.name,
content: JSON.stringify(result),
});
}
}
}
Add observability
Two lines at startup, and one wrapping span around the run:
import * as observra from "observra-sdk-node";
import Groq from "groq-sdk";
const config = observra.configure({ serviceName: "my-agent" });
await observra.instrument();
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
export async function runAgent(task: string) {
return config.tracer.startActiveSpan("agent.run", async (span) => {
const messages = [{ role: "user", content: task }];
try {
for (let turn = 0; turn < 5; turn++) {
const res = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages,
tools: TOOLS,
});
const assistant = res.choices[0].message;
messages.push(assistant);
const calls = assistant.tool_calls ?? [];
if (calls.length === 0) return assistant.content;
for (const call of calls) {
const result = runTool(call.function.name, JSON.parse(call.function.arguments));
messages.push({
role: "tool",
tool_call_id: call.id,
name: call.function.name,
content: JSON.stringify(result),
});
}
}
} finally {
span.end();
}
});
}
The agent's own logic is untouched. What changed:
- Kept the return value of
configure()— you need itstracer. - Wrapped the run body in
startActiveSpan. Every LLM call inside now joins one trace. span.end()in afinally, so the span closes even when the agent throws. A span that never ends is never exported.
What you get
Instead of five unrelated observations, one flow:
App
└── Model (asks for a tool)
└── Tool check_conflicts(...)
└── Model (asks again with the result)
└── Tool
└── Model (final answer)
└── Done
Open any observation in the trace and choose Request flow.
Two things that catch people out
startActiveSpan, not startSpan
Only an active span is found by the calls inside it — that's what carries trace
context across await boundaries.
startSpan creates a span without activating it, so calls inside won't join it and
you get the ungrouped behaviour you were trying to fix. The names are one word apart
and the failure is silent.
The tree needs payload capture
The Request flow tree is reconstructed from captured request and response bodies. Payload capture is opt-in and off by default, so with it off every step reads "capture is off" and you get a flat single-step flow.
Enable it per Environment in the dashboard, under the Environment's security policy.
:::warning Consider what you're storing
Enabling capture means prompts and responses are retained, subject to your project's
retention window. If your prompts carry personal or regulated data, decide
deliberately — and consider guardrails with "redact".
:::
Writing the loop itself
If you're building the agent loop rather than adapting one, two details matter:
Loop, don't hard-code two turns. Models routinely call a tool, read the result, and call it again. A fixed "call, run tool, call once more" returns an empty answer the moment that happens.
Bound the loop. A model that keeps asking for tools has to terminate. A turn limit is the simplest guard.
Framework support
Every framework gets routing and gateway telemetry, because they all make their calls
over fetch and the SDK patches fetch. Only LangChain currently gets deeper
instrumentation.
| Framework | Routing + observations | Automatic agent & tool spans |
|---|---|---|
| LangChain.js | Yes | Yes |
| LlamaIndex.TS | Yes | No |
| Vercel AI SDK | Yes | No |
| Google ADK | Yes | No |
| Mastra | Yes | No |
Anything else on fetch | Yes | No |
"No" in the right column doesn't mean unusable. You still get every LLM call with its model, tokens, cost, and latency — and the wrapping span above still groups a run into one trace. What you don't get automatically is a span per framework-level step.
LangChain
If @langchain/core is installed, instrument() detects it and patches the chat
model and tool entry points, so every model call and tool execution becomes a span
with no extra code:
import * as observra from "observra-sdk-node";
import { ChatGroq } from "@langchain/groq";
const config = observra.configure({ serviceName: "my-agent" });
await observra.instrument(); // detects and patches LangChain
const model = new ChatGroq({ apiKey: process.env.GROQ_API_KEY, model: "llama-3.3-70b-versatile" });
const res = await model.invoke("Hello");
Instrumentation is version-guarded. Against a major version it hasn't been tested with, the SDK logs a line and skips patching rather than patching blind against a changed internal API — you keep routing and telemetry, you lose the extra spans.
Still wrap multi-step runs in a span. LangChain gives you spans per step; the wrapping span is what makes the whole run one trace.
Tool calls in the dashboard
When a model response contains tool calls, the gateway records them and Observra checks each against your MCP registry.
If you haven't registered any MCP servers, tool calls show as Unregistered. That's accurate — an ordinary local function isn't a registered MCP tool — but it isn't a failure. Your tool ran fine. Register them in the MCP hub if you want them recognised.
Checklist
If a run isn't rendering as one tree:
- Every call is inside one
startActiveSpancallback -
startActiveSpan, notstartSpan -
span.end()runs, including on the error path -
await observra.instrument()ran before the first call - Payload capture is enabled for that Environment
- The dashboard range covers when the run happened
Next
- Tracing — spans in general, and custom spans
- Guardrails — scanning agent prompts
- API reference — full surface