ProbeRun

Docs

Import a trace from OpenTelemetry GenAI, Phoenix or Langfuse

probe import turns one exported agent run into a ProbeRun Fixture plus a drafted Scenario, so a run you saw in your observability tool becomes a regression test. It reads three formats, and you always name the one you have with --source; nothing is sniffed:

--source What it reads
openinference Collector OTLP/JSON (resourceSpans[].scopeSpans[].spans[], lowerCamelCase names, hex ids) carrying OpenInference span attributes (openinference.span.kind)
genai The same collector OTLP/JSON envelope carrying the OpenTelemetry GenAI semantic conventions (gen_ai.operation.name), as written by the OpenTelemetry OpenAI instrumentation and by OpenLLMetry
langfuse One trace saved from Langfuse’s public API (no collector needed)

Any other name is refused with unknown_source and the list above:

error: unknown_source: `langsmith` is not a source this build reads; supported: openinference, genai, langfuse
You use Route Status
An OpenTelemetry GenAI instrumentor (OpenTelemetry’s OpenAI instrumentation, OpenLLMetry) Send spans to an OpenTelemetry Collector with a file exporter, then --source genai Import run this session on real captures from both instrumentors
An OpenInference instrumentor Same collector route, --source openinference Import run this session
Phoenix Rewrap the span-search response with examples/trace-import/phoenix_to_otlp.py Script and import run against a sample in Phoenix’s documented shape; the Phoenix request itself was not run against a live Phoenix
Langfuse Save one trace’s observations with curl, then --source langfuse Import run on a sample in Langfuse’s documented response shape; the curl was not run against a live Langfuse

The full reference for probe import (gaps, coverage, every refusal) is in docs/SETUP.md.

OpenTelemetry GenAI conventions

The GenAI semantic conventions are what OpenTelemetry-native instrumentors emit: gen_ai.operation.name on every span, gen_ai.tool.name on tool spans, and message content in gen_ai.input.messages / gen_ai.output.messages. The conventions are still in Development and have renamed attributes before, so ProbeRun reads one pinned revision and records it in every Fixture it writes.

1. Turn message content on, or the import has tool names but no text and no prompt. Content is opt-in in the conventions:

Instrumentor Content on
OpenTelemetry OpenAI (opentelemetry-instrumentation-openai-v2) OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental and OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY
OpenLLMetry (opentelemetry-instrumentation-openai) On by default; TRACELOOP_TRACE_CONTENT=false turns it off

Neither instrumentor writes agent or tool spans: by the conventions those belong to your application. If your framework does not write them, open an execute_tool span with gen_ai.operation.name=execute_tool and gen_ai.tool.name=<tool> around each tool call, and set an OK status on your run’s root span when it finishes. examples/trace-import/genai_capture_agent.py shows both.

2. Write the spans to a file with an OpenTelemetry Collector file exporter, as in the Langfuse tee below without the Langfuse leg, or with examples/trace-import/collector-tee.yaml if you also send them to Langfuse.

3. Import one trace.

probe import --source genai --input trace.json
imported_with_gaps
  fixtures/builder/imported-genai-48727a88be79
  gap: writer/no_filesystem_evidence x1

A collector .jsonl works the same way; with several traces in it the import refuses with multiple_traces and lists their ids, and --trace-id picks one:

probe import --source genai --input traces.jsonl --trace-id 48727a88be7959a999b9b3ac7f6ab818

What you see when content was off:

imported_with_gaps
  fixtures/builder/imported-genai-9268487a38fa
  gap: adapter/content_not_recorded x2
  gap: mapping/no_user_prompt x1
  gap: mapping/input_history_not_recorded x1
  gap: writer/no_filesystem_evidence x1

The model calls are still Steps, empty; the tool Steps keep their names, so the tool path is the same as with content on. No prompt is invented: write it into the drafted scenario.yaml yourself. With content on, every customer turn of a multi-turn conversation is imported as its own user Step, rebuilt from each model call’s input history, so the Trajectory reads as the chat it was; a later call that recorded no input history is the input_history_not_recorded gap above rather than a guessed turn.

Other things the GenAI import names rather than guesses:

  • An execute_tool span with no gen_ai.tool.name is reported as unnamed_tool_record; it is never named from its span name, which the conventions define as a display string.
  • An operation with no rule (the memory operations, fetch_response, or your own value) is reported as unmapped_record_kind with the value it declared.
  • Content in older encodings (indexed gen_ai.prompt.N.* attributes, or span events such as gen_ai.user.message) is not read and is reported as legacy_encoding_not_read. Neither instrumentor above writes them at the versions tested.
  • A trace without any gen_ai.operation.name is refused with missing_genai_marker. An OpenInference trace is refused under --source genai, and a GenAI trace under --source openinference (missing_openinference_marker).

probe triage reads the same sources, so you can rank a folder of exports first:

probe triage --source genai traces/

Phoenix

Phoenix stores spans with their OpenInference attributes, so the content is already right; only the envelope differs.

1. Fetch the spans of one trace. Phoenix’s span search returns spans in OTLP structure, filtered by trace id:

curl -s "$PHOENIX_URL/v1/projects/$PHOENIX_PROJECT/spans/otlpv1?trace_id=$TRACE_ID&limit=1000" \
  -H "Authorization: Bearer $PHOENIX_API_KEY" > phoenix-spans.json

Not yet exercised against a live Phoenix instance. The path, parameters and response schema follow the Phoenix REST reference, https://arize.com/docs/phoenix/sdk-api-reference/rest-api/api-reference/spans/search-spans-with-simple-filters-no-dsl, fetched 2026-10-02. The Authorization header is only needed when your Phoenix has authentication on.

