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:
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. 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.

Content records

user_message and agent_response carry end-user content. On a drain without --include-content, those records are still delivered — so you can see that a turn happened, on what channel, for which user — but body is absent and lua.content.withheld is true. Turning content on changes only that: the body appears, still scrubbed.

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

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.