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

# log

> Write a log line with structured fields beside the message, from agent code

`log.debug`, `log.info`, `log.warn` and `log.error` write one log entry each: a message, and an optional set of **fields** that travel beside it rather than inside it. The entry appears in [`lua logs`](/reference/cli/logs) like any other, with its fields under `metadata.fields` in `--json`, and a [log drain](/drains/overview) carries them to your destination as searchable `app.*` attributes — see [Emit your own fields](/drains/emit-your-own-fields).

Available wherever agent code runs: tools and conditions, skills, webhooks, triggers, jobs, preprocessors, postprocessors, MCP servers and device handlers. Like [`env`](/reference/sdk/env), the platform injects it into every one of those contexts.

*`log` is not in lua-cli 3.38.0; it arrives in a later release, whose number is not settled as this is written.*

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

log.info('Ticket resolved', { tenant: 'acme', ticket_id: 48219 });
```

## Quick example

Describe what happened in the message, and put the values you will want to group by in the fields.

```ts theme={null}
log.warn('Upstream slow, serving cached result', {
  tenant: input.tenant,
  upstream: 'tickets-api',
  latency_ms: 2841,
  cached: true,
});
```

## Functions

### log.debug(message, fields?), log.info, log.warn, log.error

Writes one log entry. The four methods differ only in the entry's level.

```ts theme={null}
log.debug(message: string, fields?: LogFields): void
log.info(message: string, fields?: LogFields): void
log.warn(message: string, fields?: LogFields): void
log.error(message: string, fields?: LogFields): void
```

<ParamField path="message" type="string" required>
  The line as a person reads it. It becomes the entry's message, and a drain record's `body`.
</ParamField>

<ParamField path="fields" type="LogFields">
  Up to 32 keys, counting the `.count` companion a `string[]` field adds. Omit it and the call is equivalent to the matching `console` method with a single string argument — no fields on the entry, and no `lua.log.structured` marker on a [drain](/drains/emit-your-own-fields) record.
</ParamField>

The level sets the entry's level and, on a drain, its severity:

| Method      | `lua logs` | `severityNumber` | `severityText` |
| ----------- | ---------- | ---------------- | -------------- |
| `log.debug` | `DEBUG`    | `5`              | `DEBUG`        |
| `log.info`  | `INFO`     | `9`              | `INFO`         |
| `log.warn`  | `WARN`     | `13`             | `WARN`         |
| `log.error` | `ERROR`    | `17`             | `ERROR`        |

It does **not** change where the entry comes from. A call in a skill is a `skill` entry, with the `eventName` `lua.skill.<level>`, so `lua logs --type skill` and a drain selecting `--sources skill` both pick it up unchanged.

**Returns** — nothing.

**Example**

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

export default class LookupTickets implements LuaTool {
  name = 'lookup_tickets';
  description = 'Open tickets for a customer';
  inputSchema = z.object({ tenant: z.string(), email: z.string().email() });

  async execute(input: z.infer<typeof this.inputSchema>) {
    const started = Date.now();
    try {
      const tickets = await lookup(input.email);
      log.info('Ticket lookup succeeded', {
        tenant: input.tenant,
        result_count: tickets.length,
        duration_ms: Date.now() - started,
      });
      return tickets;
    } catch (error) {
      log.error('Ticket lookup failed', {
        tenant: input.tenant,
        upstream: 'tickets-api',
        duration_ms: Date.now() - started,
        retried: false,
      });
      throw error;
    }
  }
}
```

