Skip to main content
A log export is the drain’s offline twin. The same rows go through the same event builder and the same scrubber as a log drain; instead of being pushed at a destination you own, they are written as gzipped NDJSON and handed back as signed, expiring links. Use it when you have no collector, or when you need a window that predates the drain — a drain copies what happens next, and an export reads what already happened.
An export applies Lua’s built-in scrubbing and your organization’s own scrub rules.The built-in rule classes — builtin, lua-token, vendor-key — run unconditionally on every record, exactly as they do on a drain delivery. On top of them, an export applies the union of the rules you added yourself across your organization’s active drains, so a value a drain would mask as [redacted:org:<your rule id>] carries the same mask in an export file.The union, not one drain’s list, because a rule is configured per drain and an export is organization-wide: the only answer that cannot under-redact is every rule the organization has asked for anywhere. “Active” means every drain that is not disabled and not deleted — a paused drain’s rules still count. Masking is only ever added: an organization with no drain rules downloads byte-identical files either way.It may not be enabled on your deployment yet. Where it is not, an export applies the built-in classes alone, and a value only an organization rule would have caught is in the files in the clear. counts.orgScrubHits on the finished job says which of the two you got: it is absent when no organization rule ran.
These routes are organization-scoped and need logs:read. The base URL, bearer authentication, and the error envelope are on the REST API overview; lua logs export is the same surface from a terminal.
Exports are switched on for a deployment as a whole. While they are off, both routes answer 404 — the resource does not exist rather than being forbidden. It may not be enabled on your deployment yet.

Scopes

logs:manage is sensitive: a wildcard such as logs:* does not satisfy it. It is checked when the job is started, not when it is read — the decision is frozen onto the job at that moment, because the worker picks the job up minutes later with no request and no principal behind it. A job started without content cannot be upgraded; start another. Both routes share the drain rate-limit buckets: log-drains-mutate on the start, log-drains-read on the poll.

POST /admin/orgs/:orgId/logs/export

Starts an export. Returns immediately; the work happens on the platform.
string
required
Inclusive lower bound: an ISO 8601 instant, or a relative form such as 15m, 2h, or 7d. A relative form is resolved by the server’s clock.
string
required
Inclusive upper bound; the same formats. The literal string now is not one of them — the vocabulary is an ISO 8601 instant or a relative form, and nothing else. Both bounds are required, so “up to the present moment” is written as an instant you compute yourself.
string[]
Which sources to export. Defaults to every exportable source.
string[]
Up to 200 agent ids. Defaults to every agent in the organization.
string[]
Any of production, sandbox. Defaults to both.
boolean
default:"false"
Include user_message and agent_response bodies. Requires logs:manage.
Response 202 with { "jobId": "lex_…", "state": "queued", "createdAt": "…" }. Poll the status route. Errors
Only the eighteen execution-log sources and execution are exportable. The six adjacent-record sources — delivery, trigger-execution, job-execution, workflow-run, voice-call, and workflow-audit — are built for a drain out of the platform’s own ledgers and are not execution-log rows, so there is nothing for an export to read. Naming one answers 422 EXPORT_SOURCE_UNSUPPORTED. To capture them, point a drain at a destination you can read.

GET /admin/orgs/:orgId/logs/export/:jobId

Reads one job. A job id belonging to another organization answers 404, never 403 — the two cases are deliberately indistinguishable. Response
Errors

Caps

Three clocks, and they measure different things.
  • The signed link — 24 hours after the job finishes (expiresAt). After it, files[].url is gone from the status payload and a link you kept answers 403. There is no supported way to re-issue one — start another export for the same window instead.
  • The job’s links — 7 days after the job was started (deleteAfter). The job moves to expired and loses its URLs permanently. This is the clock that ends the capability, and it is anchored on the start, not the finish.
  • The stored objects — between 7 and 8 days after each part was written. The parts live under the log-exports/ key prefix in Lua’s object storage and are removed by that bucket’s own storage lifecycle rule, which sweeps at UTC midnight rather than to the second. Quote “7 to 8 days” when someone asks how long Lua holds the bytes of an export.
The last two run independently and neither waits on the other: deleteAfter withdraws the links, the lifecycle rule removes the bytes. Download inside the 24 hours, or pass --out and keep the bytes. One export runs per organization at a time. That is a guarantee, not a race — a second start answers 409 EXPORT_ALREADY_RUNNING. lua logs export turns that into an attach: a second invocation in a terminal follows the running job to its files rather than refusing. A truncated export is a success. The files are valid and complete as far as they go; they simply do not cover the whole window. Narrow the window or the sources and run it again.

What the files contain

One complete batch per line. Not one record per line. A record has no resource of its own — service.name, lua.org.id, deployment.environment.name, and the agent version all live on the batch’s resource half. A file of bare records would validate against nothing you were given and would drop the tenancy every record depends on. Parts are named org-<orgId>-logs-<NNNN>.ndjson.gz, zero-padded so lexical order is emission order. Each is stored under the log-exports/ key prefix, so a part’s object key is log-exports/<fileId>.gzip and the signed link in files[].url points at exactly that key — which is also what makes the parts expirable on their own storage lifecycle rule, separately from everything else in the bucket:

Validate it against the published schema

Each line is one batch and validates against the same JSON Schema a drain delivery does — byte-identical shape, same encoder.
Build your ingest so an unknown attributes key is carried through rather than rejected — additions are additive within a major version, and a schema addition should never fail a load.

What is not in them

  • Rows written before organization attribution existed. A row that never recorded an organization cannot be attributed to one, so it is in no org-scoped export. These are the oldest rows only, and the boundary is fixed: nothing written since carries the gap.
  • log-type rows with no source. A row the platform never classified is counted in counts.scanned and not in counts.rows.
  • Message bodies, unless the job was started with logs:manage and includeContent: true. The record still arrives, carrying lua.content.withheld: true.
  • Anything the scrubber caught. Scrubbing is unconditional: there is no flag that skips it, and includeContent: true does not weaken it. An export runs the built-in rule classes (builtin, lua-token, vendor-key) and, where the deployment has it enabled, the union of your organization’s active drains’ own rules as well — see the note at the top of this page.

Retention bounds what you can export

The platform keeps execution logs for 90 days by default. An export can only read what is still there, so a window whose lower bound is older than the retention horizon returns the part of it that survives, not an error. Export before you need to, not after.

The CSV download is deprecated

GET /admin/orgs/:orgId/logs/download still works and still caps at 10,000 rows. It is deprecated in favour of this job: Nothing about the CSV route has changed. Its removal will be its own change, with its own release note.

See also