Duet Machines
Traffic · OpenTelemetry

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},
):
    pass

Each 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.