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

# Emit your own fields

> Attach structured fields to a log line from agent code and have them arrive as searchable attributes at every destination

A log line your agent writes is a string. Everything a destination can group, facet, or alert on has to be *in* that string — so the usual answer is to print JSON and teach the destination to parse it back out:

```ts theme={null}
console.log(JSON.stringify({ msg: 'Ticket lookup failed', tenant, ticketId, retried }));
```

That works until it doesn't. The line is stored as printed, so `lua logs` shows you a wall of JSON; the destination needs a parsing rule per shape, and the rule is the thing that breaks when a field is added; and nothing that reads the record — the [scrubber](/drains/protecting-your-destination), a drain's `redact` list, an [export](/reference/rest/log-export) — can see the fields as fields, because to every one of them the whole thing is body text.

`log.*` is the other way round. You pass the fields beside the message, and they travel as **attributes** all the way to the wire.

```ts theme={null}
log.error('Ticket lookup failed: upstream timeout', {
  tenant: 'acme',
  ticket_id: 48219,
  retried: true,
  tags: ['billing', 'urgent'],
});
```

<Info>
  **Fields reach drains as your deployment is switched on for them.** The `log.*` call itself works everywhere agent code runs and its fields always appear in [`lua logs --json`](/reference/cli/logs#output). Carrying them onto a drain as `app.*` attributes is rolling out separately: until it is enabled, a `log.*` record is delivered exactly as the same line written with `console.*` would be — message, severity, and the platform's own attributes — and nothing about your existing deliveries changes. Ask [support@heylua.ai](mailto:support@heylua.ai) to have it enabled.
</Info>

The API reference is [`log`](/reference/sdk/log). This page is about what happens to the fields after the call.

## What arrives

Each key is prefixed `app.` and placed in the record's `attributes`, beside the platform's own. The record is an ordinary record in every other respect: same `eventName`, same severity mapping, same `lua.source`.

```json theme={null}
{
  "id": "1789217997644-jtoulxxxq",
  "timestamp": "2026-09-12T12:59:57.644Z",
  "eventName": "lua.skill.error",
  "severityNumber": 17,
  "severityText": "ERROR",
  "body": "Ticket lookup failed: upstream timeout",
  "attributes": {
    "lua.log.type": "log",
    "lua.source": "skill",
    "lua.primitive.name": "tickets",
    "lua.log.structured": true,
    "app.tenant": "acme",
    "app.ticket_id": 48219,
    "app.retried": true,
    "app.tags": "billing,urgent",
    "app.tags.count": 2
  }
}
```

Four things in that are worth stating plainly.

**`app.` is yours.** The platform writes `lua.*`, `gen_ai.*`, `service.*`, `user.id` and `exception.*`, and adds exactly one key of its own under `app.`: `app.lua_fields_clamped`, which marks a call whose values were [cut to fit](#limits). That key is reserved — a call that tries to set it is refused — so nothing you can name will collide with it, or with a key the platform adds later, and nothing you name is passed through unprefixed.

**The level sets the severity, not the source.** `log.debug`, `log.info`, `log.warn` and `log.error` map to `severityNumber` 5, 9, 13 and 17 exactly as the matching `console` method does. A `log.*` call from a skill is still a `skill` record with the `eventName` `lua.skill.<level>`, so a drain that selects `--sources skill` receives it with no change to the drain. If you pass an explicit `--sources` list, make sure it includes `skill` (and `webhook`, `job` or `trigger` for tools that run there): a list built only from the delivery and execution sources delivers no `log.*` rows at all. There is no new source to select and no new `eventName` to add a branch for.

**`lua.log.structured: true` is how you select them.** A record whose line carried at least one field carries that marker, so `lua.log.structured:true` is a query for "the lines my code described" without matching the lines it merely printed. A `log.*` call with no fields is an ordinary line and carries neither the marker nor any `app.*` key.

**An array becomes a joined string plus a count.** `attributes` values are strings, numbers and booleans — [never arrays and never nested](/drains/event-schema#record) — and that is a promise of the published `logs/1.0` schema rather than an implementation detail. So `tags: ['billing', 'urgent']` is sent as `app.tags: "billing,urgent"` with `app.tags.count: 2` beside it — always that spelling, and it can never collide with a field of your own, because a key you write may not contain a `.`. The companion does count as one of your 32 keys. Every destination indexes a joined string usefully and can filter on the count; `app.tags.0`, `app.tags.1` would instead spend one of [New Relic's 255 attributes](#new-relic) on every element of every array. An element containing a comma is refused at the call, so splitting on `,` is always safe.

## What each destination does with them

The same call, at each family of destination.

### Nested under `lua`

[Datadog](/drains/datadog), [Axiom](/drains/axiom), [Better Stack](/drains/better-stack) and [Sumo Logic](/drains/sumo-logic) carry the whole record under a `lua` key, and [Splunk](/drains/splunk) under `event`. Fields need no encoder support to arrive and no configuration to be addressable:

```json theme={null}
{
  "ddsource": "lua",
  "service": "support-agent",
  "message": "Ticket lookup failed: upstream timeout",
  "status": "error",
  "lua": {
    "eventName": "lua.skill.error",
    "body": "Ticket lookup failed: upstream timeout",
    "attributes": {
      "lua.source": "skill",
      "lua.log.structured": true,
      "app.tenant": "acme",
      "app.ticket_id": 48219,
      "app.tags": "billing,urgent"
    }
  }
}
```

In Datadog that is `@lua.attributes.app.tenant`:

```text theme={null}
source:lua @lua.attributes.app.tenant:acme @lua.attributes.app.retried:true
source:lua @lua.attributes.lua.log.structured:true
```

The [generic HTTPS](/drains/generic-https), [OTLP](/drains/opentelemetry) and [object storage](/drains/object-storage) destinations carry the record itself, so `attributes["app.tenant"]` is where it is. OTLP types each value by its JavaScript type as the [mapping table](/drains/event-schema#otlp-mapping) already describes — `stringValue` for `app.tenant`, `intValue` for `app.ticket_id`, `boolValue` for `app.retried`.

### Better Stack

Better Stack indexes **top-level** keys and does not index inside a nested object, so app fields are lifted to the top level the same way the [resource ids](/drains/better-stack#filtering-a-source-by-organization-agent-or-environment) are:

```json theme={null}
{
  "dt": "2026-09-12T12:59:57.644Z",
  "level": "error",
  "message": "Ticket lookup failed: upstream timeout",
  "lua.org.id": "org_4f2c9a1b",
  "service.name": "support-agent",
  "app.tenant": "acme",
  "app.ticket_id": 48219,
  "app.retried": true,
  "app.tags": "billing,urgent",
  "app.tags.count": 2,
  "lua": { "…": "the record, unchanged" }
}
```

It is a **copy, not a move** — the record under `lua` still carries every field, so nothing you already query changes meaning:

```text theme={null}
app.tenant:"acme" AND level:error
```

### New Relic

New Relic's `logs[].attributes` is flat, so a field is an attribute under its own name:

```json theme={null}
{
  "timestamp": 1789217997644,
  "message": "Ticket lookup failed: upstream timeout",
  "attributes": {
    "lua.record.id": "1789217997644-jtoulxxxq",
    "lua.eventName": "lua.skill.error",
    "lua.severityNumber": 17,
    "lua.source": "skill",
    "app.retried": true,
    "app.tags": "billing,urgent",
    "app.tags.count": 2,
    "app.tenant": "acme",
    "app.ticket_id": 48219
  }
}
```

```sql theme={null}
SELECT count(*) FROM Log FACET `app.tenant` SINCE 1 day ago
```

**New Relic caps a log at 255 attributes**, and app fields are the ones that count against it. The platform's own attributes are written first — whatever their prefix — and app fields fill what remains, in sorted key order. So an overflow loses *your* fields, alphabetically last, and never a `lua.*`, `gen_ai.*`, `exception.*` or `user.id` key you were already relying on. One call adds at most 33 attributes — the 32-key budget already counts any `.count` companion, plus `app.lua_fields_clamped` when a value was clamped — which leaves the ceiling comfortable; it is worth knowing which side of it gives way.

### Loki

Fields ride in the JSON line, where `| json` reaches them at query time. **They never become stream labels.** [The five labels](/drains/loki#five-labels-and-why-there-are-only-five) are frozen, and for the reason that page gives: a label whose values are not a small closed enum multiplies streams and is a cost bug in your own Loki bill. A tenant id or a ticket number is exactly that kind of value.

```json theme={null}
{
  "streams": [
    {
      "stream": { "org": "org_4f2c9a1b", "agent": "agent_1789214224176_2vta8rnyn", "environment": "production", "source": "skill", "level": "error" },
      "values": [
        ["1789217997644000000", "{\"eventName\":\"lua.skill.error\",\"body\":\"Ticket lookup failed: upstream timeout\",\"attributes\":{\"lua.source\":\"skill\",\"app.tenant\":\"acme\",\"app.ticket_id\":48219}}"]
      ]
    }
  ]
}
```

```logql theme={null}
{org="org_4f2c9a1b", level="error"} | json | app_tenant = "acme"
```

If your own pipeline promotes a field to a label, that is your decision and your cardinality — the drain will not make it for you.

## Limits

They are checked at the call, before anything is written, and the check is the same everywhere your code runs. What passes under [`lua test`](/reference/cli/test) passes in production.

| Limit                               | Value                                                                                                                    | Past it                         |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------- |
| Keys per call                       | 32, counting the `.count` companion a list field adds                                                                    | Throws                          |
| Key shape                           | `^[a-z][a-z0-9_]{0,63}$` — lower-case, starts with a letter, letters, digits and `_`, at most 64 characters              | Throws                          |
| Credential-shaped keys              | Refused: a key that looks like a credential the [scrubber](/drains/protecting-your-destination#what-is-not-masked) masks | Throws                          |
| Reserved key                        | `lua_fields_clamped` — the platform's clamp marker                                                                       | Throws                          |
| Value types                         | `string`, `number`, `boolean`, or `string[]`                                                                             | Throws                          |
| Commas in an array element          | Not allowed                                                                                                              | Throws                          |
| Bytes per value                     | 1 KiB, UTF-8                                                                                                             | Clamped, and the call is marked |
| Bytes per call, all fields together | 8 KiB, UTF-8                                                                                                             | Clamped, and the call is marked |

**The split is deliberate.** A structural mistake — a misspelled key, a nested object, a `Date` where a string belongs — is the same on every call your code makes, so it throws `LuaLogFieldsError` and you find it the first time you run the tool. A length is data: the one customer whose ticket title runs to two kilobytes must not be the one who breaks your skill. Length is clamped instead, and the call says so.

**What clamping does, exactly.** Each value over 1 KiB is cut to 1 KiB first. If the whole map — measured as the JSON form of your fields, marker included — is still over 8 KiB, every string value is cut to one common byte length, the largest that makes the map fit. **No key is ever dropped**, the cuts land on character boundaries rather than mid-character, and the call gains the reserved field `lua_fields_clamped: true`, which reaches your destination as `app.lua_fields_clamped`. Numbers and booleans are never clamped.

Nothing here is retroactive. `console.log`, `console.info`, `console.warn` and `console.error` are unchanged: an object passed to one is still serialized into the message text, and `console.log` is still a `debug` line.

## Redaction and scrubbing

App fields are part of the record before anything that inspects a record runs, so the existing controls reach them with no new configuration. In order:

1. Your fields become `app.*` attributes.
2. The drain's `redact` list drops the keys it names, entirely.
3. The [scrubber](/drains/protecting-your-destination#what-is-masked-before-anything-leaves) masks credential shapes in every string value — the built-in rules and your organization's own.
4. The destination's encoder formats what is left.

**`redact` names the prefixed key.** `"app.query"`, not `"query"` — it is matched against the attribute key as the record carries it. The list holds [32 keys per drain](/drains/overview#quotas-and-limits) and that budget is now shared between the platform's keys and yours, so a drain that redacts `user.id` and twenty fields of its own has eleven left.

```json theme={null}
{ "redact": ["user.id", "app.query", "app.account_ref"] }
```

**Your organization's scrub rules apply unchanged**, and so do all of the built-ins. A field that happens to contain a bearer token or a Stripe key is masked as `[redacted:vendor-key]` before it leaves, exactly as it would be inside a `console.log` line. The [5 ms per-record budget](/drains/protecting-your-destination#your-own-rules) for your own rules is shared across everything in the record, so thirty-two fields and twenty rules is more work in the same budget than one body and twenty rules — if you run many rules, add fields with that in mind.

### What is not scrubbed

* **Numbers and booleans.** The scrubber reads string values only. No credential shape can be expressed in a number — but a number is not scanned either, so `log.info('Charged', { account_pin: 90210447 })` reaches your destination as it was written. Put anything that might be sensitive in a string, where the rules can reach it, or keep it out of the log.

* **Keys.** Only values are scanned, and no `app.*` key can carry a credential shape the scrubber masks. The key rule admits lower-case letters, digits and `_` only, which excludes most of the built-in shapes on its own — the upper-case prefixes, the hyphens, the dots of a JWT. The five that *are* spellable that way — a legacy Lua API key, a GitHub token, a Stripe key, a Lua handoff code and a Lua scoped key — are refused as keys outright, so a name the scrubber would have masked as a value cannot be used as a field name at all.

* **End-user text you chose to put in a field.** Lua does not classify the content of a field you authored; a field is your own telemetry in the same way your `console.log` output is. If a field can hold what a customer typed, name it in `redact` or write a rule for it.

* **The row itself.** Scrubbing happens on the way to a drain. `lua logs` and `lua logs --json` show field values exactly as your code logged them, so a value the drain masks is still readable by anyone who can read your agent's logs.

## What fields cost

A field is drain bytes. There is no separate meter, metric, or quota for them — the bytes count in the [per-batch byte cap](/drains/delivery-guarantees#batching), in the daily [byte allowance](/drains/usage-and-quotas), and in the delivery row's byte count, in exactly the way an equally long `console.log` line would.

Two consequences worth planning for:

* **Batches get smaller, not less frequent.** A batch is cut at the destination's record cap or its byte cap, whichever comes first. Records average a few hundred bytes today, so the record cap is usually what binds; add several kilobytes of fields per record and the byte cap binds instead. The flush cadence does not change, so this shows up as more batches of fewer records, not as delay.
* **A very large record can shed its fields.** Several destinations cap one record on its own — Datadog at 1 MiB, Splunk, Loki, Axiom and Sumo Logic at 256 KiB; the [per-record column](/drains/delivery-guarantees#batching) has them. A record still over its destination's cap once its body has been trimmed sheds `app.*` values, largest first, until it fits, and carries `lua.app_fields.dropped` — a **number**, how many went — to say so. Ties break on the key name, so the same record sheds the same fields wherever it is built. Platform attributes are never shed, and a record with no app fields takes the path it always took.

Per-skill byte attribution is not available: [usage](/drains/usage-and-quotas#what-is-metered) is metered per settled batch and there is no per-record byte count to attribute. To see what one skill is costing, send it to a drain of its own.

## Migrating from JSON in the message

If you already print JSON and parse it at the destination, the move is mechanical — and you can make it without a flag day, because the two shapes coexist.

**Before.** The fields are inside the string, and a parsing rule at the destination lifts them back out:

```ts theme={null}
export default class LookupTickets implements LuaTool {
  async execute(input: z.infer<typeof this.inputSchema>) {
    try {
      return await lookup(input.ticketId);
    } catch (error) {
      console.error(
        JSON.stringify({
          msg: 'Ticket lookup failed',
          tenant: input.tenant,
          ticketId: input.ticketId,
          retried: true,
          tags: ['billing', 'urgent'],
        }),
      );
      throw error;
    }
  }
}
```

**After.** The message is a message, and the fields are fields:

```ts theme={null}
import { LuaTool, log } from 'lua-cli';

export default class LookupTickets implements LuaTool {
  async execute(input: z.infer<typeof this.inputSchema>) {
    try {
      return await lookup(input.ticketId);
    } catch (error) {
      log.error('Ticket lookup failed: upstream timeout', {
        tenant: input.tenant,
        ticket_id: input.ticketId,
        retried: true,
        tags: ['billing', 'urgent'],
      });
      throw error;
    }
  }
}
```

Four things to check as you go:

| In the old shape                              | In the new one                                          |
| --------------------------------------------- | ------------------------------------------------------- |
| `ticketId`, `orderNo`                         | `ticket_id`, `order_no` — keys are lower-case with `_`  |
| A nested object, `{ customer: { id, tier } }` | Flatten it: `customer_id`, `customer_tier`              |
| `null`, `undefined`, a `Date`                 | A string, a number, or leave the key out                |
| The message doubling as the payload           | Keep a message a human can read; it is still the `body` |

Then cut over in three steps:

1. Ship the `log.*` call. Nothing at the destination changes yet — the fields are on the record and in `lua logs --json`, and until app fields are enabled for your deployment nothing extra reaches the drain.
2. Build the new queries against `app.*` beside the old ones, once fields are arriving. Both work at once, because the message is still the message and your old parsing rule still sees whatever you still print.
3. Delete the parsing rule and the `JSON.stringify`.

## Next steps

<Columns cols={2}>
  <Card title="log" href="/reference/sdk/log">The API: the four methods, the field types, and the errors.</Card>
  <Card title="Event schema" href="/drains/event-schema">Where `app.*` sits in the record, and the versioning promise around it.</Card>
  <Card title="Protecting your destination" href="/drains/protecting-your-destination">The scrubber, your own rules, and what is not masked.</Card>
  <Card title="Usage and plan quotas" href="/drains/usage-and-quotas">What drain bytes are metered as.</Card>
</Columns>
