Logs

Two logs, and they are not read at the same pace.

operational log    startup, reloads, an upstream going down
                   on standard error, a few thousand lines/day

access log         one line per request, refusals included
                   on standard output, 35 million/day at 400 req/s

A single stream carries both, and every line is a typed object: a collector routes them on the type field, to two destinations and two retentions.

The operational log

MEERKAT_LOG_LEVEL    debug | info | warn | error      info by default
MEERKAT_LOG_FORMAT   json | text                      text by default; JSON for an agent is
                                                      Infra, OpenTelemetry (written for an agent)

A typo in the level falls back to info rather than stopping a start.

In the console

The Logs screen: the level at the top right, the level filters and the search, then the lines, one of them open

Meerkat, Logs (root and infra admin) shows the last 5,000 lines live. The gateway keeps them structured, so the screen lays them out whatever the output format: time, level, message, attributes. Click a line to open it. Filter by level, search the text, and export what is shown as .jsonl. Scroll up and the screen stops following; the arrow brings it back.

The level at the top right applies at once, on every node. A level more talkative than the startup one goes back on its own after 30 minutes, and the screen counts down. It is not stored: a restart comes back at MEERKAT_LOG_LEVEL.

One node In a cluster, the lines are those of the node that answered, and the screen names it. The whole cluster's log is the collector's.

The access log is not on this screen: it would push everything else out in a few seconds. Metrics and the traces answer it.

The access log

Off by default: at four hundred requests a second, that is thirty-five million lines a day. It is switched in the console, Infra, OpenTelemetry, Logs tab, Access log - live, on every node. (MEERKAT_ACCESS_LOG, which used to turn it on, is no longer read.)

One line, as it comes out:

{"time":"2026-09-22T09:14:07Z","type":"access","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
 "method":"GET","path":"/patients/42","endpoint":"/patients/{id}","route":"dmp-api",
 "status":200,"ms":45,"outcome":"ok","user":"dr.martin","ip":"10.0.0.7"}
type access or app: the discriminator a collector splits the two logs on
trace_id the join key with your service's business audit
path the path as it was asked for, identifiers included
endpoint the template, when the route declares a spec - which is what makes these lines countable
user the account as this gateway authenticated it, never as a header claimed it
token the API token's name when a machine called: one account can hold several
ip the address resolved by the gateway, never an X-Forwarded-For written by the caller
outcome ok, refused, rejected, failed, upstream-down

outcome separates refused from failed: a 403 is the gateway doing its job, a 502 is a service that went down. Reading both under the same word buries the first in the second on the morning an upstream lets go.

Turning the operational level down does not silence this log: cutting the noise must not carry the audit trail away.

The line no service can write in your place A service that was refused never saw the call, and of the caller it knows only what the gateway told it. The gateway is what authenticated, so the register that holds up is the one it keeps - including for an application nobody can modify any more.

What is not in it: the body. POST /orders/12/validate is recorded; "exceptional 40% discount" is for your service to write, under the same trace_id, in its own audit.

MEERKAT    who, when, from where, with which identity, on which endpoint,
           with what result
  |
  |  trace_id: 4bf92f35...
  v
SERVICE    what the operation meant, on which data, with which values

Nothing is written to a file: the runtime already rotates what a container writes on its standard output.

Remember to bound Docker's logs The json-file driver has no limit by default, and thirty-five million lines a day fill a disk. `` docker run --log-opt max-size=50m --log-opt max-file=5 `` Kubernetes does it on its own: 10 Mi, 5 files.

In OpenTelemetry's format

Infra, OpenTelemetry, tab Logs, mode Written for an agent. Both logs keep their outputs and change their shape, live, on every node. One object per line, with the field names of OpenTelemetry's log data model:

{"timestamp": "2026-10-01T16:35:37.296Z", "severity_text": "WARN", "severity_number": 13,
 "body": "the upstream is down", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
 "attributes": {"route": "billing", "upstream.host": "billing:8080"},
 "resource": {"service.name": "meerkat", "service.version": "..."}}

Nothing is pushed. A Collector running as a DaemonSet reads every container's output and decodes these fields, so a line lands in Loki with its severity, trace and attributes. No collector yet? on the OpenTelemetry page gives that agent's configuration. This mode is in both editions: it writes, it does not export.

Or pushed to the collector

The third mode, Pushed to the collector, sends both logs over OTLP to the collector address (Enterprise), for nodes with no agent: a bare Docker host, a VM. The outputs keep the format chosen at startup. Only one mode at a time: with an agent reading the outputs too, every line would arrive twice.

The push has its own queue, never the audit's: a flood of logs cannot cost the trail an event. A batch the collector refuses three times is dropped and counted, and the tab says how many.