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

# OpenTelemetry

> Point an existing OpenTelemetry exporter or collector at Moda's OTLP trace endpoint and control attribution with span attributes.

Moda accepts standard OpenTelemetry traces over OTLP/HTTP. If your application is already instrumented — with the OpenTelemetry GenAI semantic conventions, OpenLLMetry, or the Vercel AI SDK's telemetry — point that exporter at Moda's trace endpoint and conversations are extracted without re-instrumentation. The Moda SDKs use this same endpoint; you only need this page when integrating an existing OpenTelemetry pipeline directly.

## Endpoint and authentication

```
POST https://moda-ingest.modas.workers.dev/v1/traces
Authorization: Bearer moda_sk_...
```

Both OTLP/HTTP encodings are accepted; OTLP/gRPC is not supported.

| `Content-Type`           | Body                                         |
| ------------------------ | -------------------------------------------- |
| `application/json`       | OTLP/HTTP JSON (`ExportTraceServiceRequest`) |
| `application/x-protobuf` | OTLP/HTTP protobuf                           |

## Point an exporter at Moda

<Tabs>
  <Tab title="Exporter environment variables">
    Standard OTLP exporters read these variables:

    ```bash theme={"dark"}
    export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://moda-ingest.modas.workers.dev/v1/traces"
    export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer YOUR_MODA_API_KEY"
    ```

    Use the `_TRACES_`-scoped variables (rather than `OTEL_EXPORTER_OTLP_ENDPOINT`) so the exporter posts to the exact `/v1/traces` path and other signals in your pipeline are unaffected.
  </Tab>

  <Tab title="OpenTelemetry Collector">
    Add an `otlphttp` exporter to your collector configuration and route the traces pipeline through it:

    ```yaml otel-collector-config.yaml theme={"dark"}
    exporters:
      otlphttp/moda:
        traces_endpoint: https://moda-ingest.modas.workers.dev/v1/traces
        headers:
          Authorization: Bearer YOUR_MODA_API_KEY

    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [batch]
          exporters: [otlphttp/moda]
    ```

    Use `traces_endpoint` (not `endpoint`) so the collector sends to the exact path instead of appending its own.
  </Tab>
</Tabs>

<Note>
  Already running OpenTelemetry inside a Node.js app? The Moda SDK for Node.js attaches its span processor to your existing TracerProvider instead of replacing it — see [Node.js SDK](/ingestion/node).
</Note>

## Supported attribute conventions

Moda recognizes GenAI spans in each of these conventions automatically, per span — mixed batches are fine:

| Convention                               | Recognized attributes                                                                                                                                                                                                              |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenTelemetry GenAI semantic conventions | `gen_ai.input.messages` / `gen_ai.output.messages` (JSON-string message arrays; `parts` arrays supported), plus the older numbered `gen_ai.prompt.{n}.role` / `gen_ai.prompt.{n}.content` and `gen_ai.completion.{n}.*` flattening |
| OpenLLMetry / Traceloop                  | `llm.prompts.{n}.role` / `llm.prompts.{n}.content` and `llm.completions.{n}.*`                                                                                                                                                     |
| Vercel AI SDK telemetry                  | `ai.prompt.messages`, `ai.response.text`, `ai.response.object`, `ai.operationId` — see the [Vercel AI SDK guide](/ingestion/vercel-ai-sdk) for the SDK-level setup                                                                 |
| Single-string fallback                   | A bare `gen_ai.prompt` / `llm.prompt` and `gen_ai.completion` / `llm.completion` string, when the span also carries a model attribute                                                                                              |
| Generic tool spans                       | Spans carrying `gen_ai.tool.name`, `db.system`, `rpc.system`, or `http.route` are captured as tool-call activity                                                                                                                   |

When a span carries both `llm.*` and numbered `gen_ai.*` message attributes, the `llm.*` set is used — messages are never extracted twice from one span.

## Model, provider, usage, and finish reason

For each field, the first attribute found wins:

| Field                 | Attributes, in precedence order                                                                                                                         |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Model                 | `llm.request.model`, `gen_ai.request.model`, `llm.response.model`, `gen_ai.response.model`, `ai.model.id`                                               |
| Provider              | `gen_ai.provider.name`, `llm.system`, `gen_ai.system`, `ai.model.provider`                                                                              |
| Input tokens          | `llm.usage.prompt_tokens` / `llm.usage.input_tokens`, `gen_ai.usage.input_tokens` / `gen_ai.usage.prompt_tokens`, `ai.usage.promptTokens`               |
| Output tokens         | `llm.usage.completion_tokens` / `llm.usage.output_tokens`, `gen_ai.usage.output_tokens` / `gen_ai.usage.completion_tokens`, `ai.usage.completionTokens` |
| Reasoning tokens      | `gen_ai.usage.reasoning.output_tokens`                                                                                                                  |
| Cache-read tokens     | `gen_ai.usage.cache_read.input_tokens`, or the flat `gen_ai.usage.cache_read_input_tokens`                                                              |
| Cache-creation tokens | `gen_ai.usage.cache_creation.input_tokens`, or the flat `gen_ai.usage.cache_creation_input_tokens`                                                      |
| Finish reason         | First entry of `gen_ai.response.finish_reasons`, then `gen_ai.response.finish_reason`, then `llm.response.finish_reason`                                |

## Moda attribution attributes

Set these span attributes to control how spans map onto Moda conversations, users, environments, and prompts. All are optional.

| Attribute                                                                                                | Purpose                                                         |
| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `moda.conversation_id`                                                                                   | Groups spans into one conversation across traces                |
| `moda.user_id`                                                                                           | End-user attribution for per-user analytics                     |
| `moda.environment`                                                                                       | `development`, `staging`, or `production` for this span         |
| `moda.prompt_key`, `moda.prompt_id`, `moda.prompt_name`, `moda.prompt_version`, `moda.prompt_version_id` | [Prompt management](/prompt-management/attribution) attribution |
| `moda.external_message_id`                                                                               | Your own stable, unique ID for the resulting message            |
| `gen_ai.agent.name` (or `agent.name`)                                                                    | Agent identity recorded on the resulting messages               |

The conversation, user, and prompt keys are also read from the Vercel AI SDK metadata form `ai.telemetry.metadata.moda.*`, which is how [`getVercelAITelemetry()`](/ingestion/vercel-ai-sdk) sets them.

On the resource, `user.id` / `enduser.id` and `deployment.environment` are read as defaults for every span under that resource.

## Conversation ID precedence

For each span, the conversation ID is resolved in this order:

1. `moda.conversation_id` (including the `ai.telemetry.metadata.moda.conversation_id` form)
2. `gen_ai.conversation.id` — the legacy underscore form `gen_ai.conversation_id` and the Traceloop key `traceloop.association.properties.session_id` are also honored
3. The span's trace ID

A bare `session_id` attribute is deliberately ignored: it is too generic and could merge unrelated conversations. Without an explicit ID, spans group by trace — each trace becomes its own conversation — so set `moda.conversation_id` for anything multi-turn.

## Environment normalization

Environments are normalized to `development`, `staging`, or `production`. Common aliases map to their canonical forms (`dev` / `develop` → `development`, `stg` / `stage` → `staging`, `prod` → `production`); unrecognized values fall back to `production`. A span-level `moda.environment` overrides the resource-level `deployment.environment`.

## Example request

A minimal OTLP/JSON request with one GenAI chat span:

```bash theme={"dark"}
curl https://moda-ingest.modas.workers.dev/v1/traces \
  -H "Authorization: Bearer YOUR_MODA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceSpans": [{
      "resource": {"attributes": [
        {"key": "service.name", "value": {"stringValue": "my-app"}}
      ]},
      "scopeSpans": [{
        "spans": [{
          "traceId": "5b8efff798038103d269b633813fc60c",
          "spanId": "eee19b7ec3c1b174",
          "name": "chat gpt-4o",
          "startTimeUnixNano": "1755338400000000000",
          "endTimeUnixNano": "1755338401500000000",
          "attributes": [
            {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
            {"key": "gen_ai.response.model", "value": {"stringValue": "gpt-4o"}},
            {"key": "gen_ai.provider.name", "value": {"stringValue": "openai"}},
            {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "12"}},
            {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "8"}},
            {"key": "moda.conversation_id", "value": {"stringValue": "conv-123"}},
            {"key": "gen_ai.input.messages", "value": {"stringValue": "[{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"content\":\"What is the capital of France?\"}]}]"}},
            {"key": "gen_ai.output.messages", "value": {"stringValue": "[{\"role\":\"assistant\",\"parts\":[{\"type\":\"text\",\"content\":\"Paris.\"}]}]"}}
          ]
        }]
      }]
    }]
  }'
```

JSON requests receive a JSON acknowledgment, where `count` is the number of conversation messages extracted (the example yields two — one user message and one assistant message):

```json theme={"dark"}
{
  "success": true,
  "count": 2,
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}
```

Protobuf requests receive a standard OTLP `ExportTraceServiceResponse` (`Content-Type: application/x-protobuf`), so unmodified OTLP exporters work without changes.

After sending, open **Conversations** in the [dashboard](https://moda.dev/dashboard) — conversation `conv-123` appears with the extracted user and assistant messages within minutes.

## Limits and errors

* Request bodies are limited to 5 MB (`413` `Request too large`). There is no per-request span-count limit below that.
* An optional `X-Request-ID` header (UUID) is echoed back as `requestId` for correlating requests with responses.

| Status | Body                                                                                                                | Meaning                                                                      |
| ------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `200`  | `{"success": true, "count": n, "requestId": "..."}`                                                                 | Accepted; `count: 0` means no conversation data was extracted from the batch |
| `400`  | `{"success": false, "count": 0, "message": "Invalid OTLP format", "requestId": "..."}`                              | Unparseable body                                                             |
| `401`  | `{"success": false, "count": 0, "message": "...", "requestId": "..."}`                                              | Missing or invalid API key                                                   |
| `413`  | `{"success": false, "count": 0, "message": "Request too large", "requestId": "..."}`                                | Body over 5 MB                                                               |
| `503`  | `{"success": false, "count": 0, "message": "Queue temporarily unavailable", "requestId": "...", "retryable": true}` | Backpressure — safe to retry                                                 |

## Next steps

* [Ingestion overview](/ingestion/overview) — all ingestion endpoints, authentication, and shared limits.
* [Vercel AI SDK](/ingestion/vercel-ai-sdk) — the AI SDK integration that emits `ai.*` telemetry to this endpoint.
* [HTTP API](/ingestion/http-api) — the simple JSON alternative when you don't run OpenTelemetry.
* [Reliability](/ingestion/reliability) — retries, limits, and error envelopes.
