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:
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.
Resource
Resource attributes follow the OpenTelemetry semantic conventions where one exists, and are prefixedlua. 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
^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 samelua.<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. AneventName 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 collapsedrun.completedandstep.completedinto one name and produced unreadable suffixes likeretryscheduled.lua.workflow.event_typecarries the full dotted type.lua.workflow-audit.infois 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 onlua.audit.action, with a default branch. See Workflow audit records.
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.*
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.*
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 recordid 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.
Two things not to read too much into
user.idon 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 aslua.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. Treatuser.idonlua.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_urlis 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 alua.voice-call.completeddoes not mean the call was not recorded.
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_messageoragent_responserecord is still delivered — so you can see that a turn happened, on what channel, for which user — butbodyis 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.
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.
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/HTTPExportLogsServiceRequest in JSON encoding. The mapping is one-to-one.
Versioning
The schema URL carries the major version, and theschemaUrl field in every batch tells you which one you received.
Within 1.0, changes are additive only. That means:
- New optional
attributeskeys may appear at any time, on any record. - New
eventNamevalues may appear — new sources, new synthetic records. A consumer that switches oneventNamemust have a default branch. - New optional fields may be added to
resourceand torecords[]. - New
severityNumbervalues within the OpenTelemetry ranges may appear; compare with>=against the boundaries above rather than with==against the four current values.
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.

