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

# Export logs

> Stream unified LLM logs from Writer with the Logs Export API tail endpoint, and resume the feed from a cursor.

This guide shows you how to stream unified LLM logs with the Logs Export API. After you connect a poller or an incremental connector, you can land those logs in a data warehouse, SIEM, or analytics platform.

## Prerequisites

Before you export logs, make sure you have:

* A Writer API key. See instructions in the [API keys guide](/api-reference/api-keys).

## Call the export endpoint

Request logs for one organization.

**URL:** `GET https://api.writer.com/api/observability/v1/organization/{organizationId}/logs/export`

Omit `cursor` and `start_date` to start at the current time. Only newly settled rows stream forward.

<CodeGroup>
  ```bash cURL theme={null}
  curl --get --location \
    "https://api.writer.com/api/observability/v1/organization/<ORGANIZATION_ID>/logs/export" \
    --header "Authorization: Bearer $WRITER_API_KEY" \
    --data-urlencode "order=tail" \
    --data-urlencode "format=native" \
    --data-urlencode "limit=1000"
  ```

  ```python Python theme={null}
  import os

  import requests

  organization_id = "<ORGANIZATION_ID>"
  api_key = os.environ["WRITER_API_KEY"]

  response = requests.get(
      "https://api.writer.com/api/observability/v1/organization/"
      f"{organization_id}/logs/export",
      headers={"Authorization": f"Bearer {api_key}"},
      params={"order": "tail", "format": "native", "limit": 1000},
      timeout=60,
  )
  response.raise_for_status()
  print(response.json())
  print(response.headers["x-next-cursor"])
  ```

  ```javascript JavaScript theme={null}
  const organizationId = "<ORGANIZATION_ID>";
  const apiKey = process.env.WRITER_API_KEY;

  const params = new URLSearchParams({
    order: "tail",
    format: "native",
    limit: "1000",
  });

  const response = await fetch(
    `https://api.writer.com/api/observability/v1/organization/${organizationId}/logs/export?${params}`,
    { headers: { Authorization: `Bearer ${apiKey}` } },
  );

  if (!response.ok) {
    throw new Error(`Export request failed with status ${response.status}`);
  }

  const body = await response.json();
  console.log(body);
  console.log(response.headers.get("x-next-cursor"));
  ```
</CodeGroup>

## Authenticate requests

Send the Writer API key on every request.

```http theme={null}
Authorization: Bearer <API_KEY>
```

Each key is bound to a single organization and can tail logs only for that organization. A request that uses a key against a different `organizationId` returns `403 Forbidden`.

## Choose a start point

You choose where the stream begins. After the first response, page with the cursor alone. The cursor already encodes your position.

### Start from the current time

Call the endpoint with `order=tail` and neither `cursor` nor `start_date`. The stream starts at the current time, with no backfill.

### Backfill from a date

Pass `start_date` with `order=tail` to replay from that time, then continue live after the backlog drains. `start_date` accepts an ISO-8601 timestamp, epoch seconds, or epoch milliseconds. For example, `start_date=2026-07-01T00:00:00Z`.

### Stay inside the retention window

A `start_date` older than the retention window (90 days by default) returns `400 Validation Error`. The `detail` field includes the window length and the earliest allowed ISO date. The API returns that error instead of truncating the backfill.

### Page with the cursor alone

After you store `x-next-cursor`, send it as the `cursor` query parameter and omit `start_date`. Sending `start_date` together with `cursor` returns `400` because the cursor already encodes the position.

## Set query parameters

These parameters control paging, the response shape, and filters. Filters apply in tail mode and keep `(inserted_at, log_id)` order across pages.

| Parameter | Values | Default | Description |
| - | - | - | - |
| `order` | `browse`, `tail` | `browse` | `tail` selects the incremental stream. |
| `cursor` | Opaque string | None | Resume token from a prior `x-next-cursor` header. Tail only. |
| `start_date` | ISO-8601, epoch seconds, or epoch milliseconds | Now | Tail backfill start. Mutually exclusive with `cursor`. |
| `end_date` | ISO-8601, epoch seconds, or epoch milliseconds | None | Browse only. Rejected when `order=tail`. |
| `limit` | Integer ≥ 1 | `100` | Page size. Clamped to 1000. With `include_bodies=true`, set `limit` to 50 or less. The default of 100 returns `400`. |
| `format` | `otlp`, `native` | `otlp` | Response body shape. |
| `signal` | `traces`, `logs` | `traces` | OTLP only. `traces` returns `resourceSpans`. `logs` returns `resourceLogs`. |
| `include_bodies` | Boolean | `false` | Adds prompt and response bodies. Requires the Content Export entitlement. |
| `model` | String, repeatable | None | Filter by model. Repeat the parameter to pass more than one value. |
| `created_by` | String, repeatable | None | Filter by user ID. Repeat the parameter to pass more than one value. |
| `source` | `llm_gateway`, `writer_agent`, `other`, repeatable | None | Filter by source. `writer_agent` is WRITER Agent. Repeat the parameter to pass more than one value. |
| `provider` | String | None | Filter by provider. |
| `status` | String | None | Filter by status. |
| `finish_reason` | String | None | Filter by finish reason. |
| `guardrail_outcome` | String | None | Filter by guardrail outcome. |
| `search` | String | None | Full-text filter. |

<Note>
  `include_bodies=true` requires the Content Export entitlement. Without that
  entitlement, the request returns `403 Forbidden`.
</Note>

## Read the response

Tail responses put the resume token and the caught-up signal in headers. The body shape depends on `format`.

### Read response headers

Use these headers to resume and to decide when the settled backlog is drained.

| Header | Meaning |
| - | - |
| `x-next-cursor` | Opaque resume token. Present on every tail response. After a full page it points past the last row. On a caught-up page it repeats the current position. Store it and send it on the next request. Use `x-has-more` to decide whether more rows are ready. |
| `x-has-more` | `true` when another page is available. `false` when the stream is caught up to the settled boundary. |
| `x-tail-watermark` | Settled boundary in epoch milliseconds. The boundary is now minus a 5-minute settle lag. Rows with `inserted_at` newer than this value are withheld from the page. |
| `x-total-count` | OTLP responses only. Approximate match count. The count can include rows past the settled boundary, so it can exceed the rows delivered to a drained tail. Use `x-has-more` to decide when to pause. |
| `x-total-count-is-capped` | OTLP responses only. `true` when `x-total-count` hit the internal count cap. |
| `retry-after` | Seconds to wait. Present only on `429`. |

### Read a native body

With `format=native`, the body is a JSON object. `result[].inserted_at_ms` and `result[].log_id` are the two components of the tail ordering axis. Use `log_id` as the key for an idempotent upsert. Other log fields, such as `model`, appear on each row.

```json theme={null}
{
  "result": [
    {
      "log_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "inserted_at_ms": 1734600000000,
      "model": "palmyra-x5"
    }
  ],
  "total_count": 1234,
  "total_count_is_capped": false,
  "pagination": {
    "limit": 1000,
    "has_more": true,
    "next_cursor": null
  }
}
```

`pagination.has_more` matches the `x-has-more` header. In tail mode, `pagination.next_cursor` is `null`. Read the resume token from the `x-next-cursor` header.

### Read an OTLP body

With `format=otlp` (the default), the body is an OTLP/JSON payload. `signal=traces` returns `{ "resourceSpans": [...] }`. `signal=logs` returns `{ "resourceLogs": [...] }`. Pagination stays in the response headers: `x-next-cursor`, `x-has-more`, and `x-tail-watermark`.

## Store and resend the cursor

The tail cursor is a versioned, HMAC-signed token. Treat it as an opaque string. Store it verbatim and send it back unchanged on the `cursor` parameter.

<Warning>
  Do not parse, truncate, or reconstruct the cursor. A tampered or
  version-incompatible token returns `400 Validation Error` with detail `Invalid
      tail cursor`.
</Warning>

## Apply delivery guarantees

Tail mode returns rows in a fixed order and withholds unsettled rows so a delivered page stays final. Dedupe on `log_id` because a retry can deliver the same row again.

* **Ordering.** Rows return in `(inserted_at ASC, log_id ASC)` order. Replaying a cursor returns no row that ordered before that cursor.
* **Settled window.** A page contains only rows whose `inserted_at` is at or before `x-tail-watermark`. The watermark is now minus a 5-minute settle lag. A row with an older event time and a newer `inserted_at` arrives when the watermark passes that `inserted_at`.
* **Dedupe.** The store resolves each `log_id` to one row. Delivery is at-least-once across retries and overlapping workers. Upsert on `log_id`.
* **Idempotency.** Replaying a cursor before the watermark moves returns the same rows. Two workers that send that cursor at the same time observe the same rows. After the stream is caught up, polling that cursor again can return newly settled rows once the watermark advances.

## Stay within limits

Each organization has a fixed window of 120 requests per 60 seconds by default. A request over that limit returns `429 Rate Limited` with a `retry-after` header. The value is the whole seconds until the window resets, with a minimum of one second. The limit is checked before authentication. Wait for `retry-after`, then retry.

`limit` is clamped to 1000. A larger value is reduced to 1000. When `include_bodies=true`, set `limit` to 50 or less. The default of 100 is above that maximum and returns `400 Validation Error`.

## Handle errors

Error bodies use the Problem+JSON shape, with `code` and `detail`.

| Status | Code | When |
| - | - | - |
| `400` | `VALIDATION_ERROR` | Tampered or version-incompatible cursor. `start_date` older than retention. `end_date` with tail. `cursor` with browse. `start_date` with `cursor`. `include_bodies=true` with `limit` above 50. |
| `401` | `UNAUTHORIZED` | Missing or malformed `Authorization` bearer token. |
| `403` | `FORBIDDEN` | The key tails a different organization, or `include_bodies=true` without the Content Export entitlement. |
| `429` | `RATE_LIMITED` | The per-organization rate limit is exceeded. The response includes `retry-after`. |

## Poll for new logs

Drain settled pages, store `x-next-cursor` from every response, and pause when `x-has-more` is `false`. Then poll again with the cursor you stored to pick up newly settled rows. Upsert each row on `log_id`. The in-memory set in the Python and JavaScript samples covers one process only.

On `429`, sleep for the `retry-after` header before the next request. The 30 second pause below is an example interval after the stream is caught up.

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  set -euo pipefail

  organization_id="<ORGANIZATION_ID>"
  poll_interval_seconds=30
  cursor=""
  base_url="https://api.writer.com/api/observability/v1/organization/${organization_id}/logs/export"

  while true; do
    headers_file=$(mktemp)
    body_file=$(mktemp)

    args=(
      --silent
      --show-error
      --get
      --location
      --output "$body_file"
      --dump-header "$headers_file"
      --header "Authorization: Bearer ${WRITER_API_KEY}"
      --data-urlencode "order=tail"
      --data-urlencode "format=native"
      --data-urlencode "limit=1000"
    )
    if [[ -n "$cursor" ]]; then
      args+=(--data-urlencode "cursor=${cursor}")
    fi

    status=$(curl "${args[@]}" --write-out "%{http_code}" "$base_url")

    if [[ "$status" == "429" ]]; then
      retry_after=$(grep -i '^retry-after:' "$headers_file" | tail -n 1 | awk '{print $2}' | tr -d '\r')
      rm -f "$headers_file" "$body_file"
      sleep "${retry_after:-1}"
      continue
    fi

    if [[ "$status" != "200" ]]; then
      echo "Export request failed with status ${status}" >&2
      cat "$body_file" >&2
      rm -f "$headers_file" "$body_file"
      exit 1
    fi

    cat "$body_file"
    echo

    cursor=$(grep -i '^x-next-cursor:' "$headers_file" | tail -n 1 | cut -d' ' -f2- | tr -d '\r')
    has_more=$(grep -i '^x-has-more:' "$headers_file" | tail -n 1 | awk '{print $2}' | tr -d '\r')
    rm -f "$headers_file" "$body_file"

    if [[ "$has_more" != "true" ]]; then
      sleep "$poll_interval_seconds"
    fi
  done
  ```

  ```python Python theme={null}
  import os
  import time

  import requests

  organization_id = "<ORGANIZATION_ID>"
  api_key = os.environ["WRITER_API_KEY"]
  poll_interval_seconds = 30
  cursor = None
  seen_log_ids = set()

  url = (
      "https://api.writer.com/api/observability/v1/organization/"
      f"{organization_id}/logs/export"
  )

  while True:
      params = {"order": "tail", "format": "native", "limit": 1000}
      if cursor:
          params["cursor"] = cursor

      response = requests.get(
          url,
          headers={"Authorization": f"Bearer {api_key}"},
          params=params,
          timeout=60,
      )

      if response.status_code == 429:
          retry_after = int(response.headers.get("retry-after", "1"))
          time.sleep(max(retry_after, 1))
          continue

      response.raise_for_status()

      for row in response.json()["result"]:
          log_id = row["log_id"]
          if log_id in seen_log_ids:
              continue
          seen_log_ids.add(log_id)
          print(log_id, row["inserted_at_ms"], row.get("model"))

      cursor = response.headers["x-next-cursor"]
      if response.headers.get("x-has-more") != "true":
          time.sleep(poll_interval_seconds)
  ```

  ```javascript JavaScript theme={null}
  const organizationId = "<ORGANIZATION_ID>";
  const apiKey = process.env.WRITER_API_KEY;
  const pollIntervalMs = 30000;
  let cursor = null;
  const seenLogIds = new Set();

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  async function pollLogs() {
    const url = `https://api.writer.com/api/observability/v1/organization/${organizationId}/logs/export`;

    while (true) {
      const params = new URLSearchParams({
        order: "tail",
        format: "native",
        limit: "1000",
      });
      if (cursor) {
        params.set("cursor", cursor);
      }

      const response = await fetch(`${url}?${params}`, {
        headers: { Authorization: `Bearer ${apiKey}` },
      });

      if (response.status === 429) {
        const retryAfter = Number(response.headers.get("retry-after") ?? "1");
        await sleep(Math.max(retryAfter, 1) * 1000);
        continue;
      }

      if (!response.ok) {
        throw new Error(`Export request failed with status ${response.status}`);
      }

      const body = await response.json();
      for (const row of body.result) {
        if (seenLogIds.has(row.log_id)) {
          continue;
        }
        seenLogIds.add(row.log_id);
        console.log(row.log_id, row.inserted_at_ms, row.model);
      }

      cursor = response.headers.get("x-next-cursor");
      if (response.headers.get("x-has-more") !== "true") {
        await sleep(pollIntervalMs);
      }
    }
  }

  pollLogs();
  ```
</CodeGroup>

## Connect Airbyte, Fivetran, or Singer

The cursor and caught-up signal are header strings, so an incremental connector can store them as a bookmark. Use `format=native` and read `x-next-cursor` from the header.

### Configure Airbyte

Airbyte low-code `CursorPagination` reads the next token from a response header and sends it back as a request parameter. Map the export contract as follows, and set the stream primary key to `log_id`.

```yaml theme={null}
pagination_strategy.cursor_value: "{{ headers['x-next-cursor'] }}"
pagination_strategy.stop_condition: "{{ headers.get('x-has-more', 'false') != 'true' }}"
page_token_option:
  type: RequestOption
  field_name: cursor
  inject_into: request_parameter
