> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getusertrace.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Live Traces (OTLP)

> Send production conversations with a standard OpenTelemetry exporter

## Overview

Stream production conversations straight from your agent using the **standard
OpenTelemetry SDK**, with no UserTrace SDK and no transcript uploads. Point an OTLP exporter at
`/v1/traces`, add your API key header, and follow the
[OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/).

Conversations are evaluated automatically as they close, and the results show up in
[Production Results](/api-reference/endpoint/production-sessions).

<Note>
  If you already run OpenTelemetry, you need two things: a second exporter pointed at
  UserTrace, and `gen_ai.conversation.id` on your spans. Everything else is convention you
  may already emit.
</Note>

## Before you start

Choose which evals run on ingested conversations. In the dashboard, open the **Agent
Setup** page and scroll to the bottom, to the **Live traces** section. Until you do,
conversations ingest and close cleanly but nothing is scored.

Two settings there:

| Setting      | Meaning                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------- |
| **Evals**    | Which evals score every ingested conversation. **At most 5**, since each one is a model call on *every* conversation |
| **Run name** | Base name for the daily run conversations are grouped under. Defaults to `Live traces`                               |

## Sending spans

```
POST /v1/traces
```

```http theme={null}
x-usertrace-api-key: ut_live_YOUR_KEY_HERE
Content-Type: application/json
```

Accepts `application/json` and `application/x-protobuf`, both of which stock OTel
exporters send unmodified, and `Content-Encoding: gzip`. Returns `200` with an empty OTLP
`ExportTraceServiceResponse`, which is what exporters expect to parse. Any other content
type is `415`.

<CodeGroup>
  ```python Python theme={null}
  # pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
  import json, uuid
  from opentelemetry import trace
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
  from opentelemetry.sdk.resources import Resource
  from opentelemetry.sdk.trace import TracerProvider
  from opentelemetry.sdk.trace.export import BatchSpanProcessor

  provider = TracerProvider(resource=Resource.create({
      "service.name": "support-agent",
      "deployment.environment.name": "production",
  }))
  provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
      endpoint="https://api.getusertrace.com/v1/traces",
      headers={"x-usertrace-api-key": "ut_live_YOUR_KEY_HERE"},
  )))
  trace.set_tracer_provider(provider)
  tracer = trace.get_tracer("support-agent")

  conversation_id = f"conv_{uuid.uuid4().hex[:8]}"
  history = []

  def record_turn(user_text, assistant_text, usage):
      """One turn = one span. Start it before the LLM call, end it after."""
      history.append({"role": "user", "parts": [{"type": "text", "content": user_text}]})
      with tracer.start_as_current_span("chat gpt-4o") as span:
          span.set_attribute("gen_ai.conversation.id", conversation_id)
          span.set_attribute("gen_ai.operation.name", "chat")
          span.set_attribute("gen_ai.request.model", "gpt-4o")
          span.set_attribute("gen_ai.response.model", usage["model"])
          span.set_attribute("gen_ai.usage.input_tokens", usage["input_tokens"])
          span.set_attribute("gen_ai.usage.output_tokens", usage["output_tokens"])
          span.set_attribute("gen_ai.input.messages", json.dumps(history))

          answer = {"role": "assistant",
                    "parts": [{"type": "text", "content": assistant_text}],
                    "finish_reason": "stop"}
          span.set_attribute("gen_ai.output.messages", json.dumps([answer]))
          history.append(answer)
  ```

  ```typescript Node theme={null}
  // npm i @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http \
  //       @opentelemetry/api @opentelemetry/resources
  import { trace } from '@opentelemetry/api';
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
  import { resourceFromAttributes } from '@opentelemetry/resources';
  import { NodeSDK } from '@opentelemetry/sdk-node';

  const sdk = new NodeSDK({
    resource: resourceFromAttributes({
      'service.name': 'support-agent',
      'deployment.environment.name': 'production',
    }),
    traceExporter: new OTLPTraceExporter({
      url: 'https://api.getusertrace.com/v1/traces',
      headers: { 'x-usertrace-api-key': 'ut_live_YOUR_KEY_HERE' },
    }),
  });
  sdk.start();

  const tracer = trace.getTracer('support-agent');
  const conversationId = `conv_${crypto.randomUUID().slice(0, 8)}`;
  const history: unknown[] = [];

  // One turn = one span. Start it before the LLM call, end it after.
  export async function recordTurn(userText: string, call: () => Promise<{
    text: string; model: string; inputTokens: number; outputTokens: number;
  }>) {
    history.push({ role: 'user', parts: [{ type: 'text', content: userText }] });

    return tracer.startActiveSpan('chat gpt-4o', async (span) => {
      span.setAttributes({
        'gen_ai.conversation.id': conversationId,
        'gen_ai.operation.name': 'chat',
        'gen_ai.request.model': 'gpt-4o',
        'gen_ai.input.messages': JSON.stringify(history),
      });

      const res = await call();
      const answer = {
        role: 'assistant',
        parts: [{ type: 'text', content: res.text }],
        finish_reason: 'stop',
      };
      history.push(answer);

      span.setAttributes({
        'gen_ai.response.model': res.model,
        'gen_ai.usage.input_tokens': res.inputTokens,
        'gen_ai.usage.output_tokens': res.outputTokens,
        'gen_ai.output.messages': JSON.stringify([answer]),
      });
      span.end();
      return res;
    });
  }
  ```

  ```bash curl theme={null}
  curl -X POST "https://api.getusertrace.com/v1/traces" \
    -H "x-usertrace-api-key: ut_live_YOUR_KEY_HERE" \
    -H "Content-Type: application/json" \
    -d '{
      "resourceSpans": [{
        "resource": {"attributes": [
          {"key": "service.name", "value": {"stringValue": "support-agent"}}
        ]},
        "scopeSpans": [{
          "spans": [{
            "traceId": "4b1e9a7c3d2f48619ae05c7b1f83d240",
            "spanId": "a1b2c3d4e5f60718",
            "name": "chat gpt-4o",
            "startTimeUnixNano": "1789034400000000000",
            "endTimeUnixNano": "1789034402150000000",
            "attributes": [
              {"key": "gen_ai.conversation.id", "value": {"stringValue": "conv_8f14c2"}},
              {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
              {"key": "gen_ai.input.messages", "value": {"stringValue":
                "[{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"content\":\"Where is my order A-4471?\"}]}]"}},
              {"key": "gen_ai.output.messages", "value": {"stringValue":
                "[{\"role\":\"assistant\",\"parts\":[{\"type\":\"text\",\"content\":\"It shipped on 8 Sep.\"}],\"finish_reason\":\"stop\"}]"}}
            ]
          }]
        }]
      }]
    }'
  ```
