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.logisolates uncaught 500s (thehttp_500marker).make_logs_routerserves those files, paginated and authenticated.
Recap¶
configure_loggingwrites structured JSON to stdout and tologs/, one file per level, each file carrying only its own level.500.logis 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_routermounts a paginatedGET /logsover those files, newest first, so you can read them without shell access to the container.