Skip to main content

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:

agent.ts — before
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:

agent.ts — after
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:

  1. Kept the return value of configure() — you need its tracer.
  2. Wrapped the run body in startActiveSpan. Every LLM call inside now joins one trace.
  3. span.end() in a finally, 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.

FrameworkRouting + observationsAutomatic agent & tool spans
LangChain.jsYesYes
LlamaIndex.TSYesNo
Vercel AI SDKYesNo
Google ADKYesNo
MastraYesNo
Anything else on fetchYesNo

"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 startActiveSpan callback
  • startActiveSpan, not startSpan
  • 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