> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Event schema

> The batch envelope, resource and record fields, the severity table, the eventName catalogue, the OTLP mapping, and the versioning policy for logs/1.0

Everything a [log drain](/drains/overview) 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:

```text theme={null}
https://docs.heylua.ai/schemas/logs/1.0
```

That URL serves [`lua-log-batch.schema.json`](/schemas/logs/1.0/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

```json theme={null}
{
  "schemaUrl": "https://docs.heylua.ai/schemas/logs/1.0",
  "batchId": "01JBW8N2Q4Z6H8Y0KX3R9T5V7M",
  "resource": { "…": "…" },
  "records": [ { "…": "…" } ]
}
```

| Field       | Type   | Required | Notes                                                                                                           |
| ----------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| `schemaUrl` | string | Yes      | Always `https://docs.heylua.ai/schemas/logs/1.0` for this major version. Also sent as the `X-Lua-Schema` header |
| `batchId`   | string | Yes      | A [ULID](https://github.com/ulid/spec). One HTTP request's identifier, also sent as `X-Lua-Batch-Id`            |
| `resource`  | object | Yes      | The agent, environment, and version these records came from                                                     |
| `records`   | array  | Yes      | Up to 500 records (1,000 for Datadog)                                                                           |

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

<Warning>
  **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.
</Warning>

## Resource

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

| Attribute                     | Type   | Required | Value                                                                           |
| ----------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `service.name`                | string | Yes      | The agent's name, for example `support-agent`                                   |
| `service.namespace`           | string | Yes      | The organization id                                                             |
| `service.instance.id`         | string | Yes      | The agent id                                                                    |
| `service.version`             | string | No       | The [agent version](/concepts/releases-and-versions) that served                |
| `deployment.environment.name` | string | Yes      | `production` or `sandbox`                                                       |
| `gen_ai.agent.id`             | string | Yes      | The agent id, under the GenAI convention                                        |
| `gen_ai.agent.name`           | string | No       | The agent's name, under the GenAI convention                                    |
| `cloud.region`                | string | No       | The region the delivery left from, for example `eu-west-1`                      |
| `lua.org.id`                  | string | Yes      | The organization id. **The tenancy key** — assert it on every record you ingest |
| `lua.agent.id`                | string | Yes      | The agent id                                                                    |

## Record

| Field               | Type    | Required | Value                                                                    |
| ------------------- | ------- | -------- | ------------------------------------------------------------------------ |
| `id`                | string  | Yes      | The log entry's id. **The deduplication key**                            |
| `timestamp`         | string  | Yes      | RFC 3339. When the thing happened                                        |
| `observedTimestamp` | string  | Yes      | RFC 3339. When the platform picked the entry up for delivery             |
| `eventName`         | string  | Yes      | `lua.<source>.<suffix>`; see below                                       |
| `severityNumber`    | integer | Yes      | `5`, `9`, `13`, or `17`                                                  |
| `severityText`      | string  | Yes      | `DEBUG`, `INFO`, `WARN`, or `ERROR`                                      |
| `body`              | string  | No       | The log message, scrubbed. Absent on content records when content is off |
| `traceId`           | string  | No       | 32 lowercase hex characters, when the turn carried W3C trace context     |
| `spanId`            | string  | No       | 16 lowercase hex characters                                              |
| `attributes`        | object  | Yes      | String, number, and boolean values only. Never nested                    |

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

| What it is              | `severityNumber` | `severityText` |
| ----------------------- | ---------------- | -------------- |
| A `debug` line          | `5`              | `DEBUG`        |
| An `info` line          | `9`              | `INFO`         |
| An execution `start`    | `9`              | `INFO`         |
| An execution `complete` | `9`              | `INFO`         |
| A `warn` line           | `13`             | `WARN`         |
| An `error` line         | `17`             | `ERROR`        |
| An execution `error`    | `17`             | `ERROR`        |

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

```text theme={null}
eventName = "lua." + <source> + "." + <suffix>
```

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:

```text theme={null}
lua.skill.debug          lua.skill.info          lua.skill.warn          lua.skill.error
lua.job.*                lua.webhook.*           lua.trigger.*           lua.preprocessor.*
lua.postprocessor.*      lua.device.*            lua.device-trigger.*    lua.mcp.*
lua.rag.*                lua.runtime.*           lua.model-resolver.*    lua.workflow.*
lua.workflow-step.*      lua.workflow-script.*   lua.user_message.*      lua.agent_response.*
lua.agent_error.*
```

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

```text theme={null}
lua.execution.start      lua.execution.complete      lua.execution.error
```

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:

| `eventName`           | When                                                                           | `lua.source` | Also carries                                                                |
| --------------------- | ------------------------------------------------------------------------------ | ------------ | --------------------------------------------------------------------------- |
| `lua.drain.test`      | `lua drains test`, the dashboard test button, and every ownership verification | `drain`      | —                                                                           |
| `lua.drain.heartbeat` | Every five minutes, to a `healthy` drain only                                  | `drain`      | `lua.drain.backlog`                                                         |
| `lua.drain.dropped`   | On the first success after records were dropped                                | `drain`      | `lua.drain.dropped_count`, `lua.drain.dropped_from`, `lua.drain.dropped_to` |
| `lua.truncated.warn`  | The runtime dropped lines from one execution because it printed too many       | `truncated`  | `lua.execution.id`, `lua.truncated.lines`                                   |

`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](/drains/packs/datadog-monitors) rely on that.

Synthetic records do not count against the drain's [daily quota](/drains/delivery-guarantees#quota-degradation), 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.

| Attribute                | Type    | On                              | Value                                                                                                                                                      |
| ------------------------ | ------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lua.log.type`           | string  | All                             | `log` for a printed line, `metric` for an execution record                                                                                                 |
| `lua.source`             | string  | All                             | The selector source, for example `skill` or `execution`; `drain` on the three `lua.drain.*` records and `truncated` on `lua.truncated.warn`                |
| `lua.duration_ms`        | number  | Where timed                     | Milliseconds                                                                                                                                               |
| `lua.execution.id`       | string  | Sandboxed code                  | The execution this line belongs to                                                                                                                         |
| `lua.execution.seq`      | number  | Sandboxed code                  | Index of the line within its execution. Sort on it to replay an execution in order                                                                         |
| `lua.primitive.id`       | string  | Named primitives                | The skill, job, webhook, processor, or device id                                                                                                           |
| `lua.primitive.name`     | string  | Named primitives                | Its name, as `lua logs --name` takes it                                                                                                                    |
| `lua.tool.id`            | string  | Tool calls                      |                                                                                                                                                            |
| `gen_ai.tool.name`       | string  | Tool calls                      | The tool name the model called                                                                                                                             |
| `gen_ai.operation.name`  | string  | Where derivable                 | `execute_tool` for `skill` and `mcp`; `chat` for `user_message` and `agent_response`; `invoke_agent` for `job`, `webhook`, `trigger`, and `device-trigger` |
| `gen_ai.conversation.id` | string  | Conversation turns              | The run id                                                                                                                                                 |
| `lua.run.id`             | string  | Conversation turns              | The same value, under the Lua prefix                                                                                                                       |
| `gen_ai.request.model`   | string  | `agent_response`, `agent_error` | The `provider/model` code that served the turn                                                                                                             |
| `lua.channel`            | string  | Where known                     | `pop`, `whatsapp`, `slack`, `email`, `api`, `dev`, …                                                                                                       |
| `user.id`                | string  | End-user traffic                | The end user. Dropped entirely when the drain's `redact` list names it                                                                                     |
| `lua.agent.version`      | string  | Where known                     | Also on the resource                                                                                                                                       |
| `lua.content.withheld`   | boolean | Content records                 | `true` when the record exists but its `body` was withheld because the drain does not include content                                                       |
| `exception.type`         | string  | Errors                          | Parsed from the thrown error                                                                                                                               |
| `exception.message`      | string  | Errors                          |                                                                                                                                                            |
| `exception.stacktrace`   | string  | Errors                          | Capped at 8 KB                                                                                                                                             |

`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](/drains/opentelemetry) sends the same information as an OTLP/HTTP `ExportLogsServiceRequest` in JSON encoding. The mapping is one-to-one.

| Batch field                   | OTLP field                                                                                                                                                                                                            |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resource`                    | `resourceLogs[0].resource.attributes[]`, as `{ key, value: { stringValue } }`                                                                                                                                         |
| `schemaUrl`                   | `resourceLogs[0].schemaUrl`                                                                                                                                                                                           |
| —                             | `resourceLogs[0].scopeLogs[0].scope` = `{ "name": "lua.log-drain", "version": "1.0" }`                                                                                                                                |
| `records[].timestamp`         | `logRecords[].timeUnixNano` (a string of nanoseconds)                                                                                                                                                                 |
| `records[].observedTimestamp` | `logRecords[].observedTimeUnixNano`                                                                                                                                                                                   |
| `records[].eventName`         | `logRecords[].eventName`                                                                                                                                                                                              |
| `records[].severityNumber`    | `logRecords[].severityNumber`                                                                                                                                                                                         |
| `records[].severityText`      | `logRecords[].severityText`                                                                                                                                                                                           |
| `records[].body`              | `logRecords[].body.stringValue`                                                                                                                                                                                       |
| `records[].traceId`           | `logRecords[].traceId` (lowercase hex)                                                                                                                                                                                |
| `records[].spanId`            | `logRecords[].spanId` (lowercase hex)                                                                                                                                                                                 |
| `records[].id`                | `logRecords[].attributes[]`, key `log.record.uid`                                                                                                                                                                     |
| `records[].attributes.*`      | `logRecords[].attributes[]`, typed `stringValue`, `intValue`, `doubleValue`, or `boolValue` by the JavaScript type; whole numbers become `intValue` (a string, per the OTLP JSON encoding) and the rest `doubleValue` |

## 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

| Version | Date       | Change                   |
| ------- | ---------- | ------------------------ |
| `1.0`   | Unreleased | First published version. |

## Next steps

<Columns cols={2}>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">At-least-once, batching, retry, and drop semantics.</Card>
  <Card title="Verify signatures" href="/drains/verify-signatures">The `X-Lua-Signature` format and working verifiers.</Card>
  <Card title="Generic HTTPS" href="/drains/generic-https">A receiver that validates this schema end to end.</Card>
  <Card title="Grafana dashboard" href="/drains/packs/grafana-dashboard">Panels built on these field names.</Card>
</Columns>
