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_toolspan with nogen_ai.tool.nameis reported asunnamed_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 asunmapped_record_kindwith the value it declared. - Content in older encodings (indexed
gen_ai.prompt.N.*attributes, or span events such asgen_ai.user.message) is not read and is reported aslegacy_encoding_not_read. Neither instrumentor above writes them at the versions tested. - A trace without any
gen_ai.operation.nameis refused withmissing_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).