Skip to content

Logging

In this recipe you set up structured JSON logs with per-request correlation, one file per severity level, and an HTTP endpoint to read them back. The goal is that every log line is parseable, traceable to the request that produced it, and inspectable without SSHing into the server.

configure_logging installs a JSON handler on the root logger that emits one-line JSON records carrying the active request ID. LogUtils is a thin facade that adds level methods accepting structured **fields.

from tempest_fastapi_sdk import LogUtils, configure_logging
from tempest_fastapi_sdk.core import get_request_id

from src.db.models import UserModel


def risky() -> None:
    """Blow up, so the log shows a real traceback."""
    raise RuntimeError("boom")


user = UserModel(name="Ana", email="ana@example.com")


# Imperative — call once during bootstrap.
configure_logging(level="INFO", json_output=True)

# Facade — handy for service-wide singletons.
log = LogUtils("app.users", level="INFO")
log.info("user_created", user_id=str(user.id), email=user.email)
log.warning("login_throttled", ip="1.2.3.4", attempts=5)

try:
    risky()
except RuntimeError:
    log.exception("risky_failed", op="reconcile")  # appends traceback

# Surface the correlation ID outside the log line if needed.
request_id = get_request_id()

Adopting it in a service that already logs %-style

The level methods take logging's positional arguments, so existing call sites move over without a rewrite — and keep lazy interpolation and the stable template a log tool groups on:

from tempest_fastapi_sdk import LogUtils

log = LogUtils("app.email", level="INFO")

log.info("Email sent to %s", "ana@example.com")
log.error("Sending to %s failed: %s", "bruno@x.com", "timeout")

log.error("Sending to %s failed: %s", "bruno@x.com", "timeout", op="send")

The last line shows the two styles coexisting: the positionals build the message, and **fields still becomes top-level keys on the JSON.

funcName/lineno point at your call site, not inside the facade — the default is stacklevel=2. Wrapping LogUtils in a layer of your own? Pass stacklevel=3 (or more) to walk past the extra frames.

JSON output (single line — formatted here for readability):

{
  "timestamp": "2026-05-16T20:14:33.412Z",
  "level": "INFO",
  "logger": "app.users",
  "message": "user_created",
  "request_id": "d83e4b0c-7c2f-4bd6-aaa1-7d4f6cf5e5e9",
  "user_id": "9c1a5b2d-...",
  "email": "ana@example.com"
}

The middleware accepts a custom header name (RequestIDMiddleware(app, header_name="X-Correlation-ID")); the same header is echoed back on every response.

Per-level files + isolated 500.log

By default the SDK writes to stdout AND to logs/ (one JSON file per level) at the same time. Each file receives only its own level (exact match — an ERROR never lands in warning.log), so every severity becomes an isolated, greppable stream.

from tempest_fastapi_sdk import configure_logging

# Defaults — stdout + logs/{debug,info,warning,error,critical,500}.log
configure_logging(level="INFO")

# Custom directory
configure_logging(level="INFO", log_dir="/var/log/myapp")

# Disable file output (stdout-only — handy for serverless / read-only FS)
configure_logging(level="INFO", file_output=False)

# Disable stdout (sidecar tails from disk)
configure_logging(level="INFO", stdout=False)

# Growth ceiling: each file rotates at ~10 MB, keeping 5 generations
configure_logging(level="INFO", max_bytes=10_000_000, backup_count=5)

# No rotation — when the host's logrotate (or a sidecar) owns retention
configure_logging(level="INFO", max_bytes=0)

Files rotate by default — here is why

A plain FileHandler grows without bound. On a service that logs one line per request, running on a long-lived host, info.log is what fills the disk — and a full disk takes the service down along with anything else sharing the partition. So the default is a RotatingFileHandler with max_bytes=10_000_000 and backup_count=5: ~60 MB per level, hard cap.

The other half of this pair already had its ceiling: make_logs_router reads at most 20k records per file, added after a service whose log directory had grown to gigabytes answered with a dead worker. This is the writing half.

Rotated files (info.log.1, info.log.2, …) are not read by /logs: the endpoint reads the exact names, so it shows the current window. Longer retention is a collector's job.

Don't disable both

configure_logging(stdout=False, file_output=False) raises ValueError — silencing every handler leaves the application blind.

File logging is best-effort — it never crashes startup

If log_dir cannot be created or its files cannot be opened (read-only filesystem, missing write permission, hardened container, serverless, CI), the SDK skips the file handlers, emits a warning (to the logger when stdout is on, otherwise straight to stderr) and keeps running with stdout only — instead of dying at import with PermissionError: [Errno 13] ... 'logs'. Pass file_output=False to opt out of file logging explicitly.

On disk:

logs/
├── debug.log      # only DEBUG records
├── info.log       # only INFO records
├── warning.log    # only WARNING records
├── error.log      # only ERROR records (a 500 lands here too)
├── critical.log   # only CRITICAL records
└── 500.log        # only uncaught-500 records (isolated)

500s are grave — that's why they get their own file

The catch-all handler registered by register_exception_handlers flags every uncaught exception with the http_500=True extra. configure_logging(log_dir=...) routes those records to a dedicated 500.log in addition to error.log. The gravest failure is never buried among the other errors.

Always in the logs, never in the body

The traceback goes to the files/terminal via logging — not to the response body. A 500 body is just the generic envelope ({"detail": "Internal server error", "code": "INTERNAL_SERVER_ERROR"}). See HTTP layer for the log_traceback / include_traceback flags.

Files are always JSON

File handlers use JSONFormatter regardless of json_output, so the /logs endpoint can parse them back. json_output only controls the stdout format.

In the scaffold the directory comes from LOG_DIR (defaults to "logs"; set it empty to disable file logging). Add logs/ to your .gitignore.

Reading logs over HTTP — make_logs_router

make_logs_router mounts GET /logs, which parses the on-disk JSON files and returns a paginated BasePaginationSchema[LogEntrySchema] (newest first).

from fastapi import FastAPI

from tempest_fastapi_sdk import make_logs_router

from src.core.settings import settings

app = FastAPI()


app.include_router(
    make_logs_router(log_dir="logs", token_secret=settings.TOKEN_SECRET),
)

Protect the endpoint in production

The payload exposes tracebacks and request metadata. The endpoint is gated by a shared-secret X-Token header via make_token_dependency. An empty TOKEN_SECRET disables the check (dev only) — never expose /logs unauthenticated in production.

Query examples:

# Latest 20 records across every level
curl -H "X-Token: $TOKEN_SECRET" "http://localhost:8000/logs"

# Only the isolated 500s, page 1, 50 per page
curl -H "X-Token: $TOKEN_SECRET" "http://localhost:8000/logs?source=500&page_size=50"

# Errors mentioning "timeout" in a time window
curl -H "X-Token: $TOKEN_SECRET" \
  "http://localhost:8000/logs?source=error&q=timeout&start=2026-05-31T00:00:00Z"

Query parameters:

Parameter Values Description
source all (default), debug, info, warning, error, critical, 500 Which file to read. all merges every level; 500 returns only the isolated 500s.
q text Case-insensitive substring match on the message.
start / end ISO-8601 Limit records to a time window. A value with no offset (2026-05-31T00:00:00, or a bare date) is read as UTC.
page / page_size integers Pagination (1-indexed).

The read is bounded per file

Each request reads the newest 20,000 records of every selected file (DEFAULT_MAX_RECORDS_PER_FILE), not the whole file. The endpoint sorts newest-first and paginates, so what was left out was unreachable anyway — and without the bound a multi-gigabyte log directory went into memory whole on every request, taking the worker down before it answered. Tune it with make_logs_router(max_records_per_file=...); when the cap bites, a WARNING is logged naming the source.

Recap

  • configure_logging(log_dir=...) → stdout + one file per level.
  • Exact-level routing: each file holds only its own severity.
  • 500.log isolates uncaught 500s (the http_500 marker).
  • make_logs_router serves those files, paginated and authenticated.

Recap

  • configure_logging writes structured JSON to stdout and to logs/, one file per level, each file carrying only its own level.
  • 500.log is isolated on purpose: the file you open first during an incident does not arrive mixed with everything else.
  • The request id lands on every line, so a user complaint becomes a grep — that is what separates structured logging from pretty logging.
  • make_logs_router mounts a paginated GET /logs over those files, newest first, so you can read them without shell access to the container.