page_size_option:
  type: RequestOption
  field_name: limit
  inject_into: request_parameter
record_selector.extractor.field_path:
  - result
primary_key:
  - log_id
```

Configure `WaitTimeFromHeader` backoff on the `Retry-After` header for `429` responses.

### Configure Fivetran or Singer

Singer taps and Fivetran custom connectors use the same header bookmark.

* **Singer.** Persist `x-next-cursor` as the stream replication bookmark in `STATE`. On the next run, send that value as `cursor`. Use `log_id` as the record key so the target upserts idempotently.
* **Fivetran.** Store `x-next-cursor` in connector state. Loop while `x-has-more` is `true`, and emit `log_id` as the primary key.

## Review default limits

These operational defaults apply to the export stream. To request different values, contact your Customer Success team.

| Setting | Default | Effect |
| - | - | - |
| Settle lag | 5 minutes | Withholds rows newer than now minus the lag. Delivered pages stay final. |
| Export retention | 90 days | Oldest `start_date` a tail backfill can request. |
| Rate limit (requests) | 120 | Requests per window, per organization. |
| Rate limit (window) | 60 seconds | Length of the rate-limit window. |

## Next steps

View logs in AI Studio, or inspect a single agent.

* [Event logs](/home/event-logs): View, filter, and download request logs in AI Studio
* [Agent observability](/home/agent-observability): Track session logs and performance metrics for individual agents
