Appearance
Logging
An Airnode writes structured JSON to stdout. That is the whole of it: there is no transport to configure, no endpoint and no credential, and nothing here has to know where you keep logs.
The split is deliberate. An Airnode has an opinion about the shape of a line, because a line nobody can query is not worth writing. It has no opinion about where the line ends up, because stdout is the one sink every host already collects, and deciding for you would mean every operator carrying a log-store credential just to run one.
What a line looks like
A level, a message that never varies, a timestamp, and everything that does vary under ctx:
json
{
"level": "info",
"message": "Performing an operation",
"timestamp": "2026-08-11T09:14:32.118Z",
"ctx": { "requestId": "0f0c1c8e-9b1e-4c1a-9a3f-1b2c3d4e5f60", "operation": "coinPrice" }
}The message is static so it stays greppable, and searching for a value means querying ctx rather than parsing prose out of a string. This is the shape @api3/commons emits, which is what every other API3 service puts in Grafana, so an Airnode needs nothing bespoke to sit alongside them.
A request writes a line when it arrives and another when it leaves, carrying the status. How long it took is the gap between their timestamps rather than a number the Airnode measures. A POST names the operation between the two, which is what answers "what are callers actually asking for" months later.
Both of those lines carry the caller's userAgent, and it is on both rather than only on the arrival so that counting by outcome does not mean joining two lines together. It is null when the caller sent none. Nothing else about the caller is written down: no address, no headers and no body.
The parameters a caller sent are a debug line of their own, since they are the caller's values rather than the Airnode's and can be large. The level defaults to info, so an Airnode you run writes the operation without them.
The integrations API3 deploys each set LOG_LEVEL=debug in their own environment for the duration of early access, because what people ask an Airnode for is what these first months are there to find out. It is set per deployment rather than defaulted in the package, so what one Airnode writes is never decided by what another wanted.
Payments and upstream failures write a line of their own, since an operator counting calls that were performed but never charged cannot recover those from a status code.
Configuration
Three environment variables, all optional:
| Variable | Default | What it does |
|---|---|---|
LOG_FORMAT | json | pretty for a colourised line, for watching a shell. |
LOG_LEVEL | info | debug, info, warn or error. |
LOG_ENABLED | true | false silences the logger, which suits a test run. |
Getting the logs somewhere
That part is yours, and it is the same problem as for anything else you run. Whatever collects stdout on your host already has these: on Fly fly logs shows them, on a container platform your existing collector picks them up, and locally they are in the terminal.
We deploy the integrations on Fly and keep the logs in Grafana Loki. Fly's own log shipper does the moving: it is a small app running Vector that subscribes to your organization's logs and forwards them to a sink, Loki among them. One shipper covers every Airnode in the organization, so adding an integration adds no logging setup at all. It forwards from the moment it connects and never backfills, so it wants to stay running rather than stop when idle the way an Airnode does.
That shipper is the only deployed piece of any of this, and ours lives in the repository as _log-shipper alongside the integrations, underscored because it wraps no API and is not one.