---
title: "Structured logging with a log format and log levels you can search by field"
description: "Structured logging puts each value in its own field instead of inside a sentence. We sent 1 order 3 ways to LogBrew and show which log format you can find again by order_id."
canonical: "https://logbrew.co/en/guides/structured-logging"
last_updated: "2026-11-10"
---

# Structured logging with a log format and log levels you can search by field

Structured logging puts each value in its own field instead of inside a sentence. We sent 1 order 3 ways to LogBrew and show which log format you can find again by `order_id`.

Tested on November 10, 2026 with Python 3.13.5 and logbrew-sdk 0.1.29

By [Furkan Erday](https://github.com/furkanerday). Furkan Erday is the founder of LogBrew. He builds LogBrew with Claude Code and Codex every day.

Canonical: https://logbrew.co/en/guides/structured-logging

## Which log line you can find again by `order_id`

| Where the id went | Text search | Field filter |
| --- | --- | --- |
| Inside the message text | Found | Not found |
| In `extra={}` fields | Not found | Found |
| In a dict inside `extra={}` | Not found | Not found |

We sent 1 order 3 ways through the Python handler, then searched for it. A full mug means the search found the line.

## Choose a log format you can filter

### Keep the message fixed, put the values in fields

A field keeps its name and its type. A value inside the message text is only a substring. In Python, pass the fields in `extra={}`, and the message stays the same for every order.

```bash
log.info("order paid", extra={"order_id": "ord_42", "amount_cents": 1999})
```

### Write JSON lines when a program reads the log

A `Formatter` subclass turns each record into 1 JSON object per line, with the standard fields and every `extra` value. A log collector parses the line without a regex.

```bash
import json
import logging

RECORD_FIELDS = set(vars(logging.LogRecord("", 0, "", 0, "", (), None))) | {"message", "asctime"}


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        line = {
            "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        line.update({k: v for k, v in vars(record).items() if k not in RECORD_FIELDS})
        if record.exc_info:
            line["exception"] = self.formatException(record.exc_info)
        return json.dumps(line)


handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
log = logging.getLogger("taproom")

log.info("order paid", extra={"order_id": "ord_42", "amount_cents": 1999})
log.warning("keg below 10%", extra={"keg": "ipa", "percent": 8})
```

Output

```text
{"time": "2026-11-10T08:34:04+0300", "level": "INFO", "logger": "taproom", "message": "order paid", "order_id": "ord_42", "amount_cents": 1999}
{"time": "2026-11-10T08:34:04+0300", "level": "WARNING", "logger": "taproom", "message": "keg below 10%", "keg": "ipa", "percent": 8}
```

### Send the fields to LogBrew

`LogBrewLoggingHandler` sends each record with its `extra` values as fields. We sent the same order 3 ways, inside the message text, as `extra` fields, and as a dict inside `extra`.

```bash
import logging
import os

from logbrew_sdk import HttpTransport, LogBrewClient, LogBrewLoggingHandler

client = LogBrewClient.create(api_key=os.environ["LOGBREW_SERVER_API_KEY"])
transport = HttpTransport()
log = logging.getLogger("taproom")
log.addHandler(LogBrewLoggingHandler(client, transport, metadata={"service": "taproom"}))
log.setLevel(logging.INFO)

logging.getLogger("taproom.text").info("order %s paid, %d cents", "ord_42", 1999)
logging.getLogger("taproom.fields").info(
    "order paid", extra={"order_id": "ord_42", "amount_cents": 1999}
)
logging.getLogger("taproom.nested").info(
    "order paid", extra={"order": {"id": "ord_42", "amount_cents": 1999}}
)

response = client.flush(transport)
print("HTTP", response.status_code, "accepted", response.accepted_events, "logs")
```

Output

```text
HTTP 200 accepted 3 logs
```

### Find the line again by field

`GET /api/logs` takes `attribute_filters` with a field path, a type, an operator, and a value. The filter on `order_id` found only the line sent with `extra`. The dict inside `extra` was dropped, because the handler keeps strings, numbers, booleans, and `None` only.

```bash
curl -sS -G "https://api.logbrew.co/api/logs" \
  -H "Authorization: Bearer $LOGBREW_TOKEN" \
  --data-urlencode "project_id=$LOGBREW_PROJECT" \
  --data-urlencode "since=2026-11-10T05:31:33Z" \
  --data-urlencode 'attribute_filters=[{"path":"/metadata/order_id","type":"string","operator":"eq","value":"ord_42"}]' \
  | jq -c '.[] | {message, order_id: .attributes.metadata.order_id}'
```

Output

```text
{"message":"order paid","order_id":"ord_42"}
```

## Use log levels the same way in every service

### Use 4 levels in production

`info` for what happened, `warning` for what went wrong but was handled, `error` for a failed request or job, and `critical` for what stops the service. Keep `debug` for local runs. The LogBrew handler sends DEBUG records as `info`.

### Set the level once, at startup

Read the level from the environment, so 1 host can run at DEBUG while the rest stay at INFO. In Python, `logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))` does it.

### Log numbers as numbers

`amount_cents: 1999` keeps its type, so the filter `amount_cents >= 1000` matches it. We also sent `amount_cents: "1999"` as text, and the number filter skipped that line.

```bash
curl -sS -G "https://api.logbrew.co/api/logs" \
  -H "Authorization: Bearer $LOGBREW_TOKEN" \
  --data-urlencode "project_id=$LOGBREW_PROJECT" \
  --data-urlencode "since=2026-11-10T05:31:33Z" \
  --data-urlencode 'attribute_filters=[{"path":"/metadata/amount_cents","type":"number","operator":"gte","value":1000}]' \
  | jq -c '.[] | {message, amount_cents: .attributes.metadata.amount_cents}'
```

Output

```text
{"message":"order paid","amount_cents":1999}
```

## What we saw when we tested it

We sent 1 order 3 ways through the Python handler, then searched for it with the LogBrew API. The text search `search=ord_42` matched 1 of the 3 lines, the one with the id inside the message.

The filter `order_id = ord_42` matched 1 line, the one sent with `extra={"order_id": "ord_42"}`. The filter `amount_cents >= 1000` matched the same line, because the handler kept 1999 as a number.

The line sent with `extra={"order": {...}}` matched nothing. The handler sends strings, numbers, booleans, and `None` from `extra` and skips a dict or a list.

```bash
curl -sS -G "https://api.logbrew.co/api/logs" \
  -H "Authorization: Bearer $LOGBREW_TOKEN" \
  --data-urlencode "project_id=$LOGBREW_PROJECT" \
  --data-urlencode "since=2026-11-10T05:31:33Z" \
  --data-urlencode "search=ord_42" \
  | jq -c '.[] | {message, order_id: .attributes.metadata.order_id}'
```

Output

```text
{"message":"order ord_42 paid, 1999 cents","order_id":null}
```

## Plain text vs JSON log format

A plain `%(message)s` format drops every `extra` value. The line below ends in `INFO order paid` with no order id, so nobody can find the order later.

A JSON line keeps the fields and their types, and a log collector parses it without a regex. A person reads it less easily, so keep the message short and the field names stable.

Python has no built-in JSON formatter. python/cpython#77691 asked for one in 2018 and was closed, because the docs already show how to write one. The 15-line `Formatter` above is that recipe.

```bash
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("taproom")

log.info("order paid", extra={"order_id": "ord_42", "amount_cents": 1999})
```

Output

```text
2026-11-10 08:34:04,955 INFO order paid
```

## What LogBrew records

Each log keeps its level, logger name, and every primitive field from `extra`, plus the fields in `metadata={}` on the handler. The API filters by field path, type, and operator. Text takes `eq`, `ne`, `contains`, and `starts_with`, and numbers take `gt`, `gte`, `lt`, and `lte`.

LogBrew does not index a field whose name contains `password`, `secret`, `token`, `cookie`, or `authorization`, so a filter never matches it. Keep secrets out of log lines anyway.

A log sent inside a trace carries the trace and span ids, so a filter by `order_id` leads to the request that produced the line.

## Questions people ask

### What is structured logging?

Logging where each value is a named field with a type, as JSON or as fields on a log record, instead of words inside a sentence. A search tool then filters by the field, not by substring.

### What are the log levels in order?

DEBUG, INFO, WARNING, ERROR, CRITICAL, with the numbers 10 to 50 in Python. A logger set to INFO passes INFO and above and drops DEBUG.

### Should I log in JSON or plain text?

JSON when a program reads the lines, such as a collector, a file, or a search tool. Plain text when only a person reads the terminal. Keep the same fields in both.

### How do I add fields to a Python log line?

Pass `extra={"order_id": "ord_42"}` to the log call. The standard formatter ignores the extra fields. A JSON formatter or the LogBrew handler keeps them.

### Can I search logs by field in LogBrew?

Yes. `GET /api/logs` takes up to 4 `attribute_filters`, each with a field path, a type, and an operator such as `eq` or `gte`.

## Filter your logs by any field

LogBrew keeps every field you log and finds the line again by field, next to the trace and the issue it belongs to. Start on the generous free tier, no card needed.

[Start Free](https://logbrew.co/en)

## Sources

- [Python docs, Logging Cookbook](https://docs.python.org/3/howto/logging-cookbook.html)
- [Python docs, LogRecord attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes)
- [python/cpython#77691](https://github.com/python/cpython/issues/77691)

## More guides for agents and Python

- [Add an MCP server to Claude Code and Codex](https://logbrew.co/en/guides/mcp-server-claude-code-codex)
- [Use AGENTS.md in Claude Code and Codex](https://logbrew.co/en/guides/agents-md)
- [Fix the Claude overloaded error 529 and rate limit error 429](https://logbrew.co/en/guides/claude-overloaded-rate-limit-error)
- [Set up Python logging with the right log levels and exception capture](https://logbrew.co/en/guides/python-logging)
- [How to fix errors in Claude Code, Codex, JavaScript, and Python](https://logbrew.co/en/guides)
