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)
| 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: all input tokens, cached ones included |
gen_ai.usage.cache_read.input_tokens | post-hoc | int, only when the call read the prompt cache |
gen_ai.usage.cache_write.input_tokens | post-hoc | int, only when the call wrote the prompt cache |
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 |
error.type | post-hoc, only when the call failed or was refused | "permission_denied", "timeout" or "tool_error" |
Run spans (loom.run)
| Attribute | When set | Value |
|---|---|---|
gen_ai.operation.name | at span open | "invoke_agent" |
gen_ai.conversation.id | at span open | the session id |
gen_ai.request.model | at span open | the 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 span | Exported OTel span name | Kind | loom.span.name |
|---|---|---|---|
loom.model.complete / loom.model.stream | chat {model}, e.g. chat claude-opus-4-7 | CLIENT | loom.model.complete / loom.model.stream |
loom.tool | execute_tool {tool}, e.g. execute_tool search | INTERNAL | loom.tool |
loom.run | invoke_agent | INTERNAL | loom.run |
loom.turn, loom.workflow.step | unchanged | INTERNAL | not 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:
- 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 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.