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

# Axiom

> Ingest agent logs into an Axiom dataset, with _time as the event's own timestamp and the whole record flattened under lua

An `axiom` drain posts to one Axiom dataset's ingest endpoint. Each record becomes one event with `_time`, `level`, `message`, and the whole Lua record under `lua` — which Axiom flattens at ingest, so `lua.eventName` and `lua.attributes.*` are queryable exactly as the [event schema](/drains/event-schema) spells them.

<Info>
  Vendor destinations are opened per deployment. Where `axiom` is not open yet, a create answers `422 DRAIN_TYPE_UNAVAILABLE` and the message names the types that are. **It may not be enabled on your deployment yet.**
</Info>

**Before you begin**

* An Axiom dataset.
* An API token with ingest permission on it.

## What to enter

| Field    | Flag                     | Value                                              |
| -------- | ------------------------ | -------------------------------------------------- |
| Name     | `--name`                 | 1–64 characters, unique in the organization        |
| Type     | `--type axiom`           |                                                    |
| Endpoint | `--endpoint`             | The **full** ingest URL, **ending in the dataset** |
| Token    | `--header Authorization` | Enter it as `Bearer <API token>`                   |

<Warning>
  **The endpoint must name the dataset.** `https://api.axiom.co/v1/ingest/agent-logs` is accepted; `https://api.axiom.co` alone is refused at create time with `422 DRAIN_VALIDATION_FAILED` and `field: "endpoint"`, because there is no dataset to ingest into. The dataset is public configuration, not a credential, so it stays in the stored endpoint and is visible on a read of the drain.
</Warning>

```bash theme={null}
lua drains create \
  --name "Axiom prod" \
  --type axiom \
  --endpoint https://api.axiom.co/v1/ingest/agent-logs \
  --header Authorization \
  --environments production \
  --min-severity info
```

In CI:

```bash theme={null}
lua drains create --ci --json \
  --name "Axiom prod" --type axiom \
  --endpoint https://api.axiom.co/v1/ingest/agent-logs \
  --header-from-env Authorization=AXIOM_TOKEN
```

The host must end in `.axiom.co`. That is a **suffix** rule, so `api.eu.axiom.co` is admitted and `evil-axiom.co` is not — the leading dot is what stops a lookalike domain matching. Anything else answers `422 DRAIN_ENDPOINT_NOT_ALLOWED`.

The last path segment may contain letters, digits, and `. _ ~ % = + -` only; anything else is refused rather than interpolated.

## How ownership verification works

<Warning>
  **A vendor preset is reachability-checked, not ownership-verified.** `lua drains verify` posts one batch holding a single `lua.drain.test` record, and any `2xx` from the ingest endpoint is accepted. It proves the dataset exists and the token can write to it. It does **not** prove you own it. Only a [generic HTTPS](/drains/generic-https) drain is ownership-verified by a token echo, and only an [object-storage](/drains/object-storage) drain proves ownership by writing into a bucket.
</Warning>

```bash theme={null}
lua drains verify drn_8b3f21e5c904a7d61f02bb55
```

A `401` means the token is wrong or lacks ingest permission; a `404` usually means the dataset in the URL does not exist. Verification is limited to 5 attempts per drain per hour.

Axiom deliveries are **not signed**.

## What arrives

A JSON array of events:

```json theme={null}
[
  {
    "_time": "2026-09-12T12:59:57.644Z",
    "level": "error",
    "message": "Ticket lookup failed: upstream timeout",
    "lua": {
      "id": "1789217997644-jtoulxxxq",
      "timestamp": "2026-09-12T12:59:57.644Z",
      "observedTimestamp": "2026-09-12T12:59:59.102Z",
      "eventName": "lua.skill.error",
      "severityNumber": 17,
      "severityText": "ERROR",
      "body": "Ticket lookup failed: upstream timeout",
      "attributes": {
        "lua.source": "skill",
        "lua.primitive.name": "tickets",
        "gen_ai.tool.name": "lookup_tickets",
        "lua.duration_ms": 1841
      }
    }
  }
]
```

| Axiom field | Comes from                                                      |
| ----------- | --------------------------------------------------------------- |
| `_time`     | The record's `timestamp`, RFC 3339 in UTC                       |
| `level`     | The severity, lower-cased: `debug`, `info`, `warn`, `error`     |
| `message`   | The record's `body`, or its `eventName` when there is no body   |
| `lua`       | The **whole record**, unflattened — Axiom flattens it at ingest |

