Skip to main content
This guide shows you how to poll captured LLM-call, agent-run, and agent-feedback 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. By default, the logs endpoint responds with OpenTelemetry Protocol (OTLP) JSON that follows the OpenTelemetry semantic conventions for generative AI (gen_ai.* attributes), alongside Writer-specific attributes under writer.*. Set format=native to receive a Writer-native JSON shape instead. The agent-feedback endpoint only emits native JSON.

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 Control Center. 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.

Interpret watermark request observations

Where available, LLM-call records include nullable observations of the actual outbound watermark request: Missing historical fields remain unknown, not disabled. An omitted parameter object does not identify why it was omitted. Agent-run rows have no corresponding provider attempt and carry null facts. These six request observations do not establish that the output was watermarked; no application-status field is reported. OTLP omits unknown writer.watermark_* attributes. Hash keys, wire messages/codes, labels and the original watermark object are excluded, including from protected bodies. Existing access and masking rules remain unchanged. Existing model-visibility rules can also withhold watermark metadata. In that case all six facts are null, including the support and parameter-presence booleans. A null value is not evidence that watermarking was unsupported, disabled or absent. These metadata rules do not expand access to protected request or response content.

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.

Follow the OpenTelemetry semantic conventions for generative AI

Attributes on LLM-call spans follow the OpenTelemetry semantic conventions for generative AI where one exists, with gen_ai.* names such as gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, and gen_ai.usage.output_tokens. Prompt and response bodies, when included, follow the OpenTelemetry generative AI message schemas on gen_ai.input.messages and gen_ai.output.messages. These conventions are still in development; expect additive changes over time. Writer-specific attributes keep the writer. prefix. Agent-run spans use the span name writer.agent.run, log records use the event name writer.agent.run.terminal, and both carry the stable writer.record.id and, when known, writer.agent.run.duration_ms.

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.

Export agent feedback

Stream thumbs up and thumbs down ratings, including optional comments, with the same authentication, cursor, rate limit, and error contract as the logs export. The feedback endpoint returns native JSON only. URL: GET https://api.writer.com/api/observability/v1/organization/{organizationId}/agent-feedback/export Omit cursor and start_date to start at the current time. Only newly settled rows stream forward.

Set feedback query parameters

The feedback endpoint accepts only the shared paging parameters. It has no filters, no format option, and no total counts. Both orders page with the opaque cursor. A browse continuation takes its window from the cursor and does not require dates.
Cursors are not interchangeable between endpoints. A cursor issued by the logs export returns 400 Validation Error on the feedback endpoint, and a browse cursor presented with order=tail (or the reverse) also returns 400.

Read a feedback response

Every response sets x-next-cursor and x-has-more. Tail responses also set x-tail-watermark. The body is always native JSON.

Handle re-ratings

A user can change their rating or edit the comment. Each change emits a new revision of the same feedback_id with a newer emitted_at_ms. The two orders surface revisions differently:
  • Browse collapses to one row per feedback_id—the revision with the latest emitted_at_ms—newest-first by (emitted_at, feedback_id) within [start_date, end_date]. Feedback first given in one window and re-rated in a later window appears only in the later window.
  • Tail returns every revision as a row, ordered (inserted_at ASC, feedback_id ASC). A re-rated message is redelivered as a second row with the same feedback_id and a newer inserted_at_ms. Upsert by feedback_id and retain the newest inserted_at_ms to match the latest-rating view browse provides.
A tail page never withholds a revision because an earlier revision was already delivered. The settled watermark (x-tail-watermark) is the only completeness boundary.
qualitative_comment is free text and may contain whatever the user typed. It is part of every row with no opt-in parameter and no entitlement check. This differs from the logs export, where prompt and response bodies require include_bodies=true and the Content Export entitlement. Consumers that must not land free text downstream should drop the field on ingest.

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 Control Center, or track organization-wide spend.
  • Event logs: View, filter, and search request logs in Control Center
  • Usage and spend: Track organization-wide costs and token consumption