Cloud Platforms & Monitoring
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9 and Python 3.13, checked on 2026-10-07; the examples were executed with those versions plus boto3 1.43, opentelemetry-sdk 1.45.1 with opentelemetry-instrumentation-flask 0.66b1, prometheus-client 0.26.0 and sentry-sdk 2.71.0, and nothing talks to a real network or cloud account. Flask 3.2 is not released yet; the context rework it announces does not change anything on this page, but request-aware log filters and instrumentation hooks are the code to re-test first after upgrading. Once the Bookshelf application works on a laptop, two questions remain: where does it run and how do we see what it is doing there. The Flask documentation answers the first with a short list of hosting platforms and the second not at all, so this page does both, and keeps to the Flask-specific parts: what each platform expects from a WSGI application, which Flask and Werkzeug features make that painless, and which monitoring tool plugs into Flask at which point. Everything that is not specific to Flask (accounts, networking, billing, the platform’s own console) lives in the cloud section of this site, and building the container image is covered by the Docker and Kubernetes sections.
What Every Platform Expects
The platforms differ in how you configure them, but they ask the application for the same four things, because they all run a WSGI server (almost always Gunicorn) in front of the application and put their own proxy in front of that.
| The platform needs | What that means for a Flask application |
|---|---|
An importable WSGI callable |
A module that defines a |
A port to listen on |
The port arrives in the |
Configuration and secrets from the outside |
The image or source bundle must be the same in every environment; settings come from environment variables, mounted secret files or a secret manager (see Secrets and Configuration). |
Logs on standard output |
The platform collects what the process writes to stdout and stderr. A log file inside the container disappears with the instance. |
A way to tell that the instance is healthy |
A cheap HTTP route that answers 200 when the process can serve requests (see Health Checks). |
A stateless process |
Instances start, stop and multiply at any time. Local disk, in-process caches and in-memory sessions are per instance and are lost. |
A platform-ready entry module is short. The factory pattern of the previous pages makes it a three-line file, and
from_prefixed_env() (see Configuration) turns FLASK_-prefixed environment variables
into configuration keys, so the platform’s settings screen is the configuration file.
import os
import secrets
from flask import Flask
# Demo only: the platform injects this variable in real life (see the secrets section).
os.environ.setdefault("FLASK_SECRET_KEY", secrets.token_hex())
def create_app(overrides: dict | None = None) -> Flask:
app = Flask(__name__)
app.config.from_prefixed_env() # FLASK_SECRET_KEY, FLASK_STORAGE_BUCKET, ...
app.config.update(overrides or {})
@app.get("/")
def index():
# K_SERVICE and K_REVISION are set by Cloud Run; other platforms have their own names.
return {
"service": os.environ.get("K_SERVICE", "bookshelf"),
"revision": os.environ.get("K_REVISION", "local"),
}
return app
# wsgi.py: the module every platform imports.
app = create_app()
application = app # Elastic Beanstalk and mod_wsgi look for this name
def listen_port(default: int = 8080) -> int:
return int(os.environ.get("PORT", default))
client = app.test_client()
print(client.get("/").get_json())
print(bool(app.config["SECRET_KEY"]), listen_port())
os.environ["PORT"] = "9000"
print(listen_port())
Source: Flask: Deploying to Production
Never end that module with app.run() unguarded, and never start the production process with flask run or with the
debugger on. The Flask documentation is explicit that the development server and the debugger are for local development
only; PythonAnywhere’s help page adds a practical reason, because an unguarded app.run() runs on import and crashes the
site with a 502 or 504. The server you start instead (Gunicorn, Waitress, uWSGI, mod_wsgi) and the reverse proxy in front of
it are the subject of the Flask deployment pages of this section that follow; here it is enough to say that all the platforms
below hide that choice behind a start command.
The Hosting Platforms at a Glance
The Flask documentation lists five hosting platforms, and the books add two more that matter for migration questions. The table keeps only what a Flask developer must know; the linked pages of the cloud section cover the platform itself.
| Platform | How the app is found and started | Port and server | Flask-specific catches |
|---|---|---|---|
PythonAnywhere |
Web tab, "manual configuration": a WSGI file that imports your app under the name |
Managed by the platform; no server command |
Do not call |
Google App Engine standard |
|
|
With a custom |
Google Cloud Run |
A container image, or |
|
The container file system is in memory and not persistent; instances receive |
AWS Elastic Beanstalk |
EB CLI ( |
Gunicorn behind nginx by default, on port 8000; a |
Remove debug settings; list every WSGI server you use in |
Azure App Service (Linux) |
Looks for |
Gunicorn with |
The default 600-second timeout hides slow requests; a bad custom startup command is ignored silently and the default is tried |
Heroku |
A |
|
The free plans ended in November 2022, so book recipes that start on a free dyno need a paid plan or another platform |
AWS Lambda (functions) |
An adapter turns Lambda events into HTTP, or the Lambda Web Adapter runs the unchanged web server |
The adapter’s own port variable |
Cold starts, a time limit per invocation and no background threads; see Serverless Flask |
The next subsections give one working configuration per platform. They are configuration files rather than Python, so they were checked against the vendors' pages rather than executed.
PythonAnywhere
The simplest path is the "Add a new web app" wizard with the Flask option. For an existing project you choose manual
configuration, enter the name of your virtual environment and edit the WSGI file that the Web tab links to. The file only
has to import the Flask object and expose it under the name application:
# norun - the path is a placeholder for your account's home directory
import sys
path = "/home/yourusername/bookshelf"
if path not in sys.path:
sys.path.insert(0, path)
from wsgi import app as application # noqa: E402
Static files can be mapped to a URL in the same tab, which is how a Flask application avoids serving them through Python (see Static Files & Assets). PythonAnywhere is the gentlest platform for a first deployment and the least flexible for a second one: there is no container, and background workers are limited to its own scheduled and always-on task features.
Google App Engine
App Engine standard reads an app.yaml and installs the dependencies listed in requirements.txt itself. The runtime ID is
python followed by the version without the dot (python313); check Google’s runtime support schedule for the versions
available when you deploy. A Gunicorn entry point that honours PORT and keeps the default thread-based workers looks like
this:
runtime: python313
entrypoint: gunicorn --bind :$PORT --workers 2 --threads 4 wsgi:app
env_variables:
FLASK_STORAGE_BUCKET: "bookshelf-covers"
Values in env_variables are stored in the file, so they are for non-secret settings; secrets belong in Secret Manager. Google
now recommends Cloud Run for new Python web services, which is why the next subsection is the one to read for a greenfield
project.
Google Cloud Run
Cloud Run runs a container and needs no Flask-specific feature beyond listening on $PORT. The shortest route is to deploy from
source, which builds the image with buildpacks. The Python buildpack picks Gunicorn when gunicorn is in requirements.txt (for
Python 3.12 and earlier the default entry point is gunicorn -b :8080 main:app), so the repository only needs a requirements file
and a module the entry point can import:
gcloud run deploy bookshelf \
--source . \
--region europe-west1 \
--no-allow-unauthenticated \
--set-env-vars FLASK_STORAGE_BUCKET=bookshelf-covers \
--set-secrets FLASK_SECRET_KEY=bookshelf-secret-key:latest
--set-secrets maps a Secret Manager secret to an environment variable, so the value is injected at start-up and never stored
in the image or the command history. Making the service private with --no-allow-unauthenticated is a safe default; open it up
deliberately. The container contract has a few consequences for Flask code. The file system is in memory and disappears with the
instance, so uploads must go to object storage (see Object Storage for Uploads). Cloud Run sends SIGTERM before it
stops an instance, and Gunicorn reacts by finishing in-flight requests, so keep the request timeout below the grace period you
want. Requests pend for a while when an instance is starting, so import-time work (loading models, opening pools, calling remote
services) shows up directly as latency; keep it lazy. The Cloud Run pages of the cloud section
cover scaling, concurrency and traffic splitting. Building your own image instead of using buildpacks is the subject of the
Flask Docker and Kubernetes page of this section that follows.
AWS Elastic Beanstalk
Elastic Beanstalk finds the application when the file is called application.py and the Flask object is called application;
the official tutorial comments that "EB looks for an 'application' callable by default", which is the reason for the alias in the
entry module above. With any other layout you set the WSGIPath option (wsgi:application), or write a Procfile, which
overrides the namespace options:
web: gunicorn --bind :8000 --workers 3 --threads 2 wsgi:application
The default port for the WSGI server is 8000; if the Procfile uses another port, set the PORT environment property to match.
The tutorial’s own sample ends with application.debug = True, which it labels as something to remove before production, and
that is the Flask pitfall of every tutorial that keeps app.run() in the main block. Elastic Beanstalk is a thin layer over
EC2, a load balancer and nginx, so the sections of the AWS part of the cloud documentation on compute
and load balancers apply to it.
Azure App Service
App Service on Linux looks for application.py or app.py and starts Gunicorn against an object called app, running the
equivalent of gunicorn --bind=0.0.0.0 --timeout 600 application:app. With any other layout, set a startup command, either in
the portal or with the Azure CLI:
az webapp config set \
--resource-group bookshelf-rg \
--name bookshelf-web \
--startup-file "gunicorn --bind=0.0.0.0 --timeout 60 --workers 2 --access-logfile - --error-logfile - wsgi:app"
The --access-logfile - and --error-logfile - options send Gunicorn’s logs to standard output, which is where the platform
collects them. App Service ignores errors in a custom startup command and falls back to looking for Django and Flask apps, so
when the behaviour is not what you wrote, check the command first. The Azure counterpart pages of this site are
App Service and Functions and the Azure monitoring page linked below.
Heroku
Heroku starts whatever the Procfile says and injects PORT:
web: gunicorn --bind 0.0.0.0:$PORT wsgi:app
Heroku’s own advice is to avoid the Flask development server, which handles one request at a time and leaves dyno resources
idle. The book recipe that deploys to a free dyno is obsolete because Heroku ended its free plans in November 2022; the Procfile
itself is unchanged, and it is also understood by Elastic Beanstalk, which makes it a portable way to keep the start command next
to the code.
Behind the Platform’s Proxy
On every platform above, a proxy terminates TLS and forwards plain HTTP, so Flask sees http and the proxy’s address.
request.url, url_for(..., _external=True), redirects, the Secure flag of session cookies, rate limiting by address and
OAuth callback URLs then all use the wrong values. The cure is Werkzeug’s ProxyFix middleware, configured with exactly the
number of proxies you have, as the Flask documentation’s "Tell Flask it is Behind a Proxy" page says; the Flask docs add that
most hosting platforms need it. The trust model, and the mistake of trusting forwarded headers that a client can send
directly, are explained in Application & Request Lifecycle; the
example below shows the effect on this page’s entry module.
from flask import request
from werkzeug.middleware.proxy_fix import ProxyFix
def build(proxied: bool) -> Flask:
app = create_app({"TESTING": True})
@app.get("/where")
def where():
return {"url": request.url, "client": request.remote_addr, "secure": request.is_secure}
if proxied:
# One platform proxy in front of the app: trust one hop of each header, no more.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
return app
headers = {
"X-Forwarded-For": "203.0.113.9",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "books.example.com",
}
print(build(False).test_client().get("/where", headers=headers).get_json())
print(build(True).test_client().get("/where", headers=headers).get_json())
Without the middleware the three forwarded headers are ignored, which is the safe default; with it, the application sees the
public URL and the client’s address. If the platform adds two proxies (a CDN and a load balancer), x_for must be 2, and a
value larger than the real number lets a client forge its address.
Health Checks
Load balancers, Cloud Run, Elastic Beanstalk and Kubernetes all ask the application the same question: should I send traffic here? Two different routes answer two different questions. A liveness check says that the process is running and not stuck; it must not touch the database, because a database outage would then restart every instance and make things worse. A readiness check says that the instance can do useful work now (configuration loaded, database reachable, caches warmed) and lets the platform stop sending traffic without killing the process. Both should be cheap, uncached, unauthenticated, kept out of the access log and of the traces, and they must not reveal versions or internals to the public internet.
from collections.abc import Callable
from flask import Response, jsonify
def register_health(app: Flask, checks: dict[str, Callable[[], bool]]) -> None:
@app.get("/healthz")
def healthz():
return Response("ok", mimetype="text/plain", headers={"Cache-Control": "no-store"})
@app.get("/readyz")
def readyz():
results = {}
for name, check in checks.items():
try:
results[name] = bool(check())
except Exception: # a failing dependency must not turn into a 500 here
results[name] = False
status = 200 if all(results.values()) else 503
response = jsonify(ready=status == 200, checks=results)
response.status_code = status
response.headers["Cache-Control"] = "no-store"
return response
database_up = {"value": True}
health_app = create_app({"TESTING": True})
register_health(health_app, {"database": lambda: database_up["value"], "storage": lambda: True})
health = health_app.test_client()
print(health.get("/healthz").status_code, health.get("/readyz").get_json())
database_up["value"] = False
ready = health.get("/readyz")
print(ready.status_code, ready.get_json())
Source: Flask API: Flask.get
The platforms take these URLs differently. Cloud Run, Azure and Elastic Beanstalk call them probes or health checks and are configured in their consoles or manifests; Kubernetes has separate liveness, readiness and startup probes (see the Kubernetes section); the Lambda Web Adapter needs a readiness path so that it knows when the server has started. Keep the readiness timeout shorter than the probe timeout, or a slow dependency makes the probe itself time out and the answer is "unhealthy" for the wrong reason.
Secrets and Configuration
The twelve-factor rule covered in Configuration applies unchanged: the same artifact runs everywhere and the environment decides. On a platform that gives three delivery mechanisms, and they differ in who can read the value afterwards.
| Mechanism | Where it appears | Good for |
|---|---|---|
Plain environment variable |
In the platform console and often in deployment history and |
Non-secret settings: the bucket name, the log level, feature flags |
Environment variable populated from a secret manager |
In the process environment; the platform fetches the value at start-up (Cloud Run |
Secrets that the application reads once; rotate by restarting |
Secret mounted as a file |
In a file path such as |
Secrets that must not leak into child processes or crash dumps |
The helper below makes the application indifferent to the mechanism with the convention used by Docker secrets and many
images: if NAME_FILE is set, read the file, otherwise read NAME. Reading secrets once at start-up into app.config is
enough for SECRET_KEY; rotation needs the key list described in the configuration page (SECRET_KEY_FALLBACKS).
import tempfile
from pathlib import Path
def read_secret(name: str, default: str | None = None) -> str | None:
file_path = os.environ.get(f"{name}_FILE")
if file_path:
return Path(file_path).read_text(encoding="utf-8").strip()
return os.environ.get(name, default)
with tempfile.TemporaryDirectory() as folder:
mounted = Path(folder) / "bookshelf_secret_key"
value = secrets.token_hex()
mounted.write_text(value + "\n", encoding="utf-8")
os.environ["BOOKSHELF_SECRET_KEY_FILE"] = str(mounted)
print(read_secret("BOOKSHELF_SECRET_KEY") == value)
del os.environ["BOOKSHELF_SECRET_KEY_FILE"]
print(read_secret("BOOKSHELF_SECRET_KEY", "not-set"))
Each cloud has its own secret store, and its page in this site explains it: the AWS Secrets Manager and KMS page, the Azure Key Vault page and the Google Secret Manager page, with the runtime configuration pages of each cloud (AWS, Azure and Google Cloud) covering parameters and application settings. Give the application’s identity (an instance role, a service account or a managed identity) permission to read exactly the secrets it uses, and never create access keys for it.
Object Storage for Uploads
A platform instance is disposable, so anything a user uploads must leave the instance. The mechanics of validating and storing
a file, including presigned URLs, are in the S3 and object storage section of File
Uploads; this section only adds the deployment side. The Flask Cookbook’s S3 recipe keeps an access key and a secret key in
the application configuration, which is the part to discard: on every platform above the application runs under an identity, and
the SDK finds the credentials of that identity by itself (the default credential chain for boto3, application default
credentials for Google’s clients, DefaultAzureCredential for Azure’s). The code then contains no secret at all, and the
bucket permissions belong to the role.
A small storage abstraction keeps the choice of store out of the views, lets tests use a folder, and builds the cloud client lazily, so importing the module on a cold start costs nothing:
from typing import Protocol
import boto3
from botocore.stub import Stubber
class BlobStore(Protocol):
def put(self, key: str, data: bytes, content_type: str) -> None: ...
def get(self, key: str) -> bytes: ...
class LocalStore:
def __init__(self, folder: Path):
self.folder = folder
def put(self, key: str, data: bytes, content_type: str) -> None:
target = self.folder / key
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
def get(self, key: str) -> bytes:
return (self.folder / key).read_bytes()
class S3Store:
def __init__(self, bucket: str, client=None):
self.bucket = bucket
self._client = client
@property
def client(self):
if self._client is None: # credentials come from the instance role, never from code
self._client = boto3.client("s3")
return self._client
def put(self, key: str, data: bytes, content_type: str) -> None:
self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type)
def get(self, key: str) -> bytes:
return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
def make_store(app: Flask) -> BlobStore:
bucket = app.config.get("STORAGE_BUCKET")
return S3Store(bucket) if bucket else LocalStore(Path(app.instance_path) / "covers")
# Test the S3 implementation without a network: a stubbed client answers put_object.
stub_client = boto3.client("s3", region_name="eu-west-1")
with Stubber(stub_client) as stubber:
stubber.add_response(
"put_object",
{},
{"Bucket": "bookshelf-covers", "Key": "covers/7.png", "Body": b"png", "ContentType": "image/png"},
)
S3Store("bookshelf-covers", stub_client).put("covers/7.png", b"png", "image/png")
stubber.assert_no_pending_responses()
print("S3Store.put sent the expected request")
with tempfile.TemporaryDirectory() as folder:
local = LocalStore(Path(folder))
local.put("covers/7.png", b"png", "image/png")
print(local.get("covers/7.png"))
print(type(make_store(app)).__name__)
Source: boto3: Credentials
The same shape works for Google Cloud Storage and Azure Blob Storage with their own clients; the storage pages of this site explain buckets, containers, access control and signed URLs: S3, Cloud Storage and Blob Storage. The Bookshelf keeps the bucket private, stores the key in the database and serves covers through short-lived signed URLs or a CDN, as the upload page recommends. Image resizing after upload is the topic of the media optimization pages, for example upload pipelines, queues and jobs.
Serverless Flask
Cloud Run is already "serverless containers", and it needs nothing special. Function platforms are different: AWS Lambda invokes a handler with an event, not an HTTP request, so a WSGI application needs a translation layer. There are three approaches, and the book recipe, Zappa, is only one of them.
| Approach | How it works | Comment |
|---|---|---|
Zappa |
A command-line tool that packages the application, creates the Lambda function and API Gateway route and maps events to WSGI |
Still released (version 0.62.1 in March 2026), now under the |
An event adapter library such as |
A handler function that converts API Gateway or load balancer events to a WSGI environ and back |
Small, explicit and testable, but you manage packaging and the API Gateway or load balancer yourself. |
AWS Lambda Web Adapter |
A Lambda extension that starts the unchanged web server (Gunicorn) and forwards invocations to it as HTTP |
The same container image runs on Lambda, Fargate, EC2 and a laptop, which keeps Flask free of Lambda-specific code. |
With the Lambda Web Adapter the Dockerfile gains one line, and the application keeps listening on a port:
FROM public.ecr.aws/docker/library/python:3.13-slim
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=8080 AWS_LWA_READINESS_CHECK_PATH=/healthz
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "8", "wsgi:app"]
Source: AWS Lambda Web Adapter
Check the adapter’s release page for the current image tag; the tag above is the one its README showed on the check date. The
/healthz route from the health section doubles as the adapter’s readiness check. Whatever the approach, a Flask application
that runs on functions has to accept three constraints. Each instance handles few concurrent requests, so a single worker with a
few threads is usual, and connection pools must be small or placed in front of the database. The instance can be frozen right
after the response, so threads that continue after the request (the "send the e-mail in a thread" recipe) may never finish;
use a queue instead, as in Background Jobs, Celery & Email. And the
first request after idle time pays a cold start, which grows with import time, so keep module-level work small. The
Lambda and API Gateway pages describe the platform side.
Continuous Deployment from GitHub Actions
The chapter in the Cookbook that deploys to Google Cloud Run from GitHub Actions stores a JSON service-account key as a
repository secret. That works but leaves a long-lived credential in GitHub; the three big clouds now accept GitHub’s OIDC token
instead: Google’s Workload Identity Federation, AWS’s IAM OIDC identity provider and Azure’s federated credentials. The job
asks GitHub for a short-lived token (id-token: write), the cloud verifies it and hands back credentials that expire after the
job. The workflow below tests, then deploys the Bookshelf from source to Cloud Run with google-github-actions/auth and
deploy-cloudrun; the AWS and Azure equivalents are aws-actions/configure-aws-credentials (with role-to-assume and
aws-region) and azure/login (with client-id, tenant-id and subscription-id).
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # lets the job request an OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -r requirements.txt pytest
- run: pytest
- uses: google-github-actions/auth@v3
with:
project_id: ${{ vars.GCP_PROJECT_ID }}
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
- uses: google-github-actions/deploy-cloudrun@v3
with:
service: bookshelf
region: europe-west1
source: .
The values come from repository variables, not from the file, and the identity the provider maps to needs only the roles that a
deployment uses. Pin third-party actions to a full commit SHA in a real project. The general topic is covered in
CI/CD with GitHub Actions of the Docker section. One more book item is
outdated here: the Cookbook pushes the image to gcr.io with the old Container Registry, which was shut down on 2025-03-18.
Images now live in Artifact Registry (existing gcr.io addresses are served by it), and the container registry and builds
page explains it.
Observability: Logs, Metrics and Traces
Once the application runs somewhere you cannot attach a debugger to, three kinds of signal replace it, each answering a different question.
| Signal | Question | Flask hook that feeds it |
|---|---|---|
Logs |
What happened to this request? |
|
Metrics |
How many, how fast, how often wrong, over time? |
|
Traces |
Where did the time of this request go, across services? |
WSGI middleware or Flask signals and hooks that open a span per request |
A request or trace identifier that appears in all four is what turns four tools into one picture; the sections below build that thread. The platform-native services (CloudWatch and X-Ray, Cloud Logging and Cloud Trace, Azure Monitor and Application Insights) accept the same data, and their pages in this site explain what they offer: AWS, Google Cloud and Azure. The FastAPI section has a generic page on performance and observability with the same ideas for ASGI.
Structured Logs
A line of free text is easy to read and hard to query. Platform log services parse a JSON object per line into fields, so
writing one JSON document per record to standard output makes request_id, path or severity filterable without any
agent. The Flask page on logging (see Debugging, Logging & Profiling)
shows how dictConfig must run before the application is created and how app.logger behaves; the formatter below plugs into
that configuration and adds the request identifier. An incoming X-Request-ID is accepted only if it looks like an identifier,
so a client cannot fill the logs with arbitrary text; the JSON encoder escapes quotes and newlines either way.
import io
import json
import logging
import re
import time
import uuid
from logging.config import dictConfig
from flask import g, has_request_context
REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"severity": record.levelname,
"message": record.getMessage(),
"logger": record.name,
"time": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) + "Z",
}
if has_request_context():
payload["request_id"] = g.get("request_id")
payload["path"] = request.path
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)
dictConfig(
{
"version": 1,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {
"stdout": {"class": "logging.StreamHandler", "stream": "ext://sys.stdout", "formatter": "json"}
},
"root": {"level": "INFO", "handlers": ["stdout"]},
}
)
log_app = create_app({"TESTING": True})
@log_app.before_request
def assign_request_id():
incoming = request.headers.get("X-Request-ID", "")
g.request_id = incoming if REQUEST_ID.match(incoming) else uuid.uuid4().hex
@log_app.after_request
def echo_request_id(response):
response.headers["X-Request-ID"] = g.request_id
return response
@log_app.get("/books/<int:book_id>")
def show_book(book_id: int):
log_app.logger.info("book %s requested", book_id)
return {"id": book_id}
captured = io.StringIO()
logging.getLogger().handlers[0].setStream(captured)
log_client = log_app.test_client()
log_client.get("/books/7", headers={"X-Request-ID": "req-0123456789"})
log_client.get("/books/8", headers={"X-Request-ID": 'bad "id" with spaces'})
for line in captured.getvalue().splitlines():
record = json.loads(line)
print(record["severity"], record["message"], record["path"], record["request_id"][:8])
Source: Flask: Logging
Two details are worth knowing. Each cloud treats a few field names specially (Cloud Logging reads severity and message,
for example), so check your platform’s page before choosing key names; the JSON above uses the common ones. And log volume is a
cost: access logs at one line per request, plus INFO logs inside every view, can become the largest line of the bill, so keep
the level at INFO or above in production, sample noisy lines and keep health checks out. Libraries such as structlog or
python-json-logger produce the same output with more features; the standard library is enough to start.
OpenTelemetry
OpenTelemetry is the vendor-neutral standard for traces, metrics and logs, and the instrumentation for Flask is the package
opentelemetry-instrumentation-flask. FlaskInstrumentor().instrument_app(app) wraps the WSGI application and adds request
hooks, so each request becomes a server span named after the matched route, with the HTTP method, route and status as
attributes. The SDK decides where spans go; in production an OTLP exporter sends them to a collector or directly to a backend,
and in the test below an in-memory exporter lets the example check the result without a network.
from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
exporter = InMemorySpanExporter()
provider = TracerProvider(resource=Resource.create({"service.name": "bookshelf", "service.version": "1.0.0"}))
provider.add_span_processor(SimpleSpanProcessor(exporter)) # production: BatchSpanProcessor with an OTLP exporter
tracer = provider.get_tracer("bookshelf")
traced_app = create_app({"TESTING": True})
register_health(traced_app, {})
@traced_app.get("/books/<int:book_id>")
def traced_book(book_id: int):
with tracer.start_as_current_span("load-book") as span:
span.set_attribute("bookshelf.book_id", book_id)
trace_id = format(span.get_span_context().trace_id, "032x") # the id to put in logs
return {"id": book_id, "trace_id": trace_id}
FlaskInstrumentor().instrument_app(
traced_app,
tracer_provider=provider,
excluded_urls="healthz,readyz", # no spans for probes
)
traced_client = traced_app.test_client()
traced_client.get("/healthz")
body = traced_client.get("/books/7").get_json()
spans = exporter.get_finished_spans()
print([s.name for s in spans])
print(spans[-1].attributes.get("http.route"), spans[-1].resource.attributes["service.name"])
print(spans[0].context.trace_id == spans[1].context.trace_id, body["trace_id"] == format(spans[0].context.trace_id, "032x"))
The child span load-book shares the trace id of the request span, and that same id is what the log formatter should add to
each record so a log line can be found from a trace and the other way round. The instrumentation’s excluded_urls option (or
the environment variable OTEL_PYTHON_FLASK_EXCLUDED_URLS) takes a comma-separated list of regular expressions, which keeps
probes and /metrics out of the traces. HTTP attribute names depend on the semantic-convention version the instrumentation
emits (the older http.method and http.status_code or the newer http.request.method and http.response.status_code),
which is the first thing to check when a dashboard stays empty after an upgrade.
Two other ways to switch it on avoid code. The zero-code route installs the instrumentation packages with
opentelemetry-bootstrap and starts the process through the agent, configured only by standard environment variables:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
export OTEL_SERVICE_NAME=bookshelf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_TRACES_EXPORTER=otlp
opentelemetry-instrument gunicorn --bind 0.0.0.0:$PORT wsgi:app
The catch is the process model. Gunicorn forks workers, and background export threads do not survive a fork, so a tracer
provider created in the master process silently exports nothing from the workers. The documented fix is to initialise the SDK
in Gunicorn’s post_fork hook:
# gunicorn.conf.py
def post_fork(server, worker):
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider(resource=Resource.create({"service.name": "bookshelf"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) # endpoint from OTEL_EXPORTER_OTLP_ENDPOINT
trace.set_tracer_provider(provider)
If the application uses the factory and builds its Flask object after the fork (gunicorn --preload off), calling
FlaskInstrumentor().instrument_app(app) in the factory is sufficient; with --preload the hook is essential.
Prometheus Metrics
For metrics the minimum set is the "RED" trio: rate, errors and duration of requests, labelled by method, route and status.
prometheus_client provides the types, and Flask provides the hooks. The one design decision that matters is the label: use
the matched route template (request.url_rule.rule, for example /books/<int:book_id>), never request.path. A label that
takes a different value for each book identifier, or each scanner’s random URL, creates a new time series per value and
exhausts the monitoring system’s memory. Unmatched requests share one label.
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Counter, Histogram, generate_latest
registry = CollectorRegistry()
REQUESTS = Counter(
"bookshelf_http_requests_total", "HTTP requests", ["method", "route", "status"], registry=registry
)
LATENCY = Histogram(
"bookshelf_http_request_duration_seconds",
"HTTP request duration",
["method", "route"],
buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5),
registry=registry,
)
def install_metrics(app: Flask) -> None:
@app.before_request
def start_timer():
g.started = time.perf_counter()
@app.after_request
def record(response):
if request.endpoint == "metrics":
return response
route = request.url_rule.rule if request.url_rule else "unmatched"
REQUESTS.labels(request.method, route, response.status_code).inc()
LATENCY.labels(request.method, route).observe(time.perf_counter() - g.started)
return response
@app.get("/metrics")
def metrics():
# Expose on an internal port or behind authentication when the platform allows it.
return Response(generate_latest(registry), content_type=CONTENT_TYPE_LATEST)
metrics_app = create_app({"TESTING": True})
@metrics_app.get("/books/<int:book_id>")
def metrics_book(book_id: int):
return {"id": book_id}
install_metrics(metrics_app)
metrics_client = metrics_app.test_client()
for number in (1, 2, 3):
metrics_client.get(f"/books/{number}")
metrics_client.get("/no-such-page-1")
metrics_client.get("/no-such-page-2")
exposition = metrics_client.get("/metrics").get_data(as_text=True)
print([line for line in exposition.splitlines() if line.startswith("bookshelf_http_requests_total{")])
Source: Prometheus client_python: Flask
Three books on three different ids produce one series, and the two random 404 paths produce one more. Scrape configuration
and alerts are the monitoring system’s job; in a platform without a Prometheus server (Cloud Run, App Service) the same
numbers can go to the platform’s managed Prometheus or to an OpenTelemetry collector. Gunicorn adds a further twist: each worker
process has its own counters, and a scrape that hits one worker sees only that worker. The client library’s multiprocess mode
solves it by writing metrics to files in a directory named by PROMETHEUS_MULTIPROC_DIR, which must exist, be empty at start
and be set before prometheus_client is imported, plus a hook that cleans up dead workers:
# gunicorn.conf.py (multiprocess metrics)
def child_exit(server, worker):
from prometheus_client import multiprocess
multiprocess.mark_process_dead(worker.pid)
def metrics_response() -> bytes:
from prometheus_client import CollectorRegistry, generate_latest, multiprocess
aggregate = CollectorRegistry()
multiprocess.MultiProcessCollector(aggregate) # reads every worker's files
return generate_latest(aggregate)
print(callable(child_exit), callable(metrics_response))
In multiprocess mode a view must build a fresh registry for each scrape (the metrics_response function), custom collectors
and some gauge features do not work, and the directory should live on fast local storage and be emptied on deploy. The
prometheus-flask-exporter package wraps all of this, but it was last released in 2025, so check its maintenance before
adopting it.
Error Tracking with Sentry
Logs show what happened and metrics show how often; neither tells you quickly that a new exception started after the last
deploy. Sentry does, and the details of the Flask integration (it hooks Flask’s got_request_exception signal, and with
sentry-sdk 2.x it is enabled automatically when Flask is installed, so the book’s integrations=[FlaskIntegration()] is
optional) are in the Sentry section of the debugging page. What
matters on a platform is what to configure: environment and release so that errors are grouped by deployment (Cloud Run’s
K_REVISION is a ready-made release), send_default_pii left off, a low traces_sample_rate, and a before_send hook that
removes anything sensitive before the event leaves the process. The DSN is configuration, not code.
import sentry_sdk
sent_events = []
os.environ.setdefault("SENTRY_DSN", "https://public@o0.ingest.example.invalid/1") # demo DSN, never delivered
def scrub(event, hint):
request_data = event.get("request", {})
request_data.pop("cookies", None)
headers = request_data.get("headers", {})
for name in list(headers):
if name.lower() in {"authorization", "cookie", "x-api-key"}:
headers[name] = "[Filtered]"
return event
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
environment=os.environ.get("APP_ENV", "production"),
release=os.environ.get("K_REVISION", "local"),
send_default_pii=False,
traces_sample_rate=0.1,
before_send=scrub,
transport=sent_events.append, # demo only: capture events instead of sending them
)
error_app = create_app()
@error_app.get("/boom")
def boom():
raise RuntimeError("cover storage is unreachable")
error_client = error_app.test_client()
reply = error_client.get("/boom", headers={"Authorization": "Bearer demo", "Cookie": "session=demo"})
sentry_sdk.flush()
print(reply.status_code, len(sent_events))
event = sent_events[0]
print(event["exception"]["values"][0]["type"], event["environment"], event["release"])
print(event["request"]["headers"].get("Authorization"), event["request"]["headers"].get("Cookie"))
Source: Sentry: Flask integration
New Relic and Datadog
Commercial APM products instrument Flask by patching it at import time, which is why both are started through a launcher
rather than called from a view. Neither needs a code change, and in both cases the account key is an environment variable
supplied by the platform’s secret mechanism, never a line in the repository or the Dockerfile.
New Relic’s Python agent is configured by a file (generated with newrelic-admin generate-config) or by environment variables
named after the settings, and the newrelic-admin run-program wrapper starts the real command:
pip install newrelic
export NEW_RELIC_APP_NAME="bookshelf"
export NEW_RELIC_LICENSE_KEY="$(cat /secrets/new_relic_license_key)" # from the secret mount, not typed here
newrelic-admin run-program gunicorn --bind 0.0.0.0:$PORT wsgi:app
Datadog’s tracing library, ddtrace, has the matching launcher ddtrace-run, and unified service tagging through three
variables (DD_SERVICE, DD_ENV and DD_VERSION) ties traces, logs and metrics to the same service and release. The tracer
sends to a Datadog Agent whose address is set with DD_AGENT_HOST (or DD_TRACE_AGENT_URL), and DD_LOGS_INJECTION adds trace
identifiers to log records emitted through the standard logging module:
pip install ddtrace
export DD_SERVICE=bookshelf DD_ENV=production DD_VERSION=1.0.0
export DD_AGENT_HOST=datadog-agent DD_LOGS_INJECTION=true
ddtrace-run gunicorn --bind 0.0.0.0:$PORT wsgi:app
Three practical rules apply to both. Use one tracing agent per process: two of them patching the same library double-count or break each other, and OpenTelemetry’s Flask instrumentation counts as one. Start them through the launcher in front of Gunicorn rather than inside the application, and test that the master and the workers both report. And treat the agent as a cost: it adds overhead per request and sends data to a paid service, so sample, exclude health checks and review what is captured. Both vendors also accept OpenTelemetry data, so instrumenting with OpenTelemetry and choosing the backend later is a common way to avoid lock-in. The books' New Relic and Datadog recipes (chapter 11 of the Cookbook) are still the right outline; what changed is the packaging (current agents need current Python versions, and the configuration is environment-first) and that the account key must not be committed.
What to Watch and Alert On
Collecting data is half of the work; the other half is deciding what wakes someone up. A short list that fits almost any Flask service:
| Signal | Alert when | Source |
|---|---|---|
Error rate |
Share of 5xx responses over five minutes is above the agreed threshold |
|
Latency |
The 95th or 99th percentile of |
The histogram above; the mean hides the slow tail |
Saturation |
Workers are all busy, the queue of waiting requests grows, memory is near the limit |
Platform metrics and Gunicorn stats |
Readiness |
Instances fail |
The health route and the platform’s probe results |
New exceptions |
A fingerprint that did not exist in the previous release appears |
Sentry |
Alert on symptoms users feel (errors and latency) rather than on causes (CPU at 80 per cent), and attach the request or trace identifier to the notification so that the first action is a search, not a guess.
Running It Safely
-
Configuration, secrets and the platform’s identity are the only things that differ between environments; the code and the image do not.
-
FLASK_DEBUGandapp.run(debug=True)have no place in a platform deployment; the Werkzeug debugger allows remote code execution to anyone who reaches it and opens the debugger console in the browser. -
Handle
SIGTERM: Gunicorn does so by finishing in-flight requests within--graceful-timeout, so set that and the platform’s termination period consistently, and set--timeoutfor slow requests deliberately instead of accepting a 600-second default. -
Keep
--workersand--threadsconsistent with the instance’s CPU and memory, and check memory on a platform that counts the in-memory file system against the instance. -
Make logs and traces carry no secrets or personal data; the
before_sendscrubber is a safety net, not the plan. -
Expose
/metricsonly where the scraper can reach it, not on the public internet, and never include sensitive data in labels. -
Protect the deployment pipeline as you protect production: OIDC federation, least-privilege roles and pinned actions.
Recent Changes and Where Older Material Is Outdated
| Change | When | Consequence |
|---|---|---|
Heroku ended its free plans |
November 2022 |
Book recipes that deploy to a free dyno no longer work as written; the |
Google Container Registry shut down; Artifact Registry replaces it |
2025-03-18 |
|
Workload Identity Federation (and the AWS and Azure equivalents) replace stored JSON keys in CI |
Ongoing |
A GitHub secret holding a service-account key is a long-lived credential; use OIDC. |
Flask |
Flask 2.3 |
Start-up commands and Dockerfiles that set |
Sentry’s Flask integration is automatic |
sentry-sdk 2.x |
|
OpenTelemetry HTTP semantic conventions are stabilising |
2024 onwards |
Attribute names ( |
App Engine recommends Cloud Run for new services |
Current |
Choose App Engine for existing applications and Cloud Run for new ones. |
Aurora Serverless v1 reached end of life |
March 2025 |
Database recipes in the container chapters that use it need Aurora Serverless v2 or another engine. |
Flask 3.2 (unreleased) merges the request context into the application context |
Upcoming |
Custom log filters and instrumentation that read |
The books that this section consults need the following updates on this page’s topic. Flask Framework Cookbook (B1, 3rd
edition, and B2, 2nd edition) covers deployment in chapter 11 (Apache with mod_wsgi, uWSGI with nginx, Gunicorn with
Supervisor, S3 uploads with boto3, New Relic and Datadog) and containers in chapter 12 (a Dockerfile, Kubernetes on minikube,
Google Cloud Run and continuous deployment with GitHub Actions); the 2nd edition adds Heroku, Elastic Beanstalk and Zappa. The
outline is still right (a WSGI server behind a proxy, configuration outside the code, object storage for uploads, an APM agent,
a pipeline that deploys on merge); the specifics are not. Heroku’s free tier is gone, Zappa recipes pin old runtimes, gcr.io
pushes target a registry that was shut down, the CI recipe stores a JSON service-account key, and the S3 recipe stores access
keys in configuration. Full-Stack Flask and React (B4) chapter 16 deploys a containerised application to AWS Elastic Beanstalk
with python:3.8-era images and a Dockerfile that runs the development server, and its chapter 13 shows AWS keys in code that
must never be reproduced; use Gunicorn, a current base image, and the instance role. Microservice APIs (B3) uses Aurora
Serverless v1 in its AWS deployment chapters, which reached end of life in March 2025. The page of this section on migration
collects these items for upgrading existing projects.
Common Pitfalls
-
Binding
127.0.0.1in a container or on a platform: the proxy cannot reach the server, and the health check fails. -
Hard-coding the port instead of reading
PORT. -
Leaving
app.run()ordebug=Truein the module the platform imports. -
Forgetting
ProxyFix: redirects go tohttp, OAuth callbacks fail,Securecookies are not set, and every client appears to have the proxy’s address. -
Trusting forwarded headers beyond the real number of proxies.
-
Writing uploads, sessions or caches to the local disk of an instance that disappears.
-
Storing a cloud access key in configuration or in the image when the platform provides an identity.
-
A readiness route that calls the database from the liveness probe, so that a database outage restarts every instance.
-
Using
request.pathas a metric label, creating one series per URL. -
Creating the OpenTelemetry provider before Gunicorn forks, so the workers export nothing.
-
Running two tracing agents at once.
-
Putting a license key in a
Dockerfile, a workflow file or a log. -
Logging whole request bodies and headers, and so storing passwords and tokens in the log service.
-
Doing heavy import-time work and paying for it in every cold start.