What comes back is a page, {"data": [span, ...], "next_cursor": ...}, with snake_case field names (trace_id, start_time_unix_nano, string_value) and no resourceSpans/scopeSpans wrapper.

2. Rewrap it as collector OTLP/JSON. The script changes the envelope and the field names and nothing else; it refuses a response that is one page of several or that holds more than one trace.

python3 examples/trace-import/phoenix_to_otlp.py phoenix-spans.json > trace.json

3. Import it.

probe import --source openinference --input trace.json
imported_with_gaps
  fixtures/builder/imported-openinference-ffadc4f02108
  gap: writer/no_filesystem_evidence x1

If the import says unbounded_capture: the root span’s status is unset (Phoenix returns "code": 0), so the export does not show that the run finished. The error names the fix: set a status on the root span where your framework closes the run, then export again. To import it anyway, add --allow-unbounded-capture; the import is written, the override is recorded in the Fixture, and the exit stays 1.

Langfuse

Langfuse exports JSON from its public API, so there are two routes: save one trace straight from the API (no collector, no change to your instrumentation), or keep an OTLP copy of your spans on the way into Langfuse.

Direct: save one trace from the API

1. Save the trace’s observations. Langfuse v4 reads traces through the observations API, filtered by trace id. Ask for the io and metadata field groups, or the import has no text and only display names for OTel-ingested tools:

curl -s -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" \
  "$LANGFUSE_HOST/api/public/v2/observations?traceId=$TRACE_ID&fields=core,basic,io,metadata&limit=1000" \
  > langfuse-trace.json

(unverified: not run against a live Langfuse. The path, the fields groups, the 1000 limit and basic auth with the project’s public and secret key follow Langfuse’s OpenAPI spec at release v4.59.0, fetched 2026-10-15.) Langfuse deprecated the older GET /api/public/traces/<id> and removes it from Langfuse Cloud on 2026-11-16; a file saved from it is still read.

The import refuses, by name and without writing anything, a response that is not one whole trace: a page with a next cursor (raise limit, or fetch every page into one file), observations from more than one trace, or a page of traces.

2. Import it. Langfuse records no success status for a trace, so the export cannot show that the run finished, and the import refuses it as unbounded:

probe import --source langfuse --input langfuse-trace.json
error: unbounded_capture: this Source trace does not evidence that the run finished (boundary: not_recorded) -- Langfuse records no success status for a trace or an observation, so its export cannot distinguish a run that finished from one that was killed. Import it anyway: --allow-unbounded-capture -- ...

Add --allow-unbounded-capture to write it; the override is recorded in the Fixture and the exit stays 1:

probe import --source langfuse --input langfuse-trace.json --allow-unbounded-capture
imported_unbounded
  fixtures/builder/imported-langfuse-7a38edd8123d
  gap: adapter/incomplete_child_record x1
  gap: mapping/unmapped_record_kind x1
  gap: eligibility/boundary_override x1
  gap: writer/no_filesystem_evidence x1

Generations become model Steps, tools and retrievers become named tool Steps, and spans, agents, chains, embeddings, guardrails and evaluators are structure. An OTel-ingested tool is named by the gen_ai.tool.name it carried, not by Langfuse’s display name (running tool: lookup_order).

Collector tee: keep an OTLP copy

If your spans already go to Langfuse over OTLP, an OpenTelemetry Collector can forward them to Langfuse and also write them to a file, which you import with --source genai or --source openinference, whichever conventions your instrumentor writes.

1. Run the collector with examples/trace-import/collector-tee.yaml (it sets the EU endpoint; change it for your region). LANGFUSE_AUTH is base64 of <public key>:<secret key>:

cd examples/trace-import
mkdir -p otel-out
export LANGFUSE_AUTH=$(printf '%s' "pk-lf-...:sk-lf-..." | base64 -w 0)
docker run --rm --user "$(id -u)" -p 4318:4318 -e LANGFUSE_AUTH \
  -v "$PWD/collector-tee.yaml:/etc/otelcol-contrib/config.yaml:ro" \
  -v "$PWD/otel-out:/out" otel/opentelemetry-collector-contrib:0.161.0

Point your agent’s OTLP exporter at http://localhost:4318 instead of Langfuse.

2. Pick a trace. The file holds one JSON document per batch, which can mix traces, so list the root spans:

jq -r '.resourceSpans[].scopeSpans[].spans[] | select((.parentSpanId // "") == "") | "\(.traceId)  \(.name)"' otel-out/traces.jsonl
ffadc4f02108444f46533b88280a00ef  agent-run
d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4  agent-run

3. Import that trace. probe import reads the collector’s .jsonl directly. With one trace in the file it is imported; with several, it refuses with multiple_traces, lists their ids, and --trace-id picks one:

probe import --source openinference --input otel-out/traces.jsonl --trace-id ffadc4f02108444f46533b88280a00ef
imported_with_gaps
  fixtures/builder/imported-openinference-ffadc4f02108
  gap: writer/no_filesystem_evidence x1

After the import

The import writes a draft, not a test. Open the drafted scenario.yaml, write the expected_behavior (a trace records what happened, not what should have), review the commented-out expected_path drafted from the trace’s tool calls at the end of the file and delete its leading # if it describes what the agent should do, then add the Scenario to your suite with --write-config probe.yaml. Edit the file in a text editor: a YAML round-trip tool drops comments, and the draft with them. See docs/SETUP.md for the review report (--report) and the coverage check (--only-new-paths).