Configure FastAPI logging with request context and JSON output

Configure Python and Uvicorn loggers, add ASGI request logging, preserve exception tracebacks, and validate structured logs and trace correlation in Guance.

Best practices
Cover illustration for structured logging in FastAPI

A FastAPI service can print Uvicorn startup messages while silently dropping your application's logger.info() calls. The two use different logger configurations. Uvicorn configures its own server loggers; your application still needs an explicit logging policy.

This guide builds that policy with Python's standard logging module, adds request-level context with ASGI middleware, and produces JSON that a collector can parse without extracting fields from a message string. It also covers exception tracebacks and the checks needed to correlate logs with traces in Guance.

Understand what happens before configuration

Consider this application:

import logging
from fastapi import FastAPI

logger = logging.getLogger(__name__)
app = FastAPI()

@app.get("/")
def index():
    logger.info("Home page requested")
    return {"msg": "hello"}

Under a typical Uvicorn configuration, the application message does not appear. A named logger without its own level inherits the root logger's default WARNING threshold, which filters out INFO.

That does not mean Python discards every unconfigured log. Module-level functions such as logging.warning() can initialize a basic root handler, and Python also has a last-resort handler for warnings and above when no handler is available. Relying on those fallbacks is a poor application configuration, but their behavior matters when diagnosing missing output. Python logging behavior

FastAPI does not provide Flask's app.logger interface or Django's LOGGING setting. Use named Python loggers and configure levels, destinations, and formatting deliberately.

Configure application logging with dictConfig

Load the configuration once when each application process starts:

import logging
from logging.config import dictConfig
from fastapi import FastAPI

dictConfig({
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "default": {
            "format": "%(asctime)s %(levelname)s [%(name)s] %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "stream": "ext://sys.stdout",
            "formatter": "default",
        },
    },
    "root": {"level": "INFO", "handlers": ["console"]},
})

logger = logging.getLogger("myapp")
app = FastAPI()

@app.get("/")
def index():
    logger.info("Home page requested")
    return {"msg": "hello"}

disable_existing_loggers=False preserves existing non-root loggers that are not explicitly named in this configuration. Leaving it out uses the default, True, which can disable loggers created by Uvicorn or libraries before configuration runs. It does not itself change their formatters or handlers. Logging configuration reference

The myapp logger propagates records to the root handler. It does not need another console handler. Attaching handlers at both levels can produce duplicate output.

The stream is explicit because StreamHandler otherwise defaults to standard error. Either destination can be valid; choose one that matches your container and collection configuration.

Decide who owns access logs

Uvicorn emits access records through uvicorn.access. Add these entries to the same configuration dictionary to give its access and error logs your console formatter:

"loggers": {
    "uvicorn.access": {
        "level": "INFO",
        "handlers": ["console"],
        "propagate": False,
    },
    "uvicorn.error": {
        "level": "INFO",
        "handlers": ["console"],
        "propagate": False,
    },
},

You can also maintain the configuration in a supported file and pass it with uvicorn main:app --log-config logging.json. Keep one clear owner for startup logging so a later configuration does not unexpectedly replace an earlier one.

If custom middleware will produce the access record you need, disable Uvicorn's access logging with --no-access-log, or raise the uvicorn.access threshold to WARNING. Otherwise each request can produce two access records with different schemas.

Record request fields in ASGI middleware

Middleware keeps HTTP logging out of individual route handlers. This example uses time.perf_counter() for elapsed time and stores request attributes as actual record fields:

import logging
import time
from starlette.types import ASGIApp, Receive, Scope, Send

logger = logging.getLogger("myapp.requests")

class LoggingMiddleware:
    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        started = time.perf_counter()
        status_code = 500
        response_started = False

        async def send_wrapper(message):
            nonlocal status_code, response_started
            if message["type"] == "http.response.start":
                status_code = message["status"]
                response_started = True
            await send(message)

        try:
            await self.app(scope, receive, send_wrapper)
        finally:
            client = scope.get("client")
            logger.info(
                "HTTP request finished",
                extra={
                    "http_method": scope["method"],
                    "http_path": scope["path"],
                    "http_status": status_code,
                    "duration_ms": round(
                        (time.perf_counter() - started) * 1000, 2
                    ),
                    "client_ip": client[0] if client else None,
                    "response_started": response_started,
                },
            )

app.add_middleware(LoggingMiddleware)

The final log is emitted when the downstream ASGI application finishes or raises. It measures that application's lifetime, including a streaming response, rather than just time to response headers. A failure after headers have been sent can still have a recorded status of 200; status alone does not prove that the full response reached the client. response_started also distinguishes the fallback 500 value from a status actually observed by the middleware.

The fields passed through extra appear only if the formatter includes them. The text formatter above prints the message but not those additional attributes. The JSON formatter below makes them independently queryable.

To add a request ID, generate or validate one at the request boundary, store it in scope["state"] or a response header, and use contextvars to make it available to application logs. Reset the context variable in finally so request context does not leak into later work. Treat client-supplied IDs as untrusted input and bound their length.

Keep the traceback when logging an exception

logger.exception() records an error and includes the current exception traceback:

@app.get("/pay")
def pay():
    try:
        process_payment()  # Your application's payment function.
    except Exception:
        logger.exception("Payment processing failed")
        raise

Use a global handler when you need a consistent response for otherwise unhandled exceptions:

from fastapi.responses import JSONResponse

@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    logger.error(
        "Unhandled exception during %s %s",
        request.method,
        request.url.path,
        exc_info=(type(exc), exc, exc.__traceback__),
    )
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal Server Error"},
    )

The explicit exception tuple ensures this handler formats the exception it received. Decide where an exception should be logged: a route handler, the global handler, and Uvicorn may all report the same failure. Keep enough context without turning one incident into several indistinguishable errors. Do not return the traceback to clients.

Output JSON with queryable request fields

A JSON formatter lets you search by HTTP status, duration, and service without parsing the message text. One option is python-json-logger:

pip install python-json-logger

For current package versions, the formatter can be configured as:

"formatters": {
    "json": {
        "()": "pythonjsonlogger.json.JsonFormatter",
        "format": "%(asctime)s %(levelname)s %(name)s %(message)s",
    },
},

Select "formatter": "json" on the handler. Confirm the formatter's import path against the package version pinned by your application. Python JSON Logger quick start

For a small, controlled schema, a standard-library formatter is also sufficient. Put this in logging_config.py:

import json
import logging
from datetime import datetime, timezone

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            "timestamp": datetime.fromtimestamp(
                record.created, timezone.utc
            ).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for field in (
            "http_method", "http_path", "http_status", "duration_ms",
            "client_ip", "response_started", "request_id", "trace_id",
        ):
            if hasattr(record, field):
                payload[field] = getattr(record, field)
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)

Reference it with "()": "logging_config.JsonFormatter" in the formatter configuration. Keeping the formatter in a separate module avoids importing the application module while its logging configuration is still being initialized.

A request record then looks like this:

{"timestamp":"2026-08-25T06:40:11.000+00:00","level":"INFO","logger":"myapp.requests","message":"HTTP request finished","http_method":"GET","http_path":"/api/users","http_status":200,"duration_ms":8.5,"response_started":true}

JSON encodes traceback line breaks inside the exception string, keeping each event on one physical output line. Do not add request bodies, authorization headers, or other sensitive content to the schema without an explicit collection policy.

Validate collection and trace correlation in Guance

First establish where the application writes. Use container log collection for container output, or configure file paths, source, and Pipeline rules through the DataKit log collector.

Send a request through the middleware and inspect the collected event. Check the timestamp, numeric HTTP status and duration, severity, and exception field. HTTP status and log severity are different attributes and should remain separate.

Then trigger a known failing request. Find its event by service and path before building a log-detection rule. Test the detection window and threshold using data you know should match.

For log-to-trace navigation, include the active trace ID supplied by your instrumentation and verify that it matches the stored trace. Check the relevant service, environment, version, and log-index mappings as well. Installing tracing instrumentation does not automatically make a custom formatter serialize every correlation field. Guance APM FAQ

Frequently asked questions

Why does logger.info() produce no output?

Check the logger's effective level, the handler's level, and whether an appropriate handler receives the record. Python defaults to WARNING; configure the application with dictConfig() or basicConfig() before expecting INFO output. Uvicorn startup logs do not establish that application logging is configured.

Why did Uvicorn logs disappear after dictConfig()?

Existing non-root loggers may have been disabled by the default disable_existing_loggers=True. Set it to False, or explicitly configure the Uvicorn loggers you intend to use. Also check their handlers and propagation settings.

Can logging block an async route?

Yes. Standard handlers perform synchronous work. Even stdout can block under pipe backpressure, so it is not a blanket guarantee of cheap logging. For a workload where logging affects latency, consider QueueHandler and QueueListener to move handler I/O to a background thread. Define what happens when a bounded queue fills, and stop the listener cleanly during shutdown. Queue-based logging handlers

Can FastAPI and Flask use the same logging setup?

They can share dictConfig, a JSON schema, and an output destination because both work with Python logging. Validate access records, timestamp formats, and exceptions separately for each framework before reusing collection rules.

Related reading

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting