# Logs

> How log rows are shaped, ingested, stored, and live-tailed in Lumin.

Canonical: https://docs.getlumin.dev/concepts/logs/

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

```text
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](#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

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.

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

- **`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.

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

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

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.
