Logs
A log row in Lumin has six top-level fields and one bag for everything else.
timestamp ISO-8601 with millisecond precisionlevel one of: TRACE DEBUG INFO WARN ERROR FATAL PANICservice free-form string; the unit you filter by in the UImessage the human-readable lineattributes arbitrary JSON, filterable with attr.<key>:<value>trace_id optional; set only by direct /v1/logs callersThere is no session_id column. A session id reaches Lumin inside attributes, which is where the agent puts every field of a structured log line. That is enough to filter on it — see Querying below — but it is not a dedicated column, and neither is span_id.
project_id is also on every row, but it’s set by the ingest layer based on the API key — not something you specify in the body. The body cannot override project_id.
How rows get there
Section titled “How rows get there”There are two ingest paths, neither of which involves an application-side Lumin SDK:
- The agent tails labeled container stdout/stderr. Add
lumin.logs=trueto a container in yourdocker-compose.yml. The agent demultiplexes the Docker log stream, parses JSON-shaped lines, and forwards everything to/v1/logsin 200-line / 1-second batches. - You POST to
/v1/logsdirectly. Use this when you have something that isn’t running as a Docker container or when the agent isn’t appropriate. The wire format is a JSON array of rows; the API key in theAuthorizationheader determines the project.
The agent path is the recommended path for everything that runs in a container.
Structured vs. unstructured
Section titled “Structured vs. unstructured”The agent recognises JSON lines automatically — any stdout line that parses as a JSON object with a level field is treated as structured. Exactly one field is read out of the object: level sets the row’s level. The whole object is then stored as attributes, up to 8 KB.
Three fields do not come from inside the JSON, which is worth knowing before you shape your logger output:
timestampcomes from Docker’s own log timestamp, not from atimeortsfield in the object.messagekeeps the raw line, so a structured line’smessageis the whole JSON text rather than just itsmsgfield.servicecomes from the container name, or from thelumin.servicelabel when you set one.
trace_id is not read out of the object either. The column is populated only when a caller posts it at the top level of a /v1/logs request; the agent does not. A trace_id field inside a JSON line is reachable as attr.trace_id like any other attribute.
Unstructured lines (plain text, e.g. 2026-05-13 14:22:01 ERROR connection refused) fall back to a best-effort level sniff: the agent looks for error|warn|info|debug|fatal|panic|trace in the first 30 characters and tags accordingly. Anything that doesn’t match defaults to INFO.
If your service writes JSON to stdout, you get structured logs for free. If it doesn’t, level filters in the UI still work (because the sniff is good enough for most CLI-shaped output) but you don’t get attributes to query into.
Reliability contract
Section titled “Reliability contract”Logs are best-effort. The contracts you can rely on:
- Successful ingest is durable. Once the API has returned 2xx for a batch, the rows are on disk and live-tail subscribers will see them. The publish-to-subscribers happens after the disk commit, so subscribers never see a row that didn’t make it to storage.
- The loss window on a crash is bounded. Under sustained peak load, an API crash loses at most the rows currently buffered for write — typically under 500 ms of ingest. There is no on-disk spool; SDKs retry on the next flush instead.
- A slow live tail misses rows, it never slows ingest. A tail that cannot keep up skips rows, and it does not mark the gap. Treat a live tail as a view of what is happening now, not as a complete record. The rows stay in storage, so a query over the same range returns all of them.
What you cannot rely on:
- Exactly-once delivery. SDKs retry on transient failures and the API does not deduplicate. Treat ingest as at-least-once.
- Strict ordering across services. Rows within one container’s stream are ordered; rows across containers or hosts may interleave by a few hundred ms.
Live tail
Section titled “Live tail”The Logs UI uses Server-Sent Events to stream new rows as they arrive. The API applies your filters before it sends, so a tail filtered to level = ERROR carries error rows only. A heartbeat is sent every 15 seconds so proxies don’t drop the connection.
A live tail falls behind on a slow network, or when your browser suspends a background tab. Lumin then drops the rows it cannot deliver to that tail. It does not hold them, because a slow viewer must never slow down ingest.
Lumin still stores those rows. Reload the page, or query the same time range, and they are there.
Querying
Section titled “Querying”The Logs page supports filters on level, service, and time range, plus a small search language in the query box:
| Term | Effect |
|---|---|
kafka |
message contains kafka |
"connection refused" |
message contains that exact phrase |
-healthz |
message does not contain healthz |
level:error |
level is ERROR |
service:checkout |
service is checkout |
trace_id:4bf92f… |
the trace_id column matches exactly |
attr.session_id:s_8f21 |
the session_id key in attributes matches exactly |
-attr.env:prod |
env is absent, or is not prod |
fields:session_id,status |
project those JSON paths out of message into table columns |
Terms combine with AND. Attribute matching is exact value, not substring; numbers and booleans compare as their textual form (42, true). A negated attribute term also matches rows that lack the key, so -attr.env:prod does not silently drop every row without an env.
You rarely type attr. by hand. Expand a row in the Logs table and click any attribute in the detail panel — the UI composes the attr.<key>:<value> term and replaces any existing clause on that key, so a second click on the same dimension narrows instead of contradicting.
Every one of these terms also applies to live tail. The SSE handler parses the same query and matches each row before it goes on the wire, so a filtered tail ships only matching rows.
For programmatic access, the read API lives on the app host (app.getlumin.dev/api/...) and is session-authenticated — the same surface the UI uses.