Flask ships with a signals system — hooks fired at each stage of the request lifecycle: when a request starts, when the response is finished, when an unhandled exception occurs. Attach a handler once where the app is created and you can log every 500 error and time every request without touching a single view.

The Problem

To find out which requests are slow or where a 500 comes from, the usual approach is to insert timing code and try/except into every view. That works for an app with a few routes, but with dozens of views it repeats everywhere, new views get missed, and views bloat with non-business code. Worse, a 500 only surfaces when a user reports it, because nothing centrally records the exception with the path that caused it.

The Solution

Flask emits signals through the blinker library — Flask 3.x declares blinker as a dependency, so it’s usually already installed; if not, run pip install blinker. The three most useful signals: request_started (a request begins), request_finished (with the response, before it goes back to the client), and got_request_exception (an unhandled exception). Attach handlers with .connect(handler, app) right after creating the app, or inside the app factory:

import logging
import time
from flask import Flask, g, request
from flask.signals import (
got_request_exception,
request_finished,
request_started,
)
app = Flask(__name__)
logger = logging.getLogger(__name__)
def _bat_dau(sender, **extra):
g.started_at = time.monotonic()
def _ket_thuc(sender, response, **extra):
elapsed = time.monotonic() - g.pop("started_at", time.monotonic())
logger.info("%s %s -> %s (%.3fs)",
request.method, request.path,
response.status_code, elapsed)
def _bat_loi(sender, exception, **extra):
logger.exception("Lỗi 500 tại %s %s",
request.method, request.path)
request_started.connect(_bat_dau, app)
request_finished.connect(_ket_thuc, app)
got_request_exception.connect(_bat_loi, app)

g stores the start time because it lives for exactly one request, and request_finished always runs even when the view fails, so the measured time covers the whole request lifecycle. Each handler receives sender (the app) and **extra with data specific to each signal: response in request_finished, exception in got_request_exception.

Note: if blinker isn’t installed, Flask replaces signals with a “fake” version — handlers register normally but never run, and no error is reported. Check whether request_started.receivers is empty after connecting, or run pip show blinker before debugging.

Apply It to Your Project

Attach these three handlers in create_app() so every blueprint is covered, then send logs to a file with RotatingFileHandler instead of leaving them on the console. Once logs carry method, path, status code, and duration, you only need to grep “500” or sort by duration to see which endpoint is slow and which error repeats most — enough to decide what to optimize first without an APM tool. Later, if you need alerts in Telegram or Sentry, add one line inside _bat_loi; no view needs changing.

Related Posts

Frequently Asked Questions

How are signals different from before_request and after_request?

before_request and after_request can also measure request time, but they run inside the app’s request-handling chain. Signals are more decoupled: handlers can live in a different module, and got_request_exception catches exceptions the before/after pair never sees. The two approaches complement each other.

Do I need to install anything extra to use signals?

You need blinker. Flask 3.x lists blinker as a dependency, so it’s usually already there; if you installed Flask with --no-deps or use a minimal environment, run pip install blinker and check with pip show blinker.

Does abort(404) trigger got_request_exception?

No. abort() raises an HTTPException and Flask turns it into a normal response, so the signal only fires for a genuinely unhandled exception — a 500. To track 404s too, look at the request_finished log, which records every status code.

Do signal handlers slow requests down?

Not noticeably, if the handler only writes a log. But don’t do heavy work in a handler — calling an external API or writing to a database — because request_finished runs before the response reaches the client, so heavy work adds directly to response time. Heavy work belongs in a queue or a background thread.

💬 Have you tried this in your project? If you hit an error or have a neater way, leave a comment below — I read them all and will add the best ideas to the post.

References