Skip to main content
Everything a log drain sends is one batch. A batch carries a schema URL, a batch id, exactly one resource object describing the agent the records came from, and an array of records. The machine-readable definition is published as JSON Schema (draft 2020-12) at:
That URL serves lua-log-batch.schema.json, which you can download or point a $ref at directly; the extensionless form above is the schema’s own $id. Validate against that file rather than against this page: it is the same file the platform’s own tests run their fixtures through. This page explains what each field means.

The envelope

A batch carries exactly one resource. Records from three agents in one flush become three batches, not one batch with a mixed resource. Group your ingest by resource['lua.agent.id'] and you never have to reconcile.
Consumers dedup on records[].id, never on X-Lua-Batch-Id. A retried record re-enters the queue and is re-sent with whatever else is then due, under a new batch id. The record id is the stable, at-least-once idempotency key; the batch id identifies one HTTP request, for support and tracing.

Resource

Resource attributes follow the OpenTelemetry semantic conventions where one exists, and are prefixed lua. where one does not.

Record

attributes is flat by design: every value is a string, a number, or a boolean. Destinations that index attributes (Datadog facets, Loki structured metadata, Elastic fields) can take the whole object without a flattening step.

Severity

The mapping is fixed. severityNumber follows the OpenTelemetry log data model, so a destination that understands OTel severity ranges sorts Lua’s records correctly without configuration. A drain’s --min-severity compares severityNumber against debug: 5, info: 9, warn: 13, error: 17 and keeps what is greater than or equal.

eventName

Every value matches ^lua\.[a-z_-]+\.[a-z]+$. The name never contains an id: a failing Shopify MCP tool is lua.mcp.error, not lua.mcp_shopify.error. Filter on the name and group by an attribute. For the eighteen agent sources the suffix is the line’s level:
lua.device.* is the one name in that list nothing emits yet: device work is logged under device-trigger, so a drain that selects device receives no lua.device.* records. The name is reserved and will start appearing within this major version, which is why a consumer switches on eventName with a default branch. For execution records the suffix is the phase or the outcome:
The four lua.execution.* outcome names on the second line, and the adjacent-record names below, are available from lua-cli 3.39.0.execution has always been a default source, so a drain that selects it receives those four names with no opt-in. They are new values on an existing source, which is additive under the policy below — and the clearest possible reason for the default branch that policy asks for. lua.execution.status carries the exact outcome, and lua.log.type is metric on all seven.

Adjacent-record event names

Six sources carry the platform’s own records of what happened next to a turn rather than lines an agent printed. Their names follow the same lua.<source>.<suffix> rule: Severities: lua.delivery.failed is 17, lua.delivery.expired is 13, every other lua.delivery.* is 9. Every lua.workflow-audit.info is 9 — so a drain at --min-severity warn receives none of them. workflow-run severity follows the event type: *.failed, *.timed_out, run.failing and journal.divergence are 17; cancellations, expiries, throttles, rejections and run.gated are 13; run.log, step.progress and run.phase are 5; everything else is 9.

The suffix is a summary. Switch on the attribute

This is the consumer contract for every one of these sources, and it is worth writing into your ingest before the first record arrives. An eventName suffix admits lowercase letters only — no digits, no _, no . — and several of these vocabularies do not fit that. So the suffix is a normalised summary and the exact value always rides an attribute. Nothing is lost, but the name is not the value: Two of those deserve the emphasis:
  • lua.workflow-run.* is the run/step distinction, not the outcome. The leading segment is used because it is a small closed set that survives the event vocabulary growing, and because it preserves the distinction a consumer routes on. The trailing segment would have collapsed run.completed and step.completed into one name and produced unreadable suffixes like retryscheduled. lua.workflow.event_type carries the full dotted type.
  • lua.workflow-audit.info is one name for roughly sixty actions. An audit action is dotted and underscored (workflow.run.step_parked) and could never be a legal suffix; widening the name pattern would be a breaking change rather than an additive one. Switch on lua.audit.action, with a default branch. See Workflow audit records.
