Skip to main content
This guide shows how to instrument an agent built from scratch, without relying on a framework integration. You will add:
  1. a top-level run span
  2. manual step spans for LLM calls
  3. manual tool-call spans for tool execution
  4. run-level metadata for filtering
  5. error handling for failed runs and child spans

Prerequisites

  • You have a Lemma API key and project ID.
  • You can run a plain Node.js or Python app.
  • Your agent loop is code you control directly (no framework wrapper).

Instrument the Agent

1

Install and configure tracing

npm install @uselemma/tracing @opentelemetry/api openai
// tracer.ts
import { registerOTel } from "@uselemma/tracing";

registerOTel({
  apiKey: process.env.LEMMA_API_KEY,
  projectId: process.env.LEMMA_PROJECT_ID,
});
Register tracing before code that creates spans or calls your agent.
2

Wrap your agent as a run

Use wrapAgent / wrap_agent to create the top-level run span (ai.agent.run).
import "./tracer";
import { wrapAgent } from "@uselemma/tracing";

const runAgent = wrapAgent("scratch-agent", async ({ span, onComplete, recordError }, input) => {
  try {
    span.setAttribute("lemma.user_id", input.userId);
    span.setAttribute("lemma.session_id", input.sessionId);
    span.setAttribute("lemma.feature", "support_chat");

    const output = await executeAgentLoop(input.message);
    onComplete(output);
    return output;
  } catch (error) {
    recordError(error);
    throw error;
  }
});
3

Add a step span for each LLM call

A step is a child span inside the run that captures one LLM request/response.
import { trace } from "@opentelemetry/api";
import OpenAI from "openai";

const tracer = trace.getTracer("scratch-agent");
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function llmStep(userMessage: string) {
  return tracer.startActiveSpan("llm.step.generate", async (stepSpan) => {
    try {
      const response = await client.chat.completions.create({
        model: "gpt-4o",
        messages: [{ role: "user", content: userMessage }],
      });

      const text = response.choices[0]?.message?.content ?? "";
      stepSpan.setAttribute("llm.model.requested", "gpt-4o");
      stepSpan.setAttribute("llm.tokens.completion", response.usage?.completion_tokens ?? 0);
      stepSpan.setAttribute("llm.response", text);
      return text;
    } catch (error) {
      stepSpan.recordException(error as Error);
      stepSpan.setAttribute("step.status", "error");
      throw error;
    } finally {
      stepSpan.end();
    }
  });
}
4

Add tool-call spans around tools

A tool call is another child span nested under the active run.
import { trace } from "@opentelemetry/api";

const tracer = trace.getTracer("scratch-agent");

async function weatherTool(city: string) {
  return tracer.startActiveSpan("tool.call", async (toolSpan) => {
    toolSpan.setAttribute("tool.name", "get_weather");
    toolSpan.setAttribute("tool.args", JSON.stringify({ city }));
    try {
      const result = await getWeather(city);
      toolSpan.setAttribute("tool.result", JSON.stringify(result));
      return result;
    } catch (error) {
      toolSpan.recordException(error as Error);
      toolSpan.setAttribute("tool.status", "error");
      throw error;
    } finally {
      toolSpan.end();
    }
  });
}
5

Wire everything into one agent loop

Below is a minimal sequence:
  1. run starts with wrapAgent / wrap_agent
  2. step span records LLM decision
  3. tool-call span records tool execution
  4. step span records final LLM response
  5. run ends with onComplete / on_complete
If any span fails, record the error on that span and rethrow so the run reflects the failure.
6

Run and verify in Lemma

  • Execute one agent request.
  • Capture the returned runId / run_id.
  • In Lemma, verify:
    • top-level ai.agent.run exists
    • llm.step.* spans are nested under the run
    • tool.call spans appear with tool.name, args, and result
    • custom metadata (lemma.user_id, lemma.session_id) is filterable

Troubleshooting checklist

  • No runs visible: ensure registerOTel / register_otel runs before your app logic.
  • Run appears but no child spans: make sure step/tool spans are created inside the wrapped function.
  • Run never closes: ensure onComplete / on_complete is reached, or that errors are rethrown.
  • Missing metadata filters: verify attributes are set on the run span, not on unrelated spans.