Skip to content

Logs

A log row in Lumin has six top-level fields and one bag for everything else.

timestamp ISO-8601 with millisecond precision
level one of: TRACE DEBUG INFO WARN ERROR FATAL PANIC
service free-form string; the unit you filter by in the UI
message the human-readable line
attributes arbitrary JSON, filterable with attr.<key>:<value>
trace_id optional; set only by direct /v1/logs callers

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

There are two ingest paths, neither of which involves an application-side Lumin SDK:

  1. The agent tails labeled container stdout/stderr. Add lumin.logs=true to a container in your docker-compose.yml. The agent demultiplexes the Docker log stream, parses JSON-shaped lines, and forwards everything to /v1/logs in 200-line / 1-second batches.
  2. You POST to /v1/logs directly. 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 the Authorization header determines the project.

The agent path is the recommended path for everything that runs in a container.

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:

  • timestamp comes from Docker’s own log timestamp, not from a time or ts field in the object.
  • message keeps the raw line, so a structured line’s message is the whole JSON text rather than just its msg field.
  • service comes from the container name, or from the lumin.service label 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.

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.

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.

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.