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.

flowchart LR U["Browser or API client"] --> P["Platform proxy: TLS, routing, load balancing"] P --> G["WSGI server: Gunicorn on PORT"] G --> F["Flask app"] F --> S[("Secrets and config from the environment")] F --> O[("Object storage and database")] G -. "stdout and stderr" .-> L["Platform log service"] F -. "traces and metrics" .-> M["Monitoring backend"]
Figure 1. Request path and telemetry on a managed platform
The platform needs What that means for a Flask application

An importable WSGI callable

A module that defines a Flask object under a name the platform looks for: app for Gunicorn-style module:app commands, App Engine’s default main.py and Azure’s app.py or application.py; application for Elastic Beanstalk and mod_wsgi. Defining both names costs one line.

A port to listen on

The port arrives in the PORT environment variable on Cloud Run, App Engine and Heroku, and is a fixed default (8000) on Elastic Beanstalk. The server must bind 0.0.0.0 (all interfaces), never 127.0.0.1, or the platform proxy cannot reach it.

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())

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 application

Managed by the platform; no server command

Do not call app.run() at import time; run database setup from a console, not from the __main__ block; make SERVER_NAME match the web app name or leave it unset

Google App Engine standard

app.yaml with a runtime and an optional entrypoint; without an entrypoint a main.py with an app object is started by Gunicorn

PORT; the default Gunicorn uses the gthread worker class with 4 threads per worker (4 workers on F1 and B1 instances, 8 on larger ones)

With a custom entrypoint and no --workers, Gunicorn runs a single sync worker, and gunicorn must be in requirements.txt; Google recommends Cloud Run for new Python services

Google Cloud Run

A container image, or gcloud run deploy --source ., which builds one with buildpacks

PORT, bind 0.0.0.0; TLS is terminated for you

The container file system is in memory and not persistent; instances receive SIGTERM before shutdown; CPU may be throttled outside requests

AWS Elastic Beanstalk

EB CLI (eb init, eb create); a file called application.py that exposes application, or the WSGIPath option, or a Procfile

Gunicorn behind nginx by default, on port 8000; a Procfile overrides the namespace options

Remove debug settings; list every WSGI server you use in requirements.txt; the Python platform version, not your laptop, decides which Python runs

Azure App Service (Linux)

Looks for application.py or app.py with an object named app, and starts Gunicorn; a custom startup command overrides it

Gunicorn with --bind=0.0.0.0 --timeout 600 behind nginx

The default 600-second timeout hides slow requests; a bad custom startup command is ignored silently and the default is tried

Heroku

A Procfile with a web: process type

PORT; Gunicorn recommended because the Flask server handles one request at a time

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())

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 describe output

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 --set-secrets, and App Service Key Vault references)

Secrets that the application reads once; rotate by restarting

Secret mounted as a file

In a file path such as /secrets/flask_secret_key; the platform can refresh it

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__)

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 zappa organisation on GitHub. The Flask Cookbook recipes pin old Python runtimes and settings files; check which Lambda runtimes the current release supports before copying them.

An event adapter library such as serverless-wsgi or apig-wsgi

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"]

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?

app.logger and the standard logging module, with a request identifier

Metrics

How many, how fast, how often wrong, over time?

before_request and after_request, the matched route, and the response status

Traces

Where did the time of this request go, across services?

WSGI middleware or Flask signals and hooks that open a span per request

flowchart TD R["Request"] --> F["Flask view"] F -->|"JSON on stdout"| LOG["Platform logs"] F -->|"counters and histograms"| MET["Prometheus or platform metrics"] F -->|"spans via OTLP"| TR["Collector or APM backend"] F -->|"unhandled exceptions"| ERR["Sentry"] LOG --- ID["Shared request or trace id"] MET --- ID TR --- ID ERR --- ID
Figure 2. Signals leaving a Flask application and the identifier that ties them together

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{")])

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"))

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

bookshelf_http_requests_total or the platform’s load balancer metrics

Latency

The 95th or 99th percentile of bookshelf_http_request_duration_seconds is above target

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 /readyz for more than a few probes

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_DEBUG and app.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 --timeout for slow requests deliberately instead of accepting a 600-second default.

  • Keep --workers and --threads consistent 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_send scrubber is a safety net, not the plan.

  • Expose /metrics only 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 Procfile itself is unchanged.

Google Container Registry shut down; Artifact Registry replaces it

2025-03-18

gcr.io push recipes in the Cookbook need Artifact Registry repositories and permissions.

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 ENV and FLASK_ENV removed

Flask 2.3

Start-up commands and Dockerfiles that set FLASK_ENV=production do nothing; production is the default and debug is --debug.

Sentry’s Flask integration is automatic

sentry-sdk 2.x

integrations=[FlaskIntegration()] still works but is no longer required.

OpenTelemetry HTTP semantic conventions are stabilising

2024 onwards

Attribute names (http.method or http.request.method) depend on the version and an opt-in setting, so dashboards need care.

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 request outside a request should be re-tested after the upgrade.

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.1 in 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() or debug=True in the module the platform imports.

  • Forgetting ProxyFix: redirects go to http, OAuth callbacks fail, Secure cookies 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.path as 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.

References