s3 drain writes your agents’ log records into a bucket you own, in your own region, as gzipped NDJSON under a Hive-style key layout. It is the destination a compliance owner asks for, and it is the only one where Lua is not the last hop: retention, encryption, object lock, and the query engine on top are all yours.
All three providers are reached through the S3 API — Cloudflare R2 and Google Cloud Storage over their S3-compatible endpoints — so there is one set of rules for the three of them.
Object-storage drains are switched on for a deployment as a whole. Where the type is not open yet, a create naming it answers
422 DRAIN_TYPE_UNAVAILABLE, and the message names the types that are. It may not be enabled on your deployment yet — ask [email protected].- A bucket, and a prefix inside it that only Lua writes to.
- Either a key pair or — better — a role Lua can assume. The grant is one action: write into that prefix.
- Azure Blob Storage is out of scope: it has no S3-compatible surface.
What to enter
A dotted bucket name is refused: a dot in a virtual-hosted URL breaks TLS certificate verification, and every provider recommends against it.
--role-arn and --credentials-from-env are mutually exclusive — a drain either assumes a role at send time or holds a key pair, never both.
Which hosts are allowed
The host is checked against the three providers’ own shapes before any DNS lookup happens, so an arbitrary S3-compatible endpoint is refused:
Anything else answers
422 DRAIN_ENDPOINT_NOT_ALLOWED. A deployment can widen the list for its own environment, but a widened host is still required to be https: and to resolve entirely to public addresses, re-checked on every send — the same posture as every other destination.
What lands in the bucket
Object keys
key=value directories as partition columns with no DDL of its own.
Each object is gzipped NDJSON: one line per record, and every line carries schemaUrl, batchId, the batch’s resource block, and one record. Each line’s record is the same shape the event schema describes and the same encoder the generic HTTPS drain uses in ndjson format, so the two cannot drift. A consumer that splits a file and drops lines still has the tenancy of every line it kept.
format is always ndjson for a bucket drain; --format json is not honoured here.
Dedup on record.id, never on batchId.
Batching
Object storage batches on a window, not on a flush tick: 5 minutes or 50 MB, whichever comes first. The 500-record, 1 MiB ceilings that suit an HTTP intake would cut a five-minute window into twenty half-megabyte objects, and no query engine reads a partition of 43,000 tiny files at a sensible cost. A single flush writes at most 25,000 records, so a very busy drain gets a second object in the window rather than one enormous one. At roughly a kilobyte a record that sits well inside 50 MB, so most windows are one file. A drain holding its window looks like backlog, not failure:lua drains status shows a non-zero backlog for up to five minutes at a time, by design. A test or a verify never waits out the window.
The hourly manifest
Every hour partition gets a manifest beside its objects:_manifest.json, and for each row confirm the object exists, its length equals bytesCompressed, and the SHA-256 of the downloaded bytes equals sha256. The hash is over the stored, gzipped object, so you never have to decompress anything to verify it.
- The merge is idempotent on the file name. A re-run that writes the same object replaces its row rather than appending a second one, and the totals are recomputed from the rows.
- Entries are sorted by file name, which is a ULID, which is time order.
- Past 5,000 entries a manifest keeps the oldest rows and sets
"truncated": true. The objects are all still there; only the index is short. - The manifest is best effort. A delivery is never retried because a manifest write failed — a retry would mint a fresh batch id and write the same records to a second file. If you suspect a manifest is short, rebuild it by listing the partition: every column but
sha256is derivable from the objects themselves, andsha256is derivable from their bytes.
Concurrent manifest writes use conditional puts. A provider that ignores them degrades to last-write-wins, which can lose a manifest row — never an object. Rebuild from the listing if a partition’s manifest and its objects disagree; the objects are the record.
Prove you own the bucket
Writing to a bucket proves somebody gave Lua a key. It does not prove the bucket is yours. So before an object-storage drain delivers anything, it writes a challenge object under your prefix and has to see the token come back.1
Start the challenge
{prefix}/_lua-verify-<token> into the bucket, with the token as its content.2
If the credentials can read it back, you are done
Verification finishes on its own and the drain goes to
healthy. This is the path when the grant includes s3:GetObject.3
Otherwise, echo the token yourself
With the write-only grant we recommend, Lua cannot read the object back. The drain stays in
pending_verification, and the delivery row says token_not_echoed. List your prefix, open the object, and echo the token:Output
logs:manage confirm a bucket they cannot even read.
The challenge expires with the verification token, after 10 minutes. Run lua drains verify again to mint a new one; verification is limited to 5 attempts per drain per hour.
The read model carries a challenge.keyPattern telling you where to look, not what to send. There is no route that returns the token.
When verification fails
Errors carry the provider’s own error code, never the SDK’s message. An SDK message quotes the signed request, and a signed request carries a credential.
The bucket policy
The grant is deliberately small: write into one prefix, and nothing else.s3:GetObject is optional — without it the ownership challenge takes the lua drains confirm path and the hourly manifest degrades from read-modify-write to write-only.
- A key pair
- An assumable role (preferred)
Terraform
Object Lock is your setting, not Lua’s. Lua writes each data object exactly once and never deletes or overwrites one, so compliance mode is compatible with everything above except the manifest, which is rewritten as each hour fills. Either keep the manifest outside the locked prefix, or accept that a locked hour’s manifest reflects only its first object — the objects themselves are complete either way.
Query it
The Hive layout means a query engine discoversorg, agent, env, dt, and hour as partition columns.
- Athena
- BigQuery
- DuckDB
_manifest.json is not NDJSON, so exclude it — either keep manifests in a prefix of their own or add AND "$path" NOT LIKE '%_manifest.json' to the queries that would otherwise read one.A map<string,string> coerces every attribute value to a string, which is what you want for the long tail. Declare the handful you compare numerically (lua.duration_ms, say) as named struct fields instead.What a read of the drain shows
Ans3 drain reads back with its bucket configuration and no credential:
credentials.secretAccessKeyis write-only. It is accepted onPOSTand onPATCH, and returned by nothing.credentials.accessKeyIdis shown, because SigV4 sends it in clear on every request anyway.credentials.secretLast4is the last four characters of the stored secret, so you can tell one credential from another after a rotation without ever reading one.challenge.keyPatternis where to look, not what to send.tokenHashis always an empty string on a read: a hash would be no use to you and is one thing less to leak.challenge.methodappears once the challenge settles:readbackwhen Lua read the object back itself,confirmedwhen you echoed the token.
PATCH replaces the whole bucket block, never merges it. A half-patched destination — a new bucket with the old credentials — is a drain writing somewhere you did not ask for. Omit bucket entirely to keep the current one.
If it isn’t working
The drain stays in pending_verification and nothing is wrong
The drain stays in pending_verification and nothing is wrong
That is the write-only grant working as intended. List your prefix for
_lua-verify-* and run lua drains confirm <id> --token <token>. If the object is not there either, the delivery row will say s3:AccessDenied — the grant is missing s3:PutObject on that prefix.Objects appear but the manifest is short or missing
Objects appear but the manifest is short or missing
The manifest is best effort and the objects are the record. Rebuild it by listing the partition. A grant without
s3:GetObject cannot do read-modify-write, so an hour with several objects may list only the first.The backlog sits at a few thousand and clears every five minutes
The backlog sits at a few thousand and clears every five minutes
That is the window, not a fault. Object storage batches on 5 minutes or 50 MB. Watch the age of the backlog against the window rather than its depth.
s3:PermanentRedirect on every attempt
s3:PermanentRedirect on every attempt
The bucket is in a different region from
--region. An AWS endpoint is derived from the bucket and the region, so a wrong region is a wrong host. Recreate the drain with the right one.Counts do not match at a day boundary
Counts do not match at a day boundary
dt= is the hour Lua wrote the file, not the hour the events happened. Read one partition on either side and filter on record.timestamp. See the partition caveat.Next steps
Event schema
Every field on the record inside each line.
Protecting your destination
Why an arbitrary endpoint is refused, and what is re-checked on every send.
Log export
A one-off NDJSON export, for a window that predates the drain.
lua drains
Every verb and flag, including
confirm.
