Instrumentation

Nested LLM traces with spans and generations

Build parent-child LLM traces for retrievers, tools, agents, and model calls in TypeScript or Python, with error and flush handling.

Nested LLM traces show the order and parent-child relationship of every step in a request. Use them when one root trace contains retrieval, tool calls, routing, or more than one model generation. The result is a tree you can inspect from the root request down to the slow or failed operation.

Choose the right node type

A Currai trace tree uses three main node types:

  • A trace is the root unit of work, such as one chat turn or agent run.
  • A span records non-model work, including retrieval, tool execution, and application code.
  • A generation records a model call, including the model, input, output, parameters, token usage, and cost.

Create the first span or generation from the trace. To create a child node, call span() or generation() on an existing span. Currai assigns the trace ID and parent observation ID when the child is created.

Build a nested trace in TypeScript

This example records retrieval as a span, reranking as a generation inside that span, and the final answer as a generation on the root trace.

code
const trace = currai.trace({
  name: "rag-answer",
  userId,
  sessionId,
  input: { question },
});

const retrieval = trace.span({
  name: "retrieval.search",
  input: { question, limit: 8 },
});

try {
  const candidates = await vectorStore.search(question, { limit: 8 });

  const rerank = retrieval.generation({
    name: "retrieval.rerank",
    model: "gpt-4o-mini",
    input: { question, candidates },
  });

  const ranked = await rerankDocuments(question, candidates);
  rerank.end({ output: ranked });
  retrieval.end({ output: { documentIds: ranked.map((doc) => doc.id) } });

  const answer = trace.generation({
    name: "answer.generate",
    model: "gpt-4o",
    input: { question, context: ranked },
  });

  const response = await generateAnswer(question, ranked);
  answer.end({ output: response.text, usage: response.usage });
  trace.update({ output: response.text });
} catch (error) {
  retrieval.end({
    level: "ERROR",
    statusMessage: error instanceof Error ? error.message : String(error),
  });
  throw error;
} finally {
  await currai.flushAsync();
}

The trace appears with this shape:

code
rag-answer (trace)
 retrieval.search (span)
    retrieval.rerank (generation)
 answer.generate (generation)

The final answer is not a child of retrieval because it represents the next top-level stage in the request. The reranker is a child of retrieval because it is part of choosing documents.

Build the same tree in Python

The Python SDK uses the same data model and snake_case field names.

code
trace = currai.trace(
    name="rag-answer",
    user_id=user_id,
    session_id=session_id,
    input={"question": question},
)

retrieval = trace.span(
    name="retrieval.search",
    input={"question": question, "limit": 8},
)

try:
    candidates = vector_store.search(question, limit=8)

    rerank = retrieval.generation(
        name="retrieval.rerank",
        model="gpt-4o-mini",
        input={"question": question, "candidates": candidates},
    )
    ranked = rerank_documents(question, candidates)
    rerank.end(output=ranked)
    retrieval.end(output={"document_ids": [doc.id for doc in ranked]})

    answer = trace.generation(
        name="answer.generate",
        model="gpt-4o",
        input={"question": question, "context": ranked},
    )
    response = generate_answer(question, ranked)
    answer.end(output=response.text, usage=response.usage)
    trace.update(output=response.text)
except Exception as error:
    retrieval.end(level="ERROR", status_message=str(error))
    raise
finally:
    currai.flush()

End every node where the work finishes

Call end() on each span and generation after its operation completes. This records the output and end time on the correct node. End a parent span after its children finish so its duration covers the complete nested operation.

For failed work, end the active node with level: "ERROR" and a useful status message before rethrowing. The failed node then stays in the trace tree with the context that led to the error.

Short-lived scripts, jobs, and serverless handlers must flush before the process exits. Use await currai.flushAsync() in TypeScript or currai.flush() in synchronous Python code. A missing flush can make a correctly built tree appear incomplete because queued events never reached the ingestion endpoint.

Nest tools and agent loops

A span can contain more spans and generations, so the same pattern works for an agent loop:

code
const iteration = trace.span({ name: "agent.iteration", input: state });
const decision = iteration.generation({
  name: "agent.choose-tool",
  model: "gpt-4o-mini",
  input: state,
});
decision.end({ output: toolChoice });

const tool = iteration.span({
  name: `tool.${toolChoice.name}`,
  input: toolChoice.arguments,
});
tool.end({ output: toolResult });
iteration.end({ output: { tool: toolChoice.name } });

Use stable, descriptive names such as retrieval.search, tool.weather, or agent.choose-tool. Put request-specific values in input or metadata instead of the name. Stable names make latency and error comparisons useful across traces.

OpenTelemetry parent context

If your service already uses OpenTelemetry, create spans inside the active OTel context and export them to Currai. OTel propagates the parent context for you, so you do not need to rebuild the tree with Currai SDK objects. Avoid instrumenting the same operation with both approaches unless you intend to store two copies.

See OpenTelemetry ingestion for exporter configuration and GenAI attribute mapping.

Troubleshoot a flat or incomplete tree

If every observation appears at the root, check that child nodes are created from the parent span rather than from the trace. For example, use retrieval.generation(...), not trace.generation(...), when the model call is part of retrieval.

If a child is missing, confirm that its end() call runs on success and error paths, then confirm the client flushes before shutdown. If durations look wrong, end child nodes before their parent. For more ingestion checks, see Troubleshooting.

Next: track token usage and cost on generations.