Skip to main content
An eval took 40 seconds, and you want to know where the time went: was the model slow to think, or did a tool call get stuck? The terminal only tells you how long the whole turn took. A call waterfall answers that. In niceeval view it lists every model call and tool execution turn by turn, with each one’s duration and tokens. If your application already sends OTel traces, you already have this data. AI SDK telemetry, LangGraph traces exported to LangSmith, OpenLLMetry or OpenInference auto-instrumentation, and your own instrumentation following GenAI semantics all count. Have the application send a copy of its spans to NiceEval too. No business code in the application changes, so this is still a non-invasive integration; see Tier. The waterfall is only for viewing and does not take part in scoring. Assertions such as t.calledTool and t.maxTokens still read the events and usage the Adapter returns from send. Missing or late spans only make the waterfall incomplete; they never change pass or fail.

Where spans come from and where they go

When evals run, NiceEval starts an OTLP receiver on the local machine. Spans sent by the application are grouped by send turn, normalized to GenAI semantics, and saved with the Attempt’s results; niceeval view then draws the waterfall from them. Spans do not take part in assertions and do not affect the Agent’s behavior, execution errors, or verdict. Missing instrumentation, late spans, or a lost batch only make the waterfall incomplete.

Send spans to NiceEval

The integration has three changes: the Adapter carries each turn’s trace header, the application knows where to send, and the application’s instrumentation turns on export. 1. The Adapter carries traceparent. Write send as usual with the same event mapping, plus one line: send this turn’s traceparent to the application with the request.
With uiMessageStreamAgent you do not need this line; it adds ctx.telemetry.headers to the request headers automatically. 2. Tell the application where to send. The receiver address is configured when the application starts and does not go through send. A standard OTel SDK reads the OTEL_* environment variables only once, at process startup. Choose by how the application runs:
  • A service you start yourself that keeps running (most common): fix the receiver port in niceeval.config.ts. Writing this configuration turns on OTel integration:
    Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces once when the service starts, and you never need to change it no matter how many evals you run. When forwarding through an OTel Collector, point it at this address too. Because the port is fixed, only one niceeval process can run on the same machine at a time. When another process holds the port, NiceEval reports an error directly and tells you to pick a free port. The receiver address given to the application defaults to 127.0.0.1. Only when the application has to go through a tunnel or other routing to reach the machine running NiceEval, use host to write that address, for example telemetry: { host: "otel.internal", port: 4318 }. A Docker Sandbox Agent starts the receiver inside the same Sandbox by default, so the container does not need to connect back to the host. OTLP receiving is configured only through these two fields and does not read environment variables.
  • A child process started by NiceEval (CLI-style Agents): nothing to do. Put ctx.telemetry.env (standard OTEL_* environment variables, ready to spread) into the process environment. Each run is a new process, so it reads this run’s address.
3. Turn on export in the application’s instrumentation. Add a few lines of configuration for the instrumentation library you use:
The official OTel integration is recommended (@ai-sdk/otel, which produces standard GenAI semantics). The older experimental_telemetry (ai.*) can also be drawn:
The exporter uses the standard OTel Node SDK, with the endpoint pointing to NiceEval (injected env or fixed port). Runnable examples: application-side instrumentation in examples/zh/origin/ai-sdk-v7 (src/backend/otel.ts, official @ai-sdk/otel), and the full eval project after integration in examples/zh/tier1/ai-sdk-v7.

Attribute spans to Turns

When several evals run in parallel, the same receiver gets spans from several conversations at once. NiceEval sorts them into their turns in two ways:
  • By traceparent (recommended, accurate under concurrency): when send makes the request, spread ctx.telemetry.headers into the request headers so each turn carries a new traceparent. When the application’s instrumentation supports trace context propagation (all standard OTel HTTP server instrumentation does), this turn’s spans attach under the trace NiceEval provided and match exactly by traceId.
  • By time (fallback): when the application does not propagate trace context, attribution uses the start and end times of send. This is only reliable when turns run one at a time, so NiceEval runs this Agent’s turns one at a time and notes it in the logs, instead of mixing spans from different conversations. Once traceparent is confirmed to work, concurrency resumes automatically.
The application must export spans promptly. Use SimpleSpanProcessor, or flush at the end of each turn. BatchSpanProcessor buffers for a while before sending, so spans can arrive late into the next turn; a waterfall that occasionally misses its tail is usually caused by this.

Keep your existing OTel backend and send to both

The application most likely already sends traces to its own observability backend, such as Langfuse, SigNoz, or a production collector. Connecting NiceEval does not require switching backends or a second set of instrumentation. A TracerProvider can have several SpanProcessors, sending the same spans to two places:
When changing application code is inconvenient, forward through an OTel Collector: the application sends only to the collector, and the collector has two exporters, one pointing to your backend and one to NiceEval’s fixed address. The cost is one more component to maintain.

Control waterfall content with semantic mapping

Whether a span shows in the waterfall as a model call, a tool execution, or a sub-Agent depends on its attributes. Before drawing, NiceEval normalizes each span to GenAI semantics, mainly looking at gen_ai.operation.name (the standard operation name) and the normalized kind: When the application instruments directly according to the GenAI semantic conventions (the “Your own gen_ai instrumentation” tab above), all of this holds automatically. Common spans from AI SDK, LangSmith, and OpenLLMetry / OpenInference are also recognized automatically.

Private instrumentation: write your own mapping

A waterfall full of gray other usually means the application uses its own span names that automatic recognition does not know. There are two ways to fix it:
  • Change the instrumentation (recommended): add a gen_ai.operation.name attribute to spans in the application. It is a one-line change, and your own observability backend benefits too.
  • Write a spanMapper: when changing the application is inconvenient, declare a pure function on the Agent that translates custom spans to the semantics in the table above before drawing. tagSpan writes the category back to the span, only adding attributes and never changing existing ones; heuristicTag is the same logic automatic recognition uses. Both are exported from niceeval/adapter:
mapCodexSpans, exported from niceeval/adapter, is a spanMapper implementation you can use as a reference. spanMapper only affects the waterfall. A wrong mapping only makes categories or colors inaccurate; it never affects assertions.

Check the result

After an eval runs, open the browser with pnpm exec niceeval view and go into an Attempt to see each turn’s waterfall. In the terminal, use pnpm exec niceeval show @<locator> to see the Attempt’s results and log notes.
  • The waterfall is empty: not a single span arrived during the whole run, usually because the application did not reach the receiver address, for example the environment variable was not set, or the service was not restarted after a config change. NiceEval notes this in the logs, and assertions are judged as usual.
  • The waterfall occasionally misses its tail: first check whether the application uses BatchSpanProcessor; see “Attribute spans to Turns” above.
  • All spans show as other: see “Private instrumentation: write your own mapping” above.

What OTel integration does not handle

  • All assertion data comes from send. Tool calls must be mapped into events (with a built-in converter or handwritten mapping; see Write send), and usage goes in the return value of send. Data that appears only in spans and not in events does not take part in assertions.
  • Multi-turn sessions and HITL still happen in send. Spans do not express “waiting for human input”, and session continuation is decided by the application interface (for session continuation see Write send; for HITL see HITL).
  • Connect Your Agent: send event mapping and the data source for assertions.
  • Write send: the full tutorial for a handwritten Adapter; step six is the Adapter-side configuration for this page.
  • Events reference: the event structure assertions read.