Skip to main content
A trace is a tree of steps, and every surface that renders or scores it needs to know what kind of step each span is: the Execution Timeline colors and filters by it, Code scorers branch on it, the dashboard’s span buckets count by it, and the RAG judges pull their {context} from it. Spans carry that answer as a stated kind. Whoever produces the span says what it is, the engine resolves it once at ingest, and every reader gets the same answer back on the wire as spanKind. This is the same design as LangSmith’s run_type, Langfuse’s observation type, and OpenInference’s openinference.span.kind.

The vocabulary

Kinds are mostly labels - with a few that carry real semantics:
  • retrieval has a behavioral consequence. The output of every retrieval span in an interaction is what the RAG judges grade against as {context} - a Faithfulness or Context Relevancy scorer reads exactly those chunks.
  • memory is deliberately not retrieval, even though a memory read looks like a lookup. {context} means knowledge the answer should be grounded in; a recalled user preference is state, not grounding - feeding it to a groundedness judge would penalize answers for not citing it. Keeping the kinds apart lets the timeline and scorers treat “consulted the knowledge base” and “remembered the user” as the different acts they are.
  • The bare kind string recall is deliberately NOT an alias: eval harnesses (Ragas, DeepEval) emit per-metric spans kinded recall/precision/faithfulness - evaluator spans about a retrieval metric, not memory ops. Use memory_recall (or state memory) for a recall operation.

How a span gets its kind

Three paths, checked in this order - an earlier one always wins. 1. Stated explicitly. Both the root span and child spans take span_kind:
2. Stamped by the SDK. The helpers that already know what they are say so - you never pass span_kind to these:
One behavioral difference between them: record_memory/trace_memory drop (warned once per process, then at debug) when no span is active, unlike record_tool_call/record_retrieval, which queue onto the next trace. Framework integrations do the same: merged LLM steps arrive as llm, LangChain graph nodes as chain, tool executions as tool, retriever runs as retrieval. 3. Inferred by the engine, as a fallback. A span that never stated a kind - a trace recorded before span kinds existed, or a producer that just doesn’t say - still classifies, by the engine’s inference ladder: a span with a model is llm, one carrying tool calls is tool, the SDK’s auto-generated names (LLM Call N, Retrieval N, Memory ...) are recognized, and everything else is chain. One nuance with a real consequence: a retrieval-named span whose name contains the word memory/memories (retrieve_memories) classifies as memory, so recalled user state never rides into the RAG judges’ {context} - while Retrieval: memoranda index stays a retrieval. Old traces classify exactly as they always did; inference is a best guess, never a fact. Alias spellings from the other products’ vocabularies (OpenInference’s retriever, Langfuse’s generation, every memory_* variant, …) fold onto the kinds above - the table below is the reference, generated from the same alias map the engine runs. Two deliberate absences: UNKNOWN (OpenInference / MLflow’s default) is not an alias - it means “nothing was stated” and falls through to the ladder - and bare recall is not one either, because eval harnesses emit per-metric spans literally kinded “recall” (the retrieval metric), which must never classify as memory (memory_recall works).
A stated kind always beats the ladder. A guardrail implemented as an LLM call carries a model, which the ladder would read as llm - stating span_kind="guardrail" is what keeps it a guardrail. One thing the engine will not infer: that a root span is an agent turn. A flat trace’s root is the LLM call, so root-ness proves nothing - a root that really is an agent turn should say so.

Example: memory steps in a trace

One trace whose steps mix the three step families - a memory read (recalled user state), a knowledge retrieval, and a memory write:
See it from the dashboard:
screenshot of the trace Execution Timeline for the travel-concierge trace - a 'user prefs' Memory step (read), a 'route_search' Retrieval step, and a second 'user prefs' Memory step (write), each with its own colored dot, and the Memory chip active in the kind filter row.

The Execution Timeline: memory read and write steps in their own lane, next to the retrieval step.

Other products’ vocabularies just work

The engine folds every convention it knows onto its own vocabulary, so a span instrumented for another product classifies on arrival with no change by the producer: On the OpenTelemetry ingest path the kind is read from the span’s own attributes, in this order: openinference.span.kind, gen_ai.operation.name, mlflow.spanType, langfuse.observation.type, then gen_ai.tool.name (a span carrying one IS the tool call) and finally the model attribute. Anything already instrumented with OpenInference or the GenAI semconv arrives fully classified. A word the engine does not recognize is ignored, not stored: the span falls back to the inference ladder rather than carrying a “kind” nobody defined.

Kind vs. tool_calls

These answer different questions. span_kind="tool" says this span is one tool execution. The root trace’s flat tool_calls[] list says this interaction made these calls - it is what the built-in Tool failure check, trajectory matching, and the Tool quality column read. The SDK maintains both for you (trace_tool_call writes the child span with its kind and appends to the active span’s tool_calls list); if you build spans by hand, keep doing both.