Skip to main content
This guide shows you how to poll captured LLM-call and agent-run records with the Logs Export API. After you connect a poller or an incremental connector, you can land those records in a data warehouse, SIEM, or analytics platform.

Prerequisites

Before you export logs, make sure you have:

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.

Authenticate requests

Send the Writer API key on every request.
Each key is bound to a single organization and can export logs only for that organization. A request that uses a key against a different organizationId returns 403 Forbidden. The gateway or API-key verifier can also reject access; body export still requires an authorized organization-scoped API key.

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 request retained records from that ingestion time, then continue polling 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. Available history depends on source telemetry and completed backfills; this does not guarantee full historical coverage.

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. Call filters qualify agent runs through matching LLM calls. For order=browse, provide both start_date and end_date, or omit both. Providing only one date returns 400 Validation Error. For order=browse, continue with both cursor_started_at_ms and cursor_log_id. Read them from pagination.next_cursor with format=native, or from x-next-cursor-started-at-ms and x-next-cursor-log-id with format=otlp. The opaque cursor is tail-only, and the two-part browse parameters are rejected in tail mode.
API-key callers receive unmasked bodies when you request include_bodies=true, even if content masking is enabled in AI Studio. The UI masking setting is not a privacy boundary for API-key exports. User-token detail requests separately follow the organization masking setting and remain masked if that setting cannot be resolved.

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 decide when the settled backlog is drained. Native pagination.has_more matches x-has-more. In tail mode, native pagination.next_cursor is null; read the resume token from x-next-cursor. Tail responses omit total_count, total_count_is_capped, and total-count headers. Browse-only bounded counts appear in those native fields, or in x-total-count and x-total-count-is-capped for OTLP. These counts are not tail catch-up signals.

Read a native body

With format=native, the body is a JSON object containing heterogeneous rows. result[].inserted_at_ms and result[].log_id are the two components of the tail ordering axis. Upsert by log_id, retaining the revision with the newest inserted_at_ms. This example shows selected fields from an LLM call and an agent run:

Distinguish calls and runs

Use record_type to distinguish llm_call from agent_run. The fields describe different units of work: Run rows describe terminal execution legs with a qualifying LLM call. Still-running and tool-only legs are excluded. Runs have no inference model, token, or body values.

Interpret run duration

A run’s duration_ms is lifecycle timing for that execution leg. It is not:
  • A sum of call latency
  • A session total
  • Exact user-visible latency
Missing or ambiguous timing remains null; do not infer a duration or replace it with zero. Parent, child, and resumed legs remain separate records. There is no session-total record.

Correlate records

Correlate records using agent_run_id, root_agent_run_id, request_id, and session_id where present. A root ID does not guarantee a user turn or identify a subagent role. Uncorrelated calls remain exportable. Late lifecycle events or call correlation can revise the same run identity. Accept later revisions rather than permanently discarding known IDs. See Apply delivery guarantees.

Handle unavailable bodies

When you request include_bodies=true, an unavailable call body is null with a body_unavailable_reason. The reason codes distinguish:
  • Capture: not_captured
  • Processing or retrieval: pending, missing, failed
  • Authorization: ekm_denied, unauthorized
  • Retention or deletion: expired, deleted
  • Other unavailability: unavailable
These conditions are distinct from masking. Run records return body: null without hydration or an unavailable-body reason.

Read an OTLP body

With format=otlp (the default), the body is an OTLP/JSON payload. Choose the body shape with signal: Pagination stays in the response headers. In tail mode, use x-next-cursor, x-has-more, and x-tail-watermark. Agent runs become writer.agent.run spans with kind INTERNAL, or log records with event name writer.agent.run.terminal. Spans end at the terminal event time and start at that time minus the run duration. Unknown duration produces a point span without writer.agent.run.duration_ms; that does not mean the run took zero time. Run spans and logs expose stable writer.record.id and correlation attributes. They do not include inference model, token, or body attributes.

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.
Do not parse, truncate, or reconstruct the cursor. A tampered or version-incompatible token returns 400 Validation Error with detail Invalid tail cursor.

Apply delivery guarantees

Tail mode is an ordered revision feed. The settlement delay does not make a record immutable, and a cursor identifies a position rather than a fixed snapshot.
  • 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. Settlement is not record finality.
  • Revision-aware upserts. Retries, overlapping workers, and later revisions can deliver the same log_id again. Upsert on log_id, retaining the newest inserted_at_ms, so an older replay cannot overwrite a newer revision. Never permanently discard an ID because you have already seen it.
  • Replay. Replaying a cursor can observe newer rows at or after that position. After the stream is caught up, polling again can return newly settled rows. Missing source telemetry is not reconstructed, and the feed does not promise lossless real-time delivery.

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

Service error bodies use the Problem+JSON shape, with code and detail. Gateway authentication errors can use a different envelope: an unauthenticated public request can return 401 with generic JSON rather than the service Problem+JSON shape. Handle the HTTP status even when the body does not contain a service error code.

Poll for new logs

Poll with the returned x-next-cursor to pick up newly settled rows and revisions:
  • Drain and pause: Drain settled pages until x-has-more is false, then pause before polling again.
  • Upsert revisions: Upsert by log_id, retaining the newest inserted_at_ms. A primary key alone does not prevent older retries from overwriting newer revisions.
  • Persist cursor: Save the cursor only after the complete page is successfully processed. Restart from that saved cursor. These examples keep the cursor in memory only.
  • Handle rate limits: On 429, wait for retry-after before retrying. The 30-second pause in these examples applies after the stream is caught up.
The Python and JavaScript examples emit each complete row revision as JSON to stdout. The cURL example prints each response body.

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. Emit every revision and configure the destination to upsert on log_id, retaining the newest inserted_at_ms. Save the bookmark only after the destination processes the page.

Configure Airbyte

Airbyte’s cursor pagination can read a response-header token and inject it into the next request. This handles pages within a sync, not the persisted bookmark needed to resume this feed across scheduled syncs. Use these mappings for pagination within a sync: For cross-sync resumption, implement a custom incremental source with the Airbyte Python CDK. The pagination mappings alone are not a complete connector. The source must:
  1. Restore the saved opaque x-next-cursor from stream state and send it as cursor on the first request of each sync. Omit start_date when resuming. Without saved state, choose a start point as described in Choose a start point.
  2. Emit every row in result, including revisions of known log_id values, before emitting a state message for that page’s x-next-cursor. Do not advance state after a partially processed page.
  3. Continue while x-has-more is true. When it is false, retain the returned x-next-cursor for the next scheduled sync, even if the final page is empty.
  4. Use Airbyte’s state protocol: a checkpoint becomes safe to resume from only after the destination acknowledges that all preceding records are committed. Emitting records or state from the source alone is not an acknowledgment.
The Writer cursor is opaque, not a timestamp. Do not parse it or substitute inserted_at_ms for it. Airbyte’s incremental sync configuration is not a replacement for restoring this bookmark. Resumable full refresh is not cross-sync incremental state either: its state is deleted at the start of a new sync job. Set the record primary key to log_id and separately configure revision-aware destination updates that retain the newest inserted_at_ms. The primary key alone does not enforce revision ordering. On 429, honor retry-after before retrying.

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 after the target processes the page. On the next run, send that value as cursor. Emit each revision with log_id as the record key and retain the newest inserted_at_ms in the target.
  • Fivetran. Loop while x-has-more is true, emit every revision with log_id as the primary key, and retain the newest inserted_at_ms at the destination. Store x-next-cursor in connector state only after processing the page.

Review default limits

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

Next steps

View logs in AI Studio, or inspect a single agent.
  • Event logs: View, filter, and download request logs in AI Studio
  • Agent observability: Track session logs and performance metrics for individual agents