</CodeGroup>

<Note>
  In OTLP/JSON, `traceId` and `spanId` are **hex strings**, a deliberate deviation from
  the usual protobuf-to-JSON mapping, which would base64 them. Stock exporters get this
  right; only hand-built payloads need care.
</Note>

## What we read

| Attribute                                               | Purpose                                                                                      |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `gen_ai.conversation.id`                                | Groups turns into one conversation. **Required**, or send the `X-Conversation-Id` header     |
| `gen_ai.operation.name`                                 | `chat` = a turn, `execute_tool` = a tool call, `invoke_agent` = a turn wrapper. **Required** |
| `gen_ai.input.messages` / `gen_ai.output.messages`      | The turn's content                                                                           |
| `gen_ai.request.model` / `gen_ai.response.model`        | Model attribution                                                                            |
| `gen_ai.usage.input_tokens` / `output_tokens`           | Token counts                                                                                 |
| `gen_ai.tool.name` / `.call.arguments` / `.call.result` | Tool call detail                                                                             |
| span `status`                                           | A non-OK status marks the turn failed                                                        |

Span ids and timestamps come free from OTLP; you never set them yourself.

<Info>
  **Spans without `gen_ai.operation.name` are ignored.** You can point an existing
  TracerProvider at UserTrace and your HTTP, database and framework spans pass through
  harmlessly.
</Info>

## Your own metadata

Any attribute outside the `gen_ai.*` namespace, such as `journey.id`, `tenant`,
`region` or `service.name`, is captured as a searchable tag on the conversation. There's no
UserTrace-specific convention here; they're ordinary OTel attributes, on the span or on
the resource.

## Retries are safe

Rows are keyed on the span id, so re-sending a batch inserts nothing. This matters because
OTLP exporters retry on their own. At-least-once delivery is the norm, not an edge case.

## Closing a conversation

```
POST /v1/sessions/{conversation_id}/end
```

Closing is what queues evaluation.

```bash theme={null}
curl -X POST "https://api.getusertrace.com/v1/sessions/conv_8f14c2/end" \
  -H "x-usertrace-api-key: ut_live_YOUR_KEY_HERE"
```

```json theme={null}
{
  "conversation_id": "conv_8f14c2",
  "status": "evaluating",
  "run": "Live traces 2026-09-15",
  "evaluations_queued": 2
}
```

<Tip>
  Make this a plain HTTP call, not a span. It must not depend on the trace pipeline
  having flushed.
</Tip>

Results appear under
`GET /api/prod-evaluations/sessions/?run=Live traces` once they finish, typically within
seconds. Conversations are grouped into a **daily** run, so a day's traffic is one row in
the dashboard rather than thousands.

| Behaviour            | Detail                                                                                                               |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Idempotent           | A second call returns `"already_closed": true` and queues nothing                                                    |
| Unknown conversation | `404`                                                                                                                |
| No evals configured  | Closes cleanly with `evaluations_queued: 0`; traces are kept                                                         |
| Deleted eval         | Skipped and logged, never queued. The conversation would otherwise wait forever for an outcome that can never arrive |

<Note>
  **A conversation left idle for 24 hours is closed automatically**, through the same path
  so a crashed client doesn't strand its data. Calling `/end` yourself just makes
  results arrive sooner.
</Note>

## Recovering unscored conversations

Conversations that arrive before any evals are configured close cleanly but are never
scored, and because the dashboard renders sessions through their run, they're invisible
until they are. The **Live traces** section of the **Agent Setup** page shows the count
and offers to score them.

This is a dashboard action, not an API one.

Scoring uses your **saved** eval configuration, so the backlog is scored with the eval set
you've committed to and nothing else. With none configured, the button tells you to pick
evals first.

Each conversation joins the run for **its own** date, so a backlog can produce several. A
backlog spanning a week produces one run per day rather than one run mislabelled as
today's.

## Errors

| Response                 | Meaning                                                                      |
| ------------------------ | ---------------------------------------------------------------------------- |
| `400 {"message": "..."}` | No conversation id. Set `gen_ai.conversation.id` or send `X-Conversation-Id` |
| `413`                    | Payload over 10 MB. Lower your exporter's batch size                         |
| `415`                    | Content type is neither `application/json` nor `application/x-protobuf`      |
| `429`                    | 120 requests per minute per key exceeded; see `Retry-After`                  |
