Skip to Content
DocsObservabilityGenAI semantic conventions

GenAI semantic conventions

Every model-call and tool-execution span emits the OpenTelemetry GenAI semantic-convention attributes (gen_ai.*) 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 # Wire the OTel SDK toward your backend as usual (see /docs/observability), # then pass the adapter — gen_ai.* attributes ride on every span. 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
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

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

Span names stay loom.*. The GenAI spec prefers span names like chat {model} / execute_tool {tool}, but renaming spans would break every existing dashboard and test keyed on loom.model.complete / loom.tool. Loomflow keeps the legacy names and puts the semconv identity in attributes — which is what attribute-keyed backends match on anyway. chat_span_name() / tool_span_name() in loomflow.observability.semconv produce the spec names if a sink or a future naming switch wants them.


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: AnthropicModelanthropic, OpenAIModelopenai.
  3. The model id: a LiteLLM-style provider/model prefix (groq/llama-3.3-70bgroq), 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 — ScriptedModel, 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 — 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}" — spec span name tool_span_name, # "execute_tool {tool}" 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, ))

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 renames upstream, expect a minor-version follow here. The legacy 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 — different consumers, run both.

Last updated on