Skip to main content
OTel integration does not change where assertion data comes from. t.calledTool, t.maxTokens, and timing verdicts all read the Turn (events and usage) your adapter returns from send — see Connect Your Agent. OTel spans power the call waterfall in niceeval view. The waterfall shows model calls, tool execution, duration, and tokens per turn, helping you pinpoint the exact step where an eval failed or a turn slowed down. If your app already emits OTel traces — AI SDK telemetry, LangGraph’s LangSmith export, OpenLLMetry / OpenInference auto-instrumentation, or your own spans following the GenAI semantic conventions — you are already producing the waterfall data: just have the app send a copy of its spans to NiceEval too. Application code stays untouched, and the integration remains non-intrusive (see Tier).

How it works (one paragraph)

At run time NiceEval starts a local OTLP receiver. Spans the app sends are attributed to the corresponding send turn, normalized into GenAI semantics, and written to the Attempt-owned niceeval.telemetry JSONL event channel. A Report decodes and displays the waterfall only when it declares that requirement. Spans never affect assertions — they do not change Agent behavior, execution errors, or Verdicts. Missing instrumentation, late spans, or dropped batches affect that channel’s coverage and waterfall completeness only; they never affect a Verdict.

Wiring it up

1. Adapter side — write send as usual (the event mapping is still your mapping), plus one extra line: forward this turn’s traceparent with the request:
Built-ins do not need this step: uiMessageStreamAgent always merges ctx.telemetry.headers into the request headers automatically. 2. How the endpoint reaches your app. NiceEval’s receiver endpoint is startup-time configuration, never passed through send. Standard OTel SDKs read OTEL_* environment variables only once, at process startup. Pick a configuration approach by deployment shape:
  • Your own long-running service (most common): use fixed-port mode — pin the receiver port in niceeval.config.ts. Writing this config is what turns OTel integration on:
    Configure OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces once when the service starts; it stays valid no matter how many eval runs follow. The trade-off: sharing a port means only one niceeval process can run on the machine at a time; OTel Collector fan-out setups point at this same fixed endpoint. If port is already taken by another process, NiceEval fails immediately with an error telling you to pick a free port — it never fails silently. The receiver hostname reported to your app defaults to 127.0.0.1. Only when your app can already reach the host’s receiver through a controlled tunnel or another reachable route should you report that address via host, e.g. telemetry: { host: "otel.internal", port: 4318 }. Docker Sandbox Agents start the receiver inside the same Sandbox by default, without relying on the container reaching back to the host. These two fields are the only way to configure the OTLP receiver in NiceEval; it does not read environment variables.
  • Child processes / processes NiceEval spawns (CLI-style agents): nothing to do. ctx.telemetry.env (standard OTEL_* environment variables, ready to spread) is injected into the process environment; each run is a fresh process that reads the fresh endpoint.
3. App side — a few lines of configuration, depending on your instrumentation ecosystem:
The official OTel integration (@ai-sdk/otel, which emits standard GenAI semantics) is recommended; the older experimental_telemetry (ai.*) renders too:
The exporter is the standard OTel Node SDK, with the endpoint pointed at NiceEval (the injected env or the fixed port). Runnable examples: app-side instrumentation in examples/zh/origin/ai-sdk-v7 (src/backend/otel.ts, official @ai-sdk/otel); the full eval project after integration in examples/zh/tier1/ai-sdk-v7.

How spans are attributed to turns

When evals run in parallel, one receiver receives spans from multiple sessions at once. NiceEval attributes them to their turns via two paths:
  • traceparent (recommended, concurrency-safe): when send makes its request, spread ctx.telemetry.headers (W3C trace context, a fresh traceparent per turn) into the request headers. If the app’s instrumentation supports context propagation (standard OTel HTTP server instrumentation does), this turn’s spans automatically attach under the trace NiceEval provided, and attribution is exact by traceId.
  • Time window (fallback): when the app does not propagate trace context, spans are attributed by the time window around send. The window is only reliable when turns run serially, so in this case NiceEval executes this agent’s turns serially and says so in the logs — it never silently mixes streams. Once traceparent is confirmed working, concurrency resumes automatically.
Export promptly on the app side: what the waterfall cares about is “this turn’s spans arrive in time”. Use SimpleSpanProcessor (or flush every turn) — BatchSpanProcessor’s buffering makes spans arrive late across turns; when the waterfall occasionally has a missing tail, that is usually why.

Keep your existing OTel backend and dual-send

Your app most likely already sends traces to its own observability backend (Langfuse / SigNoz / a production collector). Connecting NiceEval requires neither switching backends nor a second layer of instrumentation: a TracerProvider supports multiple SpanProcessors — the same batch of spans, two exits:
If touching app code is inconvenient, fan out through an OTel Collector instead — the app sends only to the collector, and the collector configures two exporters (your backend + NiceEval’s fixed endpoint). The cost is one more component to operate.

Controlling waterfall content with semantic mapping

NiceEval normalizes every span into GenAI semantics before drawing the waterfall. The mapping reads gen_ai.operation.name (the standard operation name) and the normalized kind (semantic role): If your app instruments directly per the GenAI semconv (the “Hand-rolled gen_ai” tab above), all of this holds automatically; the common shapes of mainstream formats (AI SDK, LangSmith, OpenLLMetry / OpenInference) are also within the generic fallback’s recognition range.

Private instrumentation: write your own mapping

When your instrumentation has an app-private shape the generic fallback cannot recognize, there are two routes:
  • Fix the instrumentation (recommended): add a gen_ai.operation.name attribute to the spans on the app side — a one-line change, and your own observability backend benefits equally.
  • Write a spanMapper: when touching the app is inconvenient, declare a pure function on the agent that translates private spans into the semantics of the table above before rendering. Both tagSpan (writes the verdict back onto the span; existing attributes are only added to, never changed) and heuristicTag (the generic fallback verdict) are exported from niceeval/adapter:
mapCodexSpans, exported from niceeval/adapter, is a ready-made spanMapper implementation you can use as a reference. A spanMapper only affects observability display; a mapping mistake makes the waterfall’s grouping or coloring inaccurate — it never affects assertions.

Boundaries

  • All assertion data comes from send. Tool calls need to be mapped into events (using a built-in converter or a hand-written mapping, see Write send); usage needs to be included in the send return value. Data that exists in a span but is missing from events never participates in assertions.
  • Multi-turn sessions and HITL are not span concerns. Spans have no “waiting for human input” semantics, and session continuation is application-protocol work — both keep happening in send as usual (session continuation in Write send, the HITL concept in HITL).
  • You get a warning when no spans arrive. A run that produces zero spans overall is usually because the endpoint is not hooked up (env not injected, service not restarted); NiceEval says so in the logs. The waterfall is empty; assertions judge as usual.
  • Connect Your Agent — the send event mapping and where assertion data comes from.
  • Write send — the full tutorial for hand-writing an adapter; step six is this page’s adapter-side wiring.
  • Events reference — the event structure assertions read.