**Errors** — [`LuaLogFieldsError`](#lualogfieldserror) when `fields` is structurally invalid. A value that is merely too long never throws.

## Types

### LogFields

```ts theme={null}
type LogFields = Record<string, string | number | boolean | string[]>;
```

<ResponseField name="key" type="string">
  Matches `^[a-z][a-z0-9_]{0,63}$` — starts with a lower-case letter, then lower-case letters, digits and `_`, at most 64 characters. A key that looks like a **credential** is refused on top of that, because a key is never scanned by the [scrubber](/drains/protecting-your-destination#what-is-not-masked) the way a value is: the five credential shapes spellable in lower-case and underscores — a legacy Lua API key, a GitHub token, a Stripe key, a Lua handoff code and a Lua scoped key — are rejected at the call. `lua_fields_clamped` is reserved for the platform and refused as well.
</ResponseField>

<ResponseField name="value" type="string | number | boolean | string[]">
  A `string[]` is a convenience at the call site, not a wire type: it is sent as its elements joined with `,`, plus a companion `<key>.count` that counts as one of your 32 keys. An element may not itself contain a comma. `null`, `undefined`, a nested object, an array of anything but strings, a `Date` and a `BigInt` are all refused — convert them yourself, or leave the key out. `NaN` and `Infinity` are refused too: neither has a JSON form.
</ResponseField>

### The limits

| Limit                               | Value                                                                          | Past it                         |
| ----------------------------------- | ------------------------------------------------------------------------------ | ------------------------------- |
| Keys per call                       | 32, including a `.count` companion                                             | Throws                          |
| Key shape                           | `^[a-z][a-z0-9_]{0,63}$`, never a credential shape, never `lua_fields_clamped` | Throws                          |
| Value type                          | `string`, `number`, `boolean`, `string[]`                                      | Throws                          |
| Comma 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 |

**Structure throws; length does not.** A structural mistake is the same on every call the line makes, so you meet it the first time you run the tool, under `lua test`, rather than on the one production call whose payload was unusual. A length, by contrast, is data — the single customer with a two-kilobyte ticket title must not be the one who breaks a working tool — so an over-long value is cut to fit and the entry records that it was.

**How a clamp works.** Any value over 1 KiB is cut to 1 KiB. If the whole map — measured as the JSON form of the fields, marker included — is still over 8 KiB, every string value is cut to one common byte length, the largest that lets the map fit. No key is ever dropped and a cut never splits a character. The call then carries the reserved field `lua_fields_clamped: true`, which a [drain](/drains/emit-your-own-fields) delivers as `app.lua_fields_clamped`. Numbers and booleans are never clamped.

### LuaLogFieldsError

Thrown synchronously by the `log.*` call, before anything is written, when `fields` breaks one of the structural rules above. The message names the key and the rule.

```ts theme={null}
try {
  log.info('Saved', { 'Ticket-Id': 48219 });
} catch (error) {
  // LuaLogFieldsError — the key is not lower-case and contains a hyphen
}
```

<Warning>
  Do not wrap every `log.*` call in a `try`. A `LuaLogFieldsError` means a key or a value type is wrong in your code, which is a fault to fix rather than one to survive — and it is deterministic, so it fails the first time the line runs. Catching it hides the mistake and keeps the field missing.
</Warning>

## Where `log` is, and is not

| Context                                                                          | `log`  | Note                                                                                                                                        |
| -------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Tool `execute`, a skill's condition                                              | Yes    |                                                                                                                                             |
| Webhook, trigger, job, device handler                                            | Yes    |                                                                                                                                             |
| Preprocessor, postprocessor, MCP server                                          | Yes    |                                                                                                                                             |
| A tool called as a step of a workflow                                            | Yes    | It is the same tool, running the same code it runs standalone — a `log.*` call in it does not stop working because a workflow made the call |
| Workflow **script** code — a code step, a condition written in the workflow file | **No** | Use `ctx.log(…)`, which is unchanged and writes a message with no fields                                                                    |
| `lua test`                                                                       | Yes    | The same validation, and the fields are printed with the entry                                                                              |

## `console` is unchanged

`log.*` is a second way to write a line, not a replacement for the first. Nothing about `console` moves:

* `console.log`, `console.info`, `console.warn` and `console.error` behave exactly as before, and other console methods are still not provided.
* An object passed to one of them is still serialized into the message text — `console.info('x', { a: 1 })` is still the line `x {"a":1}`. That is why fields are a separate call: an overload could not tell "log this object as text", which existing code relies on, from "attach these fields".
* `console.log` is still a `debug` entry, so a drain at `--min-severity info` still does not receive it. If you want a line at `info`, write `log.info` or `console.info`.

## See also

* [Emit your own fields](/drains/emit-your-own-fields) — what the fields become at each destination, and how redaction and scrubbing reach them
* [`lua logs`](/reference/cli/logs) — reading entries, and `metadata.fields` under `--json`
* [Event schema](/drains/event-schema#attributes) — the record a drain delivers, and the `app.*` attributes fields become
* [Logs and debugging](/ship/logs-and-debugging) — finding the entry you want
* [Protecting your destination](/drains/protecting-your-destination) — the scrubber, and what it does not reach
