Traffic
OpenTelemetry
Duet Machines accepts OTLP/HTTP traces that follow the GenAI semantic conventions, protobuf or JSON and optionally gzip, at /api/otlp/v1/traces. There is no gRPC.
#Configure the exporter
The x-duet-agent header names the agent. Set the exporter's environment, with your instrumentation recording message content:
# Duet Machines reads traces only. export OTEL_EXPORTER_OTLP_ENDPOINT=https://platform.duetmachines.com/api/otlp export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20$DUET_API_KEY,x-duet-agent=<agent uid>" export OTEL_METRICS_EXPORTER=none export OTEL_LOGS_EXPORTER=none # Record prompts and completions on spans (the OpenTelemetry GenAI instrumentations leave them out by default). export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY
Send one span to test the endpoint and key:
curl -X POST https://platform.duetmachines.com/api/otlp/v1/traces \
-H "Authorization: Bearer $DUET_API_KEY" -H "x-duet-agent: <agent uid>" \
-H "Content-Type: application/json" \
-d '{"resourceSpans": [{"scopeSpans": [{"spans": [{
"traceId": "0af7651916cd43dd8448eb211c80319c", "spanId": "b7ad6b7169203331", "name": "chat",
"startTimeUnixNano": "1791900000000000000", "endTimeUnixNano": "1791900002000000000",
"attributes": [
{"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
{"key": "gen_ai.conversation.id", "value": {"stringValue": "conv_8f2a"}},
{"key": "duet.conversation.kind", "value": {"stringValue": "conversation"}},
{"key": "gen_ai.input.messages", "value": {"stringValue": "[{\"role\": \"user\", \"parts\": [{\"type\": \"text\", \"content\": \"Can I move my payment to Friday?\"}]}]"}},
{"key": "gen_ai.output.messages", "value": {"stringValue": "[{\"role\": \"assistant\", \"parts\": [{\"type\": \"text\", \"content\": \"Yes, to Friday the 16th.\"}]}]"}}
]}]}]}]}'#How spans become conversations
Spans are grouped into conversations by gen_ai.conversation.id, or by trace id when a span has none. A model call's input messages hold the history it was given, so the conversation is the input and output of its model span that ended last; a span that ended earlier, even one sent later, never replaces it. A tool call becomes a turn once a later model span holds its response, with status executed.
A conversation ends when a span grouped with it carries duet.conversation.ended true, so it is judged now; otherwise after 30 minutes without a new turn. Set it on the last model span, or send a span of its own when the conversation is over (a model span that changes the turns afterwards reopens it):
from opentelemetry import trace
# When the conversation is over: a span with its id and the end, exported with the rest.
with trace.get_tracer("agent").start_as_current_span(
"conversation end",
attributes={"gen_ai.conversation.id": "conv_8f2a", "duet.conversation.ended": True},
):
passEach span that cannot be read is refused on its own, and the response's partialSuccess counts them and names each span under its reason; nothing is guessed. A request that is not OTLP answers 400, an unknown content type 415, a body over 10 MB after gunzip 413, and an agent outside the key's workspace 404.
#Attributes read
gen_ai.operation.name- Required. chat, text_completion or generate_content: a model call, which times the agent turns it wrote (its end) and the customer turn it answered (the earliest start of a call answering it). execute_tool: a tool call's time. Other spans (agents, HTTP) carry no conversation and are refused.
gen_ai.conversation.id- external_id. Without it, the trace id groups the conversation; a tool span needs the same grouping.
gen_ai.tool.call.id- Required on execute_tool spans: the tool call it ran, whose turn takes the span's end as its at and its length as duration_seconds, and status failed when the span's status is ERROR. A tool span before any model span of its conversation is refused.
gen_ai.input.messages- Required. The turns before this call: user text is a customer turn, assistant text an agent turn, and an assistant tool_call answered by a later tool_call_response a tool turn (status executed, the response as its result); a report_trouble call is a self-report. System messages join the instructions.
gen_ai.output.messages- Required. The turns this call produced, read as the input messages.
gen_ai.system_instructions- config.instructions, before system messages: a JSON array of text parts per the GenAI semantic conventions, e.g. [{"type": "text", "content": "You are Riley."}], not plain text.
gen_ai.tool.definitions- config.tools. A config snapshot needs instructions and this attribute; [] records an agent without tools.
duet.config.variables- config.variables: an object of string values, this conversation's value for each {{name}} the instructions keep as a placeholder. Only with a config snapshot.
duet.config.non_identifying- config.non_identifying: an array of placeholder names whose values identify no one, kept as sent in reproductions. Only with a config snapshot.
user.id- user_id.
duet.metadata.<key>- metadata: <key> with the attribute's string value, as JSON ingest's metadata (filters, experiments, exclusion rules); those of the model span that ended last.
gen_ai.request.model- The model the span's usage is priced at, from Duet Machines' list-price table; on the span carrying the snapshot, also config.model (a new model records a switch in the agent's Models).
gen_ai.usage.input_tokens- With output tokens and the model, the span's cost. The conversation costs the sum of its model spans; one span without usage, or on a model without a list price, leaves the conversation's cost unknown.
gen_ai.usage.output_tokens- See input tokens.
gen_ai.usage.cache_read.input_tokens- Of the input tokens (which include them, per the GenAI conventions), those served from the provider's cache, priced at the model's cached rate; without it the span's input is priced at the full input rate.
service.version- Resource. agent_version.
duet.conversation.kind- kind: check marks a connection check, listed but never judged or counted; conversation when absent.
duet.conversation.ended- true ends the conversation now, so it is judged without waiting for the 30-minute idle rule. Read on any span grouped with it (a model span, or a span of its own carrying only this and the grouping); a model span that changes its turns afterwards reopens it.
Messages, system instructions and tool definitions use the GenAI semantic conventions' JSON shapes, as structured attributes or as their JSON text.
#OpenInference and OpenLLMetry
Spans written in another convention are read by its own attributes and translated to the GenAI attributes above, which then apply as written (duet.* attributes and service.version included). Each span is read in exactly one convention: one carrying OpenInference and GenAI message attributes together is refused, as is Vercel AI SDK telemetry (ai.* attributes; record those conversations with recordMessages instead), and any value in another shape is refused naming the attribute.
OpenInference (Arize Phoenix, Arize AX and their instrumentations), chosen by openinference.span.kind:
openinference.span.kind- Required. LLM: a model call (gen_ai.operation.name chat). TOOL: a tool call's time (execute_tool). Other kinds (CHAIN, AGENT, RETRIEVER, ...) carry no conversation and are refused, unless they only carry duet.conversation.ended.
session.id- gen_ai.conversation.id: external_id; the trace id groups without it.
llm.input_messages.<i>.message.*- Required. gen_ai.input.messages: message.role (system, developer, user, assistant, tool), message.content or the text parts of message.contents.<j>.message_content.*, message.tool_calls.<j>.tool_call.id/function.name/function.arguments, and message.tool_call_id on tool messages, whose content is the call's result.
llm.output_messages.<i>.message.*- Required. gen_ai.output.messages, read as the input messages.
llm.tools.<i>.tool.json_schema- gen_ai.tool.definitions. With no tools listed the span has no config snapshot: OpenInference cannot say an agent has none.
tool.id- gen_ai.tool.call.id: required on TOOL spans.
user.id- user_id.
llm.model_name- gen_ai.request.model.
llm.token_count.prompt- gen_ai.usage.input_tokens.
llm.token_count.completion- gen_ai.usage.output_tokens.
OpenLLMetry (Traceloop) before it wrote the GenAI message attributes, chosen by indexed gen_ai.prompt.<i> or gen_ai.completion.<i> attributes; its other attributes (gen_ai.conversation.id, gen_ai.request.model, user.id) are read as GenAI's. Current OpenLLMetry and the OpenTelemetry GenAI instrumentations write the attributes above directly; developer messages count as system messages.
gen_ai.prompt.<i>.*- Required. gen_ai.input.messages: role (system, developer, user, assistant, tool), content (read verbatim as text), tool_calls.<j>.id/name/arguments, and tool_call_id on tool messages, whose content is the call's result.
gen_ai.completion.<i>.*- Required. gen_ai.output.messages, read as the prompt messages.
llm.request.type- chat: gen_ai.operation.name chat when that is absent; other types are refused.
llm.request.functions.<i>.*- gen_ai.tool.definitions: name, description and parameters (JSON). With none listed the span has no config snapshot.
gen_ai.usage.prompt_tokens- gen_ai.usage.input_tokens.
gen_ai.usage.completion_tokens- gen_ai.usage.output_tokens.