Skip to main content
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:
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, a drain’s redact list, an 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.
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. 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 [email protected] to have it enabled.
The API reference is 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.
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. 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 — 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 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, Axiom, Better Stack and Sumo Logic carry the whole record under a lua key, and Splunk under event. Fields need no encoder support to arrive and no configuration to be addressable:
In Datadog that is @lua.attributes.app.tenant:
The generic HTTPS, OTLP and 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 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 are:
It is a copy, not a move — the record under lua still carries every field, so nothing you already query changes meaning:

New Relic

New Relic’s logs[].attributes is flat, so a field is an attribute under its own name:
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 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.
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 passes in production. 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 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 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.
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 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, in the daily byte allowance, 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 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 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:
After. The message is a message, and the fields are fields:
Four things to check as you go: 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

log

The API: the four methods, the field types, and the errors.

Event schema

Where app.* sits in the record, and the versioning promise around it.

Protecting your destination

The scrubber, your own rules, and what is not masked.

Usage and plan quotas

What drain bytes are metered as.