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)
| Attribute | When set | Value |
|---|---|---|
gen_ai.operation.name | at span open | "chat" |
gen_ai.provider.name | at span open | best-effort provider id — see resolution |
gen_ai.request.model | at span open | the model id string (claude-opus-4-7, gpt-4.1-mini, …) |
gen_ai.usage.input_tokens | post-hoc, after the call returns | int |
gen_ai.usage.output_tokens | post-hoc | int |
gen_ai.response.finish_reasons | post-hoc | tuple, e.g. ("stop",) or ("tool_use",); omitted when the provider didn’t report one |
Tool-execution spans (loom.tool)
| Attribute | When set | Value |
|---|---|---|
gen_ai.operation.name | at span open | "execute_tool" |
gen_ai.tool.name | at span open | the tool’s registered name |
gen_ai.tool.call.id | at span open | the 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:
- An explicit
providerattribute 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, … - The adapter’s class name:
AnthropicModel→anthropic,OpenAIModel→openai. - The model id: a LiteLLM-style
provider/modelprefix (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). - The spec fallback
_OTHER— used forScriptedModel/EchoModeland 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.