Timing Python function calls with a custom logging decorator
When a Python application starts to feel sluggish, the first instinct is often to guess where the slowdown lives. Guessing, however, is rarely productive. A reliable measurement beats intuition every time, and one of the cleanest ways to capture that measurement is through a small piece of reusable code attached to a function.
That reusable piece is a decorator. Decorators wrap a callable, run code before and after it executes, and hand the original object back untouched. They are an idiomatic feature of Python, and they fit logging perfectly because the wrapper can record timestamps at the moment of entry, capture the inputs, measure elapsed time at the moment of exit, and forward everything to a configured logger without changing a single line inside the function it is monitoring.
This kind of instrumentation is especially handy on servers and cron jobs that quietly process work behind the scenes, but it works just as well for short scripts run from a terminal. The same wrapper will write consistent records whether it sits on a method in a Django view, a Celery task, or a one-off utility a developer keeps in their dotfiles.
Australia has a healthy Python ecosystem, with active communities running meetups in Sydney and Melbourne and an annual PyCon AU conference that regularly gathers developers from across the country. A logging decorator is exactly the sort of small tool that gets shared, improved, and reused across those communities, particularly when projects run on machines spread across multiple time zones from Perth to Brisbane.
Why measure function execution time
Performance work has a habit of revealing surprises. A function that looks simple might make a network call, traverse a large collection, or block on a filesystem lock. Recording how long a routine actually takes, rather than assuming it is fast, allows teams to focus their attention on the operations that genuinely deserve optimisation.
Beyond raw speed, duration logs also help with capacity planning. When a Flask endpoint serving traffic from users in Adelaide takes two hundred milliseconds on average, multiplying that figure across projected request volume gives a much clearer picture of load than any static benchmark. The same records feed alerting systems, where thresholds can flag functions that exceed their historical baseline by a meaningful margin.
Debugging benefits too. A decorated function produces a trail of timings and arguments that can be replayed long after the original error has cleared. That trail is often the difference between a five-minute fix and an afternoon of digging through strace output.
Setting up the logging module
Python ships with a rich logging package that handles severity levels, formatting, and output destinations without external dependencies. A decorator that records duration and arguments should rely on logging.getLogger(__name__) so the records integrate with whatever handlers the host application has already configured.
A typical setup imports the module, creates a logger at the top of the file, and lets the surrounding application decide where messages should go — a rotating file handler, the system journal, or a structured sink such as Logstash. For local development, configuring the root logger with logging.basicConfig at INFO level is usually enough to confirm that the decorator is wired correctly.
It helps to set a consistent format. A line such as %(asctime)s %(levelname)s %(name)s - %(message)s produces output that reads well in a terminal during a quick check in Sydney or Brisbane and parses cleanly when piped through tools like jq in a production pipeline. Time stamps default to the local clock, and on an Australian deployment that means AEST, ACST, or AWST depending on the host's region.
Building the core decorator
The wrapper itself is short. It captures time.perf_counter() before calling the wrapped function, invokes the function inside a try block, captures the second timestamp afterwards, and logs the difference. Wrapping the call in try/finally ensures that even when the function raises an exception, the elapsed time still gets recorded along with the error.
import time
import logging
from functools import wraps
logger = logging.getLogger(__name__)
def log_duration(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed = time.perf_counter() - start
logger.info("%s executed in %.4fs", func.__name__, elapsed)
return wrapper
Using @wraps from functools preserves the original function's name, docstring, and signature, which keeps the decorated function friendly to introspection, IDE tooling, and frameworks that rely on __name__ for routing.
Handling arguments and keyword arguments
Capturing inputs makes the log line far more useful when the same wrapper protects dozens of functions. The wrapper already receives *args and **kwargs, so it can format them directly into the message. For most cases, passing them through %r formatting keeps the output readable and unambiguous:
logger.info("%s called with args=%r kwargs=%r",
func.__name__, args, kwargs)
When arguments include sensitive material such as passwords or tokens, the decorator should be designed to redact them. A common pattern is to inspect the bound arguments against a configurable list of field names to hide, replacing their string representation with a placeholder. The same idea applies to bulky payloads that would otherwise dominate the log file.
Edge cases deserve attention. A function that accepts a single self argument will record that instance in the log, which can be noisy or expose internal state. Filtering positional arguments by position, or only logging names drawn from func.__code__.co_varnames, offers a tidy way to keep the records compact while still capturing meaningful inputs.
Practical use cases in production
A logging decorator earns its keep the moment it is applied to a real codebase. On a long-running data pipeline processing invoices overnight, decorating the load, transform, and write steps produces a clear timeline of where minutes are spent. The same decorator attached to API handlers shows whether a slow request is the handler's fault or something deeper in the stack.
For websites serving Australian customers, monitoring the cold path of a request — name resolution, database handshake, template render — is often more revealing than the warm path. A small wrapper attached to each layer lets a developer in Hobart compare timings against a colleague's results in Perth without needing a full APM product on day one.
When used in moderation, the decorator adds little overhead and produces output that is genuinely actionable. The trick is to think of it as a microscope rather than a megaphone: aimed at the right few functions, it reveals exactly what is happening, where, and with which inputs.