`_time` is the one reserved field, and sending it is what makes a replayed backlog readable: without it Axiom stamps ingestion time, and a batch delivered after a pause would all land at the same instant. A timestamp that does not parse is passed through untouched rather than replaced with "now" — Lua does not assert a time it never measured.

`level` and `message` are not reserved, but they are what Axiom's own dashboards look for.

### Useful queries

```text theme={null}
['agent-logs'] | where ['lua.eventName'] == 'lua.skill.error'
['agent-logs'] | where level == 'error' | summarize count() by bin_auto(_time), ['lua.attributes.lua.source']
['agent-logs'] | where ['lua.attributes.gen_ai.tool.name'] == 'lookup_tickets'
['agent-logs'] | where ['lua.eventName'] == 'lua.drain.heartbeat'
```

### Caps

| Cap                            | Value                                                                   |
| ------------------------------ | ----------------------------------------------------------------------- |
| Events per request             | 10,000 — the vendor's own ceiling; the byte cap binds first in practice |
| Uncompressed bytes per request | 448 KiB                                                                 |
| Bytes per event                | 256 KiB                                                                 |
| Compression                    | `gzip`, always                                                          |
| Retried                        | `408`, `429`, and every `5xx`                                           |
| Terminal                       | Every `3xx`, and every other `4xx`                                      |

A record over 256 KiB has its `body` cut and suffixed `... [truncated by lua]` rather than being dropped.

<Note>
  The 448 KiB batch cap is roughly half what the other vendor presets use, and deliberately so: an Axiom event carries the body **twice** — once as `message`, once inside the nested record — so a full batch encodes to about 1.9× the bytes the cap was checked against. Halving the cap is what keeps a full batch inside a 1 MiB request.
</Note>

## Prefer OTLP? Use an `otlp` drain

Axiom accepts OTLP directly, and an `otlp` drain reaches it with no preset of its own: point it at Axiom's OTLP logs endpoint and add `X-Axiom-Dataset` as a drain header.

```bash theme={null}
lua drains create \
  --name "Axiom via OTLP" \
  --type otlp \
  --endpoint https://api.axiom.co/v1/traces \
  --header Authorization \
  --header X-Axiom-Dataset
```

The trade-off is the one on [OpenTelemetry](/drains/opentelemetry): OTLP gives you resource attributes promoted onto every record and a standard wire format, and it gives up the flat `lua.*` shape the native preset produces.

## Test the token with curl

```bash theme={null}
AXIOM_URL='https://api.axiom.co/v1/ingest/agent-logs'
AXIOM_TOKEN='<your Axiom API token>'

curl -sS -i -X POST "$AXIOM_URL" \
  -H "Authorization: Bearer $AXIOM_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '[
    {
      "_time": "2026-09-12T12:59:57.644Z",
      "level": "info",
      "message": "Lua drain connectivity test",
      "lua": { "id": "probe-0001", "eventName": "lua.drain.test", "severityText": "INFO" }
    }
  ]'
```

`200` means accepted; the event shows up in the dataset within a few seconds. Then:

```bash theme={null}
lua drains test drn_8b3f21e5c904a7d61f02bb55
```

## If it isn't working

<AccordionGroup>
  <Accordion title="422 DRAIN_VALIDATION_FAILED on the endpoint">
    The URL does not end in a dataset. It must be the full ingest URL: `https://api.axiom.co/v1/ingest/<dataset>`.
  </Accordion>

  <Accordion title="422 DRAIN_ENDPOINT_NOT_ALLOWED">
    The host does not end in `.axiom.co`. A self-hosted proxy in front of Axiom is not accepted by this type — use a [generic HTTPS](/drains/generic-https) drain for that, which is signed as well.
  </Accordion>

  <Accordion title="Everything lands at the same timestamp">
    That is ingestion time, which means `_time` was not read. Lua always sends it, so check for an ingest transform in Axiom that overwrites it.
  </Accordion>

  <Accordion title="401 on every delivery">
    The stored value must read `Bearer <token>`, prefix included — the header is sent verbatim. Re-enter it with `lua drains update <id> --header Authorization`.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Event schema" href="/drains/event-schema">What every field under `lua` means.</Card>
  <Card title="OpenTelemetry" href="/drains/opentelemetry">The OTLP alternative, and what it trades.</Card>
  <Card title="Protecting your destination" href="/drains/protecting-your-destination">What verification does and does not prove.</Card>
  <Card title="lua drains" href="/reference/cli/drains">Every verb and flag.</Card>
</Columns>