And four names are synthetic — generated by the drain itself rather than by your agent. Every one of them carries lua.log.type, lua.source, and lua.drain.id, and then what the table names: lua.source follows the name: the source segment of lua.drain.test is drain, and of lua.truncated.warn it is truncated. So a query for everything the drain said about itself is lua.source = "drain", and it never catches a record one of your agents wrote. Each synthetic record also carries an attribute no agent record has — lua.drain.id on the three lua.drain.* records, lua.truncated.lines on the truncation one — which makes them cheap to select without a wildcard match on the name. The monitor packs rely on that. Synthetic records do not count against the drain’s daily quota, and they do count in health.deliveredCount24h.

Attributes

Attributes are present when the platform has the value and absent otherwise. A missing attribute is never an error and never an empty string. Everything under app. comes from a log.* call in your own code, and the platform adds exactly one key of its own there — app.lua_fields_clamped, which is reserved, so nothing you can name collides with it. The two lua.* keys are the platform describing what it did with your fields. Emit your own fields covers the limits on them, what each destination does with them, and how a drain’s redact list and the scrubber reach them. traceId and spanId come from W3C trace context when the turn carried it. A malformed value yields neither field; it is not an error and the record is still delivered.

Adjacent-record attributes

Every key below is new with the adjacent-record sources, and every one is optional in the same way as the table above: present when the platform has the value, absent otherwise, never an empty string. Keys marked content arrive only on a drain with --include-content. lua.channel, lua.provider, user.id, lua.run.id, lua.duration_ms and lua.log.type are unchanged in meaning — these records simply also carry them.

lua.delivery.*

A lua.delivery.* record carries no body at all. Delivery records has the statuses, the masking rule and the two statuses nothing reports yet.

lua.trigger-execution.*

One dispatch produces up to two records: the accept and the outcome.

lua.job-execution.* and lua.execution.*

Neither source has a content half, ever. A job result is arbitrary code output and is deliberately not offered, so --include-content changes nothing about these records and they never carry lua.content.withheld. job-execution emits on terminal transitions only. A job that is claimed, running, or has been asked to cancel is not an outcome and produces nothing.

lua.workflow-run.*

Order a workflow run by lua.workflow.seq, never by timestamp. seq is the run’s own monotonic allocation: it is gapless within a run, it survives a retried write, and it is the only field that puts two events written in the same millisecond in the right order. Sort ascending on (lua.workflow.run_id, lua.workflow.seq) and you can replay a run exactly.
traceId and spanId on these records come from the run event’s own trace context, so traceId equals the run’s trace. workflow-run is by far the highest-volume of these sources, and there is no curated subset of event types — a gapped stream would make seq useless. The volume knob is --min-severity: at the default info a drain already drops run.log, step.progress and run.phase. lua.workflow.actor is present whenever the run event recorded one, and absent otherwise — a missing attribute, never a wrong one. Do not treat its absence as “the system did it”.

lua.voice-call.*

A voice record never carries the caller’s or the callee’s phone number under any key, with or without --include-content, and the transcript is never a candidate either. A source that carried them would be unusable without a redact list, which is not a property to design in.
All ten statuses now have a producer
Records on this source are built from two different places in the platform, and nothing on a record says which. That is deliberate: the source, the attribute names, the severity per status, and the record id are identical either way. What changed is coverage. Until this release only completed and transferred were ever sent, and only on a call that produced a transcript. Eight statuses that had no producer now have one — active, ringing, answered, failed, rejected, busy, timeout, and cancelled — from the telephony call lifecycle.
If your ingest assumes “a voice-call record means the call ended”, it now also means the call rang. Branch on lua.voice.status, with a default — which is what the suffix rule has always said for this source.exception.type and exception.message appear on lua.voice-call.* for the first time as well, on the 13 and 17 statuses. A record on the happy path carries neither.
Two things not to read too much into
  • user.id on this source may be masked. Where the producer’s user id is the caller’s phone number, it is emitted as … plus the last four characters — the same mask and the same rule as lua.delivery.recipient — and the full value is content, reaching only a drain with --include-content. Where it is an ordinary Lua user id, it arrives as stored. Treat user.id on lua.voice-call.* as possibly-masked and never parse it as an identifier. A value of four characters or fewer masks to the bare ….
  • lua.voice.recording_url is not a reliable “this call was recorded” signal. One producer sets it routinely; the other almost never can, because the provider’s recording callback lands after the terminal call event, so by the time that record is built there is no URL to carry. Its absence on a lua.voice-call.completed does not mean the call was not recorded.
