How to configure Flask logging for production
Configure Flask log levels, handlers, request IDs, exception tracebacks, and JSON output, then verify collection and trace correlation in Guance.
A Flask application already has a logger: app.logger is a standard Python logging.Logger, named after the application. The work is deciding which events to record, where to send them, and how to preserve enough context to investigate a failed request.
This guide covers Flask's defaults, centralized configuration, rotating files, request context, and JSON output. The last step is to check the records after DataKit collects them. A valid JSON line on stdout does not, by itself, prove that the log platform parsed its timestamp or linked it to a trace.
Understand Flask's default log level
Consider this minimal application:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
app.logger.debug("Loading home page configuration")
app.logger.info("Home page requested")
app.logger.warning("Configuration value missing; using default")
app.logger.error("Database connection failed")
return "Hello"
if __name__ == "__main__":
app.run(debug=False)
Without additional configuration, a normal, non-debug application typically inherits the root logger's WARNING level. You will see the last two messages:
[2026-08-25 14:30:22,123] WARNING in app: Configuration value missing; using default
[2026-08-25 14:30:22,124] ERROR in app: Database connection failed
Debug mode changes that behavior: Flask can set an otherwise unconfigured application logger to DEBUG. Do not use output from app.run(debug=True) to infer production logging behavior.
Flask adds a default handler when there is no suitable handler in the logger hierarchy. During a request it writes to the WSGI server's error stream, usually stderr; outside a request it writes to stderr. See the Flask logging documentation.
For a quick level change:
import logging
app.logger.setLevel(logging.INFO)
In an application with several modules and third-party libraries, configure logging centrally instead.
Configure logging before creating the application
Calling dictConfig early avoids having Flask install a default handler before your application configures its own output. A root handler also provides a destination for library loggers that propagate their records.
from logging.config import dictConfig
from flask import Flask
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"]},
})
app = Flask(__name__)
@app.route("/")
def index():
app.logger.info("Home page requested")
return "Hello"
disable_existing_loggers=False keeps existing library loggers enabled. Review their levels separately if they produce too much output.
Useful LogRecord fields include asctime, levelname, name, module, funcName, lineno, and message. For exceptions, pass exc_info=True or call logger.exception(); the formatter appends the traceback. Putting %(exc_info)s in a text format prints the exception tuple rather than providing a properly formatted traceback.
If code has already accessed app.logger, you may need to remove Flask's default handler explicitly to prevent duplicate records:
from flask.logging import default_handler
app.logger.removeHandler(default_handler)
Rotate files on host-based deployments
For an application that writes local files, replace the console handler with a rotating handler. This is a fragment of the dictConfig dictionary above:
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "logs/flask-app.log",
"maxBytes": 20 * 1024 * 1024, # 20 MiB
"backupCount": 10,
"formatter": "default",
"encoding": "utf-8",
}
},
"root": {"level": "INFO", "handlers": ["file"]},
Create the logs directory first and grant the application permission to write to it. The handler keeps the active file and up to ten rotated files, such as flask-app.log.1. TimedRotatingFileHandler provides time-based rotation instead.
A standard rotating file handler does not coordinate writes and rotation across multiple Gunicorn worker processes. Use stdout/stderr and an external collector, or funnel records through a single writer. Python documents this limitation in its logging cookbook.
Add request and response context
Record the request fields you actually need
Within a view, Flask's request object provides the method, path, and request headers:
from flask import request
@app.route("/login", methods=["POST"])
def login():
app.logger.info(
"Login requested: ip=%s method=%s path=%s agent=%s",
request.remote_addr, request.method, request.path,
request.headers.get("User-Agent", "-"),
)
# Authenticate the user here.
return {"message": "Login handler reached"}
Avoid logging credentials, session cookies, or entire request bodies. remote_addr is the address Flask receives from its immediate peer; behind a proxy, only use forwarded client addresses after configuring trusted proxies correctly.
Log the response in one place
An after_request hook avoids duplicating access-log code in every view. Use a monotonic clock for elapsed time so a wall-clock adjustment does not produce a negative or inflated duration.
import time
from flask import g, request
@app.before_request
def start_timer():
g.start_time = time.perf_counter()
@app.after_request
def log_response(response):
started = getattr(g, "start_time", None)
duration = (
round((time.perf_counter() - started) * 1000, 2)
if started is not None else None
)
app.logger.info(
"Request completed: method=%s path=%s status=%s duration_ms=%s ip=%s",
request.method, request.path, response.status_code,
duration, request.remote_addr,
)
return response
This measures application handling through response creation. It does not include delivery of an entire streamed response. Keep the WSGI server's access logs as well if you need server-level request accounting.
Keep request IDs in request-local state
A request ID should identify one request, not the user's session. Store it in g. The following example accepts a bounded identifier from a trusted upstream and otherwise generates one:
import re
import uuid
from flask import g, request
@app.before_request
def assign_request_id():
incoming = request.headers.get("X-Request-ID", "")
g.request_id = (
incoming if re.fullmatch(r"[A-Za-z0-9._-]{1,128}", incoming)
else uuid.uuid4().hex
)
@app.route("/order")
def order():
app.logger.info("Processing order rid=%s", g.request_id)
# Perform the order operation here.
app.logger.info("Order processed rid=%s", g.request_id)
return {"request_id": g.request_id}
If clients can set this header directly, treat it as untrusted correlation data, never as an authentication or authorization claim. You can also generate the ID at your gateway.
To avoid passing the ID to every logging call, attach a logging.Filter to the output handler. The filter should check has_request_context(), set record.request_id from g, and supply a fallback outside requests. Then include %(request_id)s in the formatter. A request ID is separate from a distributed tracing trace_id.
Include tracebacks in exception logs
Inside an exception handler, logger.exception() records the message and the active exception traceback:
@app.route("/pay")
def pay():
try:
process_payment() # Your application's payment function.
except Exception:
app.logger.exception("Payment processing failed")
return {"error": "internal"}, 500
return {"status": "ok"}
It is equivalent to logger.error(..., exc_info=True). Return a controlled error to the client and keep diagnostic details in the logs. Decide which layer owns exception logging so the same failure is not logged repeatedly by the view, framework, and middleware.
Emit JSON for production collection
Install a JSON formatter:
pip install python-json-logger
With current python-json-logger releases, use pythonjsonlogger.json.JsonFormatter. Set it in the formatter configuration and select it on the handler:
"formatters": {
"json": {
"()": "pythonjsonlogger.json.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
}
},
An example record:
{"asctime":"2026-08-25 14:35:10,552","levelname":"INFO","name":"app","message":"Request completed: method=GET path=/api/users status=200 duration_ms=12.4"}
Here the envelope is JSON, but the request details are still embedded in message. If you need to filter or aggregate them, emit them as separate fields:
app.logger.info(
"Request completed",
extra={"http_status_code": 200, "duration_ms": 12.4, "request_id": g.request_id},
)
Check your installed formatter's API against the Python JSON Logger quick start. Older projects may use the legacy pythonjsonlogger.jsonlogger import path.
Collect the logs and verify the fields in Guance
For host-based applications, set logfiles to the actual file paths in DataKit's logging configuration and assign appropriate source and service tags. For containers, configure collection of the application's standard output. The DataKit log collection overview explains the available collection paths.
For the file log collector, enable json_as_fields when you want to extract top-level JSON fields; check the log collector configuration for its mapping and type rules. Alternatively, use Pipeline to parse and normalize the records. Check event time, log severity, HTTP status, request ID, and exception output using both successful and failed requests. Keep HTTP response codes in a distinct field; Guance's standard status field represents log severity.
For application performance monitoring (APM) correlation, instrument the application for tracing and inject the active trace_id into its logs. Confirm that the IDs match in trace details. Also verify service, environment, version, and index mappings where your setup requires them.
Frequently asked questions
Why are my INFO messages missing?
Check both the logger's effective level and the output handler's level. In a non-debug application with default configuration, the inherited level is usually WARNING. Set an explicit INFO level centrally with dictConfig, or set the application logger's level for a quick check.
How do I fix an incorrect timezone?
Python's default formatter uses local time. A custom formatter can set converter = time.gmtime for UTC. Prefer a timestamp that includes an explicit UTC offset, and verify how the collector parses it. The default asctime field alone does not include a timezone.
Should I use app.run() in production?
No. Use a production WSGI server such as Gunicorn or uWSGI. Send application logs to stdout/stderr or an appropriately managed file. With Gunicorn, --access-logfile - sends its access log to stdout for collection.
How do I distinguish several Flask applications?
Assign meaningful source and service values in your collection configuration, or add an application field to the JSON. Query each service after collection to make sure records are attributed correctly.
Related reading
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan