Skip to Content
ObservabilityGenAI semantic conventions

GenAI semantic conventions

The OpenTelemetry GenAI semantic conventions are a standard set of span attribute names for LLM calls (gen_ai.request.model, gen_ai.usage.input_tokens, and so on). Every model-call and tool-execution span in loomflow carries them alongside the framework’s own attributes. Backends that key on these attributes (Langfuse, Datadog LLM Observability, Arize Phoenix, and anything else built against the OTel GenAI spec) recognise your agent traffic out of the box: no span-name mapping, no processor config, no vendor-specific instrumentation.

from loomflow import Agent from loomflow.observability import OTelTelemetry # Configure the OTel SDK for your backend as usual (see /docs/observability). # With no arguments, OTelTelemetry uses the globally registered providers; # pass tracer_provider= / meter_provider= to use specific ones. agent = Agent("...", model="claude-opus-4-7", telemetry=OTelTelemetry())

There is nothing to enable. The attributes are emitted by every sink (InMemoryTelemetry, FileTelemetry, OTelTelemetry, …) because they’re set at the span-emit sites, not in an adapter.


What each span carries

Model-call spans (loom.model.complete / loom.model.stream)

AttributeWhen setValue
gen_ai.operation.nameat span open"chat"
gen_ai.provider.nameat span openbest-effort provider id; see resolution
gen_ai.request.modelat span openthe model id string (claude-opus-4-7, gpt-4.1-mini, …)
gen_ai.usage.input_tokenspost-hoc, after the call returnsint: all input tokens, cached ones included
gen_ai.usage.cache_read.input_tokenspost-hocint, only when the call read the prompt cache
gen_ai.usage.cache_write.input_tokenspost-hocint, only when the call wrote the prompt cache
gen_ai.usage.output_tokenspost-hocint
gen_ai.response.finish_reasonspost-hoctuple, e.g. ("stop",) or ("tool_use",); omitted when the provider didn’t report one

Tool-execution spans (loom.tool)

AttributeWhen setValue
gen_ai.operation.nameat span open"execute_tool"
gen_ai.tool.nameat span openthe tool’s registered name
gen_ai.tool.call.idat span openthe provider-issued tool-call id
error.typepost-hoc, only when the call failed or was refused"permission_denied", "timeout" or "tool_error"

Run spans (loom.run)

AttributeWhen setValue
gen_ai.operation.nameat span open"invoke_agent"
gen_ai.conversation.idat span openthe session id
gen_ai.request.modelat span openthe agent’s model id

The framework’s own attributes (model, turn, session_id, tool, call_id, …) stay on the same spans. The gen_ai.* set is purely additive.

Span names in OpenTelemetry

OTelTelemetry exports these spans under the GenAI spec’s names and kinds. The loomflow name goes into the loom.span.name attribute:

loomflow spanExported OTel span nameKindloom.span.name
loom.model.complete / loom.model.streamchat {model}, e.g. chat claude-opus-4-7CLIENTloom.model.complete / loom.model.stream
loom.toolexecute_tool {tool}, e.g. execute_tool searchINTERNALloom.tool
loom.runinvoke_agentINTERNALloom.run
loom.turn, loom.workflow.stepunchangedINTERNALnot set

Dashboards keyed on OTel span names need updating. Before 0.17 the exported spans were named loom.model.complete, loom.tool and so on. Query loom.span.name to keep matching on the loomflow names. The in-memory, console and file sinks still record spans under their loom.* names, so tests that read InMemoryTelemetry().spans() are unaffected.

An exception inside a span is recorded once, with error.type set to the exception’s class name and an ERROR status. A tool call that fails or is refused without raising gets the same ERROR status from its error.type attribute.


How gen_ai.provider.name is resolved

Best-effort, in order:

  1. An explicit provider attribute on the model adapter (duck-typed), normalised through the spec’s well-known values: "bedrock" → aws.bedrock, "azure" → azure.ai.openai, "vertex_ai" → gcp.vertex_ai, "xai" → x_ai, …
  2. The adapter’s class name: AnthropicModel → anthropic, OpenAIModel → openai.
  3. The model id: a LiteLLM-style provider/model prefix (groq/llama-3.3-70b → groq), then well-known bare-id prefixes (claude-* → anthropic, gpt-* / o3-* → openai, gemini-* → gcp.gemini, deepseek-* → deepseek, grok-* → x_ai, command-* → cohere).
  4. The spec fallback _OTHER, used for ScriptedModel / EchoModel and anything else unrecognised.
from loomflow.observability.semconv import provider_name provider_name(my_model) # "anthropic" | "openai" | "aws.bedrock" | ... | "_OTHER"

Post-hoc usage attributes

Token usage and the finish reason are only known after the model call returns, so they’re set on the still-open span via set_span_attributes(). The helper duck-types against whatever span handle the sink yielded: a set_attribute method wins if present (that’s what a real OTel span exposes), otherwise the span’s mutable attributes dict is updated in place, which is how the built-in capture sinks pick them up at close. MultiTelemetry fans the late additions out to every sink before any of them closes.

On the fast-telemetry path (telemetry=NoTelemetry(), the default) no spans open at all, so the semconv layer costs nothing.


Worked example: assert on gen_ai.* in-process

import asyncio from loomflow import Agent, ScriptedModel, ScriptedTurn, ToolCall, Usage, tool from loomflow.observability import InMemoryTelemetry @tool async def ping() -> str: """Return pong.""" return "pong" async def main(): tel = InMemoryTelemetry() model = ScriptedModel([ ScriptedTurn( tool_calls=[ToolCall(id="c1", tool="ping", args={})], usage=Usage(input_tokens=11, output_tokens=7), ), ScriptedTurn(text="done", usage=Usage(input_tokens=23, output_tokens=5)), ]) agent = Agent("hi", model=model, tools=[ping], telemetry=tel) await agent.run("ping please") model_spans = [s for s in tel.spans() if s.name == "loom.model.complete"] for s in model_spans: assert s.attributes["gen_ai.operation.name"] == "chat" assert s.attributes["gen_ai.request.model"] == "scripted" assert "gen_ai.usage.input_tokens" in s.attributes # set post-hoc (tool_span,) = [s for s in tel.spans() if s.name == "loom.tool"] assert tool_span.attributes["gen_ai.operation.name"] == "execute_tool" assert tool_span.attributes["gen_ai.tool.name"] == "ping" assert tool_span.attributes["gen_ai.tool.call.id"] == "c1" asyncio.run(main())

Runs offline with ScriptedModel and no API key. The same attributes land on loom.model.stream spans when you consume agent.stream(...), and on the spans OTelTelemetry exports to your OTLP endpoint.


The helpers, for custom emit sites

loomflow.observability.semconv is a module of pure functions with no OTel SDK dependency, so you can reuse it in custom architectures or custom sinks:

from loomflow.observability.semconv import ( chat_attrs, # gen_ai.* known before a model call starts usage_attrs, # gen_ai.usage.* + finish_reasons, known after tool_attrs, # gen_ai.* for a tool-execution span set_span_attributes, # duck-typed post-hoc setter chat_span_name, # "chat {model}", the spec span name tool_span_name, # "execute_tool {tool}" otel_span_identity, # (spec name, kind) OTelTelemetry exports a span as inference_metrics, # the gen_ai.client.* metrics for one model call provider_name, # best-effort gen_ai.provider.name ) async with telemetry.trace("my.model.call", **chat_attrs(model)) as span: text, calls, usage, finish = await model.complete(messages) set_span_attributes(span, usage_attrs( usage.input_tokens, usage.output_tokens, finish, cache_read_tokens=usage.cached_input_tokens, cache_write_tokens=usage.cache_write_tokens, ))

Because the span carries gen_ai.operation.name="chat", OTelTelemetry exports it as chat {model} even though you named it my.model.call.

set_span_attributes ignores None spans (the fast-telemetry null context) and drops None values, so the call site never needs guards.


The OTel GenAI spec is Development status. Loomflow implements the current draft; attribute names like gen_ai.provider.name and gen_ai.usage.input_tokens may still evolve upstream before the spec stabilises. The framework tracks the draft; if an attribute is renamed upstream, loomflow follows in a minor version. The loom.* attributes are the stable interface and are not going anywhere.

For the span/metric catalogue and the sink zoo, see Telemetry. For compliance-grade event capture, see the audit log. They serve different consumers, so run both.

Last updated on