Both producers can build a completed record for the same call, with the same id — and which of the two you receive is a race. Everything either one carries is documented above; neither invents a field the other contradicts. If your consumer needs a field only one of them sets, select a status it is guaranteed on rather than completed.

lua.workflow-audit.info

lua.audit.metadata is a JSON string, not a nested object: attribute values in this schema are scalars and that does not change. Audit metadata is free-form per action, so flattening it would put unbounded per-action key names into every destination’s attribute index. Over the 4 KB cap the whole value is replaced by {"__truncated":true,"bytes":<n>} — so an oversized entry is never dropped, never silently halved, and still parses. A lua.workflow-audit.info record carries no body. Workflow audit records has the selectors that will silently deliver you nothing, and the SIEM webhook migration.

Content and withheld values

Two things decide what arrives: the drain’s --include-content flag, and whether the record had a content half in the first place. Without --include-content:
  • a user_message or agent_response record is still delivered — so you can see that a turn happened, on what channel, for which user — but body is absent;
  • every attribute marked content above is absent. Not empty, not masked: absent. The rest of the record arrives intact, so the dispatch, the run, the call, the delivery and the audit action all stay visible;
  • the record carries lua.content.withheld: true.
With it, those values appear, having passed the same scrubber as everything else, and each one is capped at 8 KB — a value that was cut ends with a literal marker:
lua.audit.metadata is the one exception to the 8 KB cap: it is capped at 4 KB and replaced wholesale by a truncation marker rather than cut, so that it still parses as JSON.
lua.content.withheld is advisory. The presence of each key is authoritative.The flag marks that a record had a content half and did not receive it. It is deliberately not per-key, so do not infer from it which keys you are missing — read the keys.It follows that some records never carry it at all, and that is correct rather than a bug: lua.job-execution.* and lua.execution.* have no content half to withhold, and a lua.delivery.* record with no recipient has nothing to unmask. Going the other way, lua.workflow-audit.info always carries it when content is off, even for an entry that recorded no evidence — so that a quiet entry can never be mistaken for a redacted one.On lua.voice-call.* the flag is doubly advisory: a masked user.id is present but shortened rather than absent, so the key being there says nothing about whether you are seeing the whole value.
A drain that selects only adjacent-record sources is not a content drain: --include-content’s acknowledgement, and the refusal to turn content off while naming a content source, both concern user_message and agent_response alone.

OTLP mapping

An OTLP drain sends the same information as an OTLP/HTTP ExportLogsServiceRequest in JSON encoding. The mapping is one-to-one.

Versioning

The schema URL carries the major version, and the schemaUrl field in every batch tells you which one you received. Within 1.0, changes are additive only. That means:
  • New optional attributes keys may appear at any time, on any record.
  • New eventName values may appear — new sources, new synthetic records. A consumer that switches on eventName must have a default branch.
  • New optional fields may be added to resource and to records[].
  • New severityNumber values within the OpenTelemetry ranges may appear; compare with >= against the boundaries above rather than with == against the four current values.
These are breaking and bump the URL instead: renaming a field or an attribute, changing a field’s type, removing a required field, making an optional field required, or changing the meaning of an existing eventName. A new major version is published at a new URL — …/schemas/logs/2.0 — and existing drains keep receiving 1.0 until they are moved. Build your ingest so an unknown attributes key is carried through rather than rejected, and a schema addition never pages you.

Changelog

The schema file itself did not change. eventName is a pattern and attributes is open, so a consumer that validates a batch against the published logs/1.0 keeps validating one without touching anything. Every addition in the row above is a new value or a new optional key, which is what the policy above permits at any time. What a consumer does need is the default branch on eventName — this release is the first time that branch is actually reached. The date above is the release in force as this is written and is subject to change until the release is announced.

Next steps

Delivery guarantees

At-least-once, batching, retry, and drop semantics.

Verify signatures

The X-Lua-Signature format and working verifiers.

Generic HTTPS

A receiver that validates this schema end to end.

Grafana dashboard

Panels built on these field names.