Logging & Debug Toolbar

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 Pyramid 2.1 (the 2.1-branch documentation and the 2.1 release installed from PyPI) on Python 3.13, checked on 2026-10-07, with pyramid_exclog 1.1, pyramid_debugtoolbar 4.12.1, Paste 3.10.1, sentry-sdk 2.71 and opentelemetry-sdk 1.45 (the opentelemetry-instrumentation-* packages at 0.66b1). Every Python example was executed with webtest.TestApp; external services (a Sentry server, a trace collector, an SMTP relay) are replaced by local fakes or switched off. The running example is the Bookshelf Shelves scenario: public shelves owned by users, which grow over the pages of this section.

Pyramid does not ship its own logging system. It uses the logging module of the Python standard library and adds three things around it: a convention for configuring it from the .ini file that pserve reads, a handful of pyramid.debug_* settings that make the framework talk about its own decisions, and a family of add-ons from the Pylons Project (pyramid_exclog for exceptions, pyramid_debugtoolbar for development) plus the PasteDeploy ecosystem (TransLogger for access logs). This page walks through them in the order a project meets them: first the configuration that already exists in a cookiecutter project, then logging from application code, then the three ways to get request and exception information out of a running process, then the toolbar, and finally the hosted and standards-based tools (Sentry and OpenTelemetry) that build on the WSGI interface.

Other pages of this site cover the same topic for sibling frameworks: Flask: Debugging, Logging & Profiling, Tornado: Options, Configuration & Logging, Django: Logging & Error Reporting and FastAPI: Performance & Observability. The Docker logging page and Kubernetes observability page show what happens to the output once it leaves the process.

The Big Picture

Everything on this page is a producer, a logger or a handler of the standard library. Pyramid adds producers and some wiring, nothing more.

flowchart LR A[Your views and models] --> P[bookshelf_shelves logger] B[Pyramid debug flags] --> P C[SQLAlchemy, Waitress and other libraries] --> R[root logger] P --> R D[pyramid_exclog tween] --> E[exc_logger] F[TransLogger middleware] --> W[wsgi logger] R --> H1[console handler on stderr] E --> H2[exception file or SMTP handler] W --> H3[access log file handler]
Figure 1. Where log records come from and where they go in a cookiecutter-style project

The root logger receives everything from libraries and, through propagation, from the package logger. exc_logger and wsgi are separate loggers so that exceptions and access lines can go to their own destinations.

The key facts that explain most surprises:

  • Logging is configured once, at process start, by pserve (or by your own call to setup_logging). The WSGI application factory does not configure it, which is why a script or a mod_wsgi deployment that skips that call prints nothing.

  • Handlers belong to loggers, and records propagate from a child logger (bookshelf_shelves.views) to its parents (bookshelf_shelves, then the root logger) unless propagate = 0.

  • The level of a logger decides whether it creates a record; the level of a handler decides whether it prints one. Both must allow the record.

  • Pyramid itself has no log files, no rotation and no log levels of its own to learn. Everything you know about logging.config.fileConfig applies.

Logging From the .ini File

What the cookiecutter generates

A project created from the Pyramid cookiecutter (see Cookiecutter Projects & pserve) ships a logging section in both development.ini and production.ini. Both are plain logging.config.fileConfig configuration embedded in the same file as the application settings, so the file serves two readers that ignore each other’s sections. The development file generated for the Bookshelf Shelves project looks like this (the production file is identical except for the levels, which are WARN):

[loggers]
keys = root, bookshelf_shelves

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = INFO
handlers = console

[logger_bookshelf_shelves]
level = DEBUG
handlers =
qualname = bookshelf_shelves

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

The [loggers], [handlers] and [formatters] sections only list names; each name then needs its own [logger_<name>], [handler_<name>] or [formatter_<name>] section. A name listed but never defined, or defined but never listed, is an error or silently ignored respectively, which is the usual cause of "I added a handler and nothing happens".

Key Meaning Notes

qualname

The dotted name of the logger the section configures.

Required for every logger except root. The section name (logger_bookshelf_shelves) is only a label; the qualified name decides which logger is configured.

level

DEBUG, INFO, WARN, ERROR, CRITICAL or NOTSET.

On a logger it is the threshold for creating records; on a handler it is the threshold for emitting them.

handlers

Comma-separated handler names, possibly empty.

An empty list is normal for a non-root logger: its records reach the root logger’s handlers by propagation.

propagate

1 (default) or 0.

Set to 0 when a logger has its own handler and must not also print through the root logger (see the access log and the exception log below).

class and args (handler)

Handler class and its constructor arguments as a Python tuple.

args is evaluated in the namespace of the logging package, which is why sys.stderr works; %(here)s expands to the directory of the .ini file.

format (formatter)

A %-style format string.

Attributes are those of LogRecord: %(asctime)s, %(levelname)s, %(name)s, %(lineno)s, %(message)s.

Because pserve calls pyramid.paster.setup_logging, which in Pyramid 2.1 delegates to the PasteDeploy loader and ends up in logging.config.fileConfig with disable_existing_loggers=False, a logger created at import time (the module-level log = logging.getLogger(__name__) idiom) is not silenced by the configuration that arrives later. That is different from a plain fileConfig call, whose default would disable such loggers.

Loading the configuration from your own code

Anything that runs outside pserve must make the call itself: a maintenance script, a pshell startup, a mod_wsgi .wsgi file, a task worker that imports the application. The function takes the same config_uri that the command-line tools use. The example below writes a complete configuration (the cookiecutter loggers plus a rotating file handler and a library logger) to a scratch directory and loads it.

import logging
import pathlib
import tempfile

from pyramid.paster import setup_logging

workdir = pathlib.Path(tempfile.mkdtemp(prefix='shelves-log-'))

INI = """
[loggers]
keys = root, bookshelf_shelves, sqlalchemy_pool, exc_logger, wsgi

[handlers]
keys = console, filelog, exc_handler, accesslog

[formatters]
keys = generic, bare

[logger_root]
level = INFO
handlers = console, filelog

[logger_bookshelf_shelves]
level = DEBUG
handlers =
qualname = bookshelf_shelves

[logger_sqlalchemy_pool]
level = DEBUG
handlers =
qualname = sqlalchemy.pool

[logger_exc_logger]
level = ERROR
handlers = exc_handler
qualname = exc_logger
propagate = 0

[logger_wsgi]
level = INFO
handlers = accesslog
qualname = wsgi
propagate = 0

[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic

[handler_filelog]
class = handlers.RotatingFileHandler
args = ('%(here)s/shelves.log', 'a', 1048576, 5)
level = INFO
formatter = generic

[handler_exc_handler]
class = FileHandler
args = ('%(here)s/exceptions.log',)
level = ERROR
formatter = generic

[handler_accesslog]
class = FileHandler
args = ('%(here)s/access.log',)
level = INFO
formatter = bare

[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

[formatter_bare]
format = %(message)s
"""

ini_path = workdir / 'logging.ini'
ini_path.write_text(INI)
setup_logging(str(ini_path))

log = logging.getLogger('bookshelf_shelves.views')
log.debug('visible: the package logger is at DEBUG, the handler is NOTSET')
logging.getLogger('some.library').debug('hidden: the root logger is at INFO')

print(sorted(p.name for p in workdir.iterdir()))
for handler in logging.getLogger().handlers:
    handler.flush()
file_text = (workdir / 'shelves.log').read_text()
assert 'visible' not in file_text      # the file handler is at INFO

Two details in that file are worth keeping. The root logger sends records to two handlers, and the file handler is at INFO while the package logger is at DEBUG: the visible message reaches the console, which has no level of its own (NOTSET), and is dropped by the file, whose handler level is INFO. A library logger is raised to DEBUG on its own (sqlalchemy.pool here, a technique the official chapter uses to watch connection pooling) without turning the whole root logger to DEBUG and drowning the output.

Levels per environment

Logger Development Production Why

root

INFO

WARN

Libraries (Waitress, SQLAlchemy, pyramid_tm) are noisy at INFO and above all at DEBUG.

bookshelf_shelves (your package)

DEBUG

WARN (or INFO if you log business events)

The package logger is yours: it is the place to log decisions you want to read in production.

sqlalchemy.engine

INFO to see every statement

not listed

At INFO it logs each SQL statement with its parameters, which can contain personal data.

exc_logger

not listed

ERROR

Added by pyramid_exclog, see below.

wsgi

not listed

INFO

The TransLogger access log, see below.

Two operational rules follow. First, treat log files as data with a retention policy: parameters, headers and form values show up in them more often than anyone intends. Second, in a container the process should write to stderr or stdout and leave collection to the platform (the "logs as event streams" idea of the twelve-factor method), which the cookiecutter already does with its console handler; the file handlers above are for virtual machines and long-lived hosts, where logrotate or a RotatingFileHandler must keep the disk from filling up.

Logging From Your Code

The official chapter’s advice is to create one logger per module with the module’s qualified name. Because the cookiecutter names its package logger after the package, every module of the package inherits the bookshelf_shelves configuration automatically.

import logging
from pyramid.config import Configurator
from pyramid.response import Response

log = logging.getLogger('bookshelf_shelves.views')   # in a module: logging.getLogger(__name__)

def show_shelf(request):
    shelf_id = request.matchdict['shelf_id']
    log.info('showing shelf %s for %s', shelf_id, request.remote_addr)
    if shelf_id == '0':
        log.warning('shelf %s is a placeholder', shelf_id)
    return Response('shelf %s' % shelf_id)

with Configurator() as config:
    config.add_route('shelf', '/shelves/{shelf_id}')
    config.add_view(show_shelf, route_name='shelf')
    basic_app = config.make_wsgi_app()

from webtest import TestApp
TestApp(basic_app).get('/shelves/7')

Habits that matter more than any setting:

  • Pass the arguments to the call (log.info('showing shelf %s', shelf_id)) instead of formatting the string first. The message is only built if a handler will use it, and log aggregation tools can group records by the unformatted template.

  • Use log.exception('...') inside an except block to include the traceback at ERROR level, or pass exc_info=True to another level.

  • Never log secrets, session cookies, Authorization headers, password form fields or full payment data. A line such as log.debug('request: %r', request.params) is a leak waiting for a form that has a password field.

  • Log events and identifiers (shelf 7 deleted by user 42), not whole objects.

Correlating the lines of one request

A single page view produces lines from the view, from the database layer and from the access log. A request identifier in every line makes them findable. The standard library offers contextvars for per-request state and a log-record factory that adds the identifier to every record, whichever logger created it. The tween below sets the identifier, copies it into an X-Request-ID response header and writes one access line per request, replacing the need for TransLogger when you only want your own log.

import contextvars
import logging
import re
import time
import uuid

from pyramid.config import Configurator
from pyramid.response import Response
from pyramid.tweens import EXCVIEW

request_id_var = contextvars.ContextVar('request_id', default='-')

_old_factory = logging.getLogRecordFactory()

def _record_factory(*args, **kwargs):
    record = _old_factory(*args, **kwargs)
    record.request_id = request_id_var.get()
    return record

logging.setLogRecordFactory(_record_factory)

SAFE_ID = re.compile(r'[A-Za-z0-9-]{8,64}')
access_log = logging.getLogger('bookshelf_shelves.access')

def request_logging_tween_factory(handler, registry):
    def request_logging_tween(request):
        incoming = request.headers.get('X-Request-ID', '')
        rid = incoming if SAFE_ID.fullmatch(incoming) else uuid.uuid4().hex[:16]
        token = request_id_var.set(rid)
        started = time.perf_counter()
        status = 500
        try:
            response = handler(request)
            status = response.status_code
            response.headers['X-Request-ID'] = rid
            return response
        finally:
            elapsed_ms = (time.perf_counter() - started) * 1000
            route = request.matched_route.pattern if request.matched_route else '-'
            access_log.info('%s %s route=%s status=%s %.1f ms',
                            request.method, request.path, route, status, elapsed_ms)
            request_id_var.reset(token)
    return request_logging_tween

# In a project the tween lives in a module of the package and is named by its dotted path. This shim registers the
# function under such a name so that the page can run as a single script.
import sys, types
sys.modules['shelves_requestlog'] = module = types.ModuleType('shelves_requestlog')
module.request_logging_tween_factory = request_logging_tween_factory

with Configurator() as config:
    config.add_tween('shelves_requestlog.request_logging_tween_factory', over=EXCVIEW)
    config.add_route('shelf', '/shelves/{shelf_id}')
    config.add_view(show_shelf, route_name='shelf')
    app = config.make_wsgi_app()

seen = []
class Collect(logging.Handler):
    def emit(self, record):
        seen.append(self.format(record))
collector = Collect()
collector.setFormatter(logging.Formatter('%(request_id)s %(name)s %(message)s'))
logging.getLogger('bookshelf_shelves').addHandler(collector)
logging.getLogger('bookshelf_shelves').setLevel(logging.INFO)

client = TestApp(app)
resp = client.get('/shelves/7', headers={'X-Request-ID': 'abc12345-from-proxy'})
client.get('/shelves/8', headers={'X-Request-ID': 'bad id with spaces\nforged line'})
client.get('/nothing', status=404)
print('\n'.join(seen))
assert resp.headers['X-Request-ID'] == 'abc12345-from-proxy'
assert seen[0].startswith('abc12345-from-proxy ') and 'forged' not in '\n'.join(seen)
assert 'status=404' in seen[-1]

Points to note in that example:

  • The tween is placed with over=EXCVIEW so that it sees the final response: an exception that an exception view turned into a 404 or 500 page has already become a response, and one that no view handled reaches the finally block with status still at 500 before it propagates to the server. The tween chain itself is the topic of the page on events, tweens and hooks of this section; ptweens production.ini prints the order.

  • An identifier arriving in a header is accepted only if it matches a strict pattern. Copying an unchecked header into a log line lets a client insert newlines and forge entries ("log injection"); the second request above shows the bad value being replaced.

  • The access line logs the route pattern (/shelves/{shelf_id}), not only the concrete path, so that dashboards can group by route without an explosion of distinct values.

  • A record factory is process-wide: install it once in application setup, not per request, and note that it only adds an attribute; a format that references %(request_id)s therefore works with every handler, including those of other libraries. A logging.Filter attached to one handler would be an alternative but would have to be attached to every handler that prints the field.

Pyramid’s Own Debug Messages

Four settings make the framework log why it did what it did. They are the first thing to switch on when a view is not called, a route does not match or a permission check surprises you. Each can be set in the [app:main] section (pyramid.debug_routematch = true) or through an environment variable (PYRAMID_DEBUG_ROUTEMATCH=1), and the umbrella pyramid.debug_all (PYRAMID_DEBUG_ALL) turns them all on. The environment page of the official documentation lists the first three; debug_templates is read by the settings code of Pyramid and by template add-ons such as pyramid_jinja2 and pyramid_chameleon.

Setting Environment variable What it logs

pyramid.debug_routematch

PYRAMID_DEBUG_ROUTEMATCH

For each request, which route matched (route name, pattern, match dictionary, predicates) or that no route matched.

pyramid.debug_notfound

PYRAMID_DEBUG_NOTFOUND

The details behind a not-found response: context, view name, subpath and traversal path. The same text is also added to the body of the 404 response.

pyramid.debug_authorization

PYRAMID_DEBUG_AUTHORIZATION

The result of each permission check against the active security policy: the permission, the context and the ACL decision. The same text is also added to the body of the 403 response.

pyramid.debug_templates

PYRAMID_DEBUG_TEMPLATES

A debug flag for template renderers. Not documented on the core environment page; template add-ons that support it (pyramid_jinja2, pyramid_chameleon) read it.

The official page says the messages are printed to stderr; what the code does is send them to the logging system at DEBUG level, on the logger named after your application package, so they appear in development (where the package logger is at DEBUG) and stay quiet in production (where it is at WARN), even though the settings are still read. The example runs the application with the route-match and not-found flags on.

with Configurator(settings={'pyramid.debug_routematch': 'true',
                            'pyramid.debug_notfound': 'true'}) as config:
    config.add_route('shelf', '/shelves/{shelf_id}')
    config.add_view(show_shelf, route_name='shelf')
    debug_app = config.make_wsgi_app()

debug_seen = []
class Capture(logging.Handler):
    def emit(self, record):
        debug_seen.append(record.getMessage())
package_logger = logging.getLogger('__main__')
package_logger.addHandler(Capture())
package_logger.setLevel(logging.DEBUG)

client = TestApp(debug_app)
client.get('/shelves/7')
client.get('/shelves/7/extra', status=404)
print('\n'.join(m[:110] for m in debug_seen))
assert any('route matched' in m for m in debug_seen)
assert any('no route matched' in m for m in debug_seen)

(Here the application lives in a script, so the package logger is __main__; in a project it is the name of the package, for example bookshelf_shelves.) These flags are for development. debug_authorization and debug_notfound change what an error response contains and debug_templates and debug_routematch add noise and cost; the cookiecutter sets all of them to false in both .ini files, and a deployment check (below) should keep it that way.

Request Logging With TransLogger

Waitress, the default server, logs its own errors and warnings but not the traffic. The official chapter’s answer is the TransLogger middleware of the Paste project, which writes one line per request in the Apache combined log format to a logger named wsgi. It does not write files itself: the logger is wired to a handler like any other. Install it with pip install Paste and put it in front of the application with a PasteDeploy pipeline, renaming [app:main] to another name and creating a new main that chains the filter and the app:

[app:mypyramidapp]
use = egg:bookshelf_shelves

[filter:translogger]
use = egg:Paste#translogger
setup_console_handler = False

[pipeline:main]
pipeline =
    translogger
    mypyramidapp

The logging section needs the wsgi logger, a handler and a formatter that prints the message untouched (the configuration of the earlier example already has them: logger_wsgi, handler_accesslog and formatter_bare). The equivalent in Python, which the example below runs, is to wrap the application:

from paste.translogger import TransLogger

access_lines = []
class AccessCapture(logging.Handler):
    def emit(self, record):
        access_lines.append(record.getMessage())
wsgi_logger = logging.getLogger('wsgi')
wsgi_logger.addHandler(AccessCapture())
wsgi_logger.propagate = False

logged_app = TransLogger(basic_app, setup_console_handler=False)
client = TestApp(logged_app, extra_environ={'REMOTE_ADDR': '203.0.113.7'})
client.get('/shelves/7', headers={'User-Agent': 'curl/8.9'})
client.get('/nothing', status=404)
print('\n'.join(access_lines))
assert '"GET /shelves/7 HTTP/1.0" 200' in access_lines[0]
assert '"GET /nothing HTTP/1.0" 404' in access_lines[1]

Pitfalls found while running it:

  • setup_console_handler = False is not optional once logging is configured. By default TransLogger attaches its own console handler and sets propagate = False on the wsgi logger, so you would get a second, unconfigured stream.

  • TransLogger sets the level of the wsgi logger to DEBUG when it is created, which happens after pserve has applied the .ini file. The level = INFO on logger_wsgi is therefore overridden; the effective filter is the handler’s level.

  • Give the wsgi logger propagate = 0 when it has its own handler, otherwise every access line also reaches the root logger’s handlers.

  • Do not set logging_level or set_logger_level from the .ini file. The filter factory in Paste 3.10.1 converts those strings with logging._levelNames, an attribute that Python 3 no longer has, and fails at start-up. Leave the defaults and set levels in the logging sections.

  • TransLogger reports the REMOTE_ADDR the server hands to WSGI. Behind a reverse proxy that is the proxy’s address unless the server is told to trust the proxy headers (trusted_proxy in Waitress, covered on the deployment page of this section). Fix the address at the server, not by parsing X-Forwarded-For in your own logging code.

  • Paste is an old package, used here only for this middleware. If you prefer to depend on nothing, the tween above gives the same information, with the route pattern added.

Exception Logging With pyramid_exclog

A traceback printed by the server or an exception view is easy to lose; pyramid_exclog sends every exception that escapes a view to a dedicated logger, so that it ends up in a file, in syslog or in an email. The official chapter on logging points to it for "logging or emailing exceptions". Its documentation targets the 1.x line and says it works with Pyramid 1.5 and later; version 1.1 (2022) is what installs today, and it ran unchanged on Pyramid 2.1 in the examples below.

How it works

config.include('pyramid_exclog') registers an implicit tween placed above the exception view tween, that is, closer to the server (the ptweens output on a project that also includes pyramid_tm lists pyramid_exclog.exclog_tween_factory, then the transaction tween, then pyramid.tweens.excview_tween_factory). Because it sits above the exception-view tween, it sees exceptions that an exception view later turns into a response (for example a custom 500 JSON body) as well as those that nobody handles. It logs them to the logger exc_logger at ERROR level with the traceback, the URL as the message, and the exceptions of the WSGIHTTPException family excluded: a HTTPNotFound or HTTPFound is control flow, not a failure.

The logger has to be configured, otherwise nothing is recorded in production, where the root logger is at WARN and has the console handler only. Add exc_logger to the three keys lists and create the sections, as in the combined example earlier (logger_exc_logger, handler_exc_handler). Give it propagate = 0 if the root logger’s console handler would otherwise print the same traceback again.

exc_lines = []
class ExcCapture(logging.Handler):
    def emit(self, record):
        exc_lines.append(self.format(record))
exc_logger = logging.getLogger('exc_logger')
exc_logger.addHandler(ExcCapture())
exc_logger.propagate = False
exc_logger.setLevel(logging.ERROR)

def broken_shelf(request):
    raise RuntimeError('shelf index is corrupt')

def missing_shelf(request):
    from pyramid.httpexceptions import HTTPNotFound
    raise HTTPNotFound()

def build_exc_app(**settings):
    with Configurator(settings=settings) as config:
        config.include('pyramid_exclog')
        config.add_route('broken', '/broken')
        config.add_view(broken_shelf, route_name='broken')
        config.add_route('missing', '/missing')
        config.add_view(missing_shelf, route_name='missing')
        return config.make_wsgi_app()

import io
quiet = {'wsgi.errors': io.StringIO()}
client = TestApp(build_exc_app(), extra_environ=quiet)
try:
    client.get('/broken')
except RuntimeError:
    pass
client.get('/missing', status=404)
print(exc_lines[0].splitlines()[0])
print(len(exc_lines), 'record(s)')
assert len(exc_lines) == 1 and 'RuntimeError: shelf index is corrupt' in exc_lines[0]

The 404 produced a response and no record, the RuntimeError produced one record with the full traceback. Including the add-on from the .ini file works too and is the usual choice because it needs no code change: pyramid.includes = pyramid_exclog in the [app:main] section.

Settings

Setting Effect Default

exclog.ignore

Exception types never logged. Dotted names or built-in names, whitespace or line separated.

pyramid.httpexceptions.WSGIHTTPException. Setting it replaces the default; list the base class again to keep it.

exclog.extra_info

If true, the message also contains pprint dumps of the WSGI environ, the request parameters and the unauthenticated user id.

false

exclog.get_message

Dotted name of a function (request) -> str that builds the message. When set, exclog.extra_info is ignored.

none

exclog.hidden_cookies

Names of cookies whose value is replaced by hidden in the message. Works with extra_info and with get_message.

none

The documentation page spells the first setting excview.ignore in one paragraph; the code reads exclog.ignore, and that is the name that works.

exclog.extra_info deserves a warning. The WSGI environ contains every request header, so with the option on a log line includes HTTP_AUTHORIZATION, HTTP_COOKIE and anything else the client sent, and the request parameters include form fields such as passwords. The example shows what is written and how hidden_cookies and a custom message function reduce it.

def build_message(request):
    return '%s %s user=%s' % (request.method, request.path, request.remote_addr)

import sys
sys.modules['bookshelf_shelves_demo'] = type(sys)('bookshelf_shelves_demo')
sys.modules['bookshelf_shelves_demo'].build_message = build_message

exc_lines.clear()
leaky = TestApp(build_exc_app(**{'exclog.extra_info': 'true'}), extra_environ=quiet)
try:
    leaky.get('/broken', headers={'Authorization': 'Bearer demo-token',
                                  'Cookie': 'session=demo-session'})
except RuntimeError:
    pass
leak = exc_lines[0]
print('Authorization header in the log:', 'Bearer demo-token' in leak)

exc_lines.clear()
safer = TestApp(build_exc_app(**{'exclog.get_message': 'bookshelf_shelves_demo.build_message'}),
                extra_environ=quiet)
try:
    safer.get('/broken', headers={'Authorization': 'Bearer demo-token'})
except RuntimeError:
    pass
print('Authorization header in the log:', 'Bearer demo-token' in exc_lines[0])
print(exc_lines[0].splitlines()[0])
assert 'Bearer demo-token' in leak
assert 'Bearer demo-token' not in exc_lines[0]

For production the common choices are exclog.get_message with a deliberately small message, or extra_info together with hidden_cookies and a proxy that strips credentials from what the application sees. Do not rely on hidden_cookies for Authorization headers: it only masks cookies.

Files, syslog and email

Because exc_logger is a normal logger, the destination is a handler. A FileHandler writes exceptions.log next to the .ini file (shown in the combined example). The official page also shows handlers.SMTPHandler for email:

[handler_exc_handler]
class = handlers.SMTPHandler
args = (('localhost', 25), 'shelves@example.com', ['ops@example.com'], 'Bookshelf Shelves exception')
level = ERROR
formatter = exc_formatter

Email is the least reliable of the options (an exception storm becomes a mail storm), and SMTPHandler only uses TLS when it is given the secure argument (and credentials only when it is given credentials); read the handler’s documentation before pointing it at a real relay. A hosted error tracker, below, is usually the better choice. Under mod_wsgi, remember that nothing calls pserve; the official page’s run.wsgi example calls setup_logging(conf) and then get_app(conf, 'main') for exactly this reason.

The Debug Toolbar

pyramid_debugtoolbar is a development tool: it adds a collapsible button to every HTML response, a history of recent requests with panels for each, and an interactive traceback page. The project’s own documentation puts the rule at the top of its page: never enable it in a production environment or on a machine whose Pyramid port is exposed to the internet, because when configured poorly it allows arbitrary code execution from only semi-trusted sources.

Enabling it in development only

Install it into the development environment only (pip install pyramid_debugtoolbar, normally a development extra of the project, not a production dependency) and include it from development.ini:

[app:main]
use = egg:bookshelf_shelves
pyramid.includes =
    pyramid_debugtoolbar

# only these client addresses see the toolbar (default: 127.0.0.1 and ::1)
debugtoolbar.hosts = 127.0.0.1 ::1

production.ini has no pyramid.includes line for it and no debugtoolbar.* settings. Keeping the toolbar out of the production file, and out of the production dependencies, is stronger than any setting: if the package is not installed, nothing can switch it on.

Run with pserve development.ini --reload; the toolbar button appears in the upper right corner of every HTML response that has a closing </body> tag, and the history is at /_debug_toolbar/ (the trailing slash matters). The example includes it by code, sends requests from a local and from a remote client address, and shows what each gets.

from pyramid.response import Response

def home(request):
    return Response('<html><body><h1>Shelves</h1></body></html>')

def explode(request):
    raise ValueError('bad shelf')

def build_toolbar_app(**settings):
    with Configurator(settings=settings) as config:
        config.include('pyramid_debugtoolbar')
        config.add_route('home', '/')
        config.add_view(home, route_name='home')
        config.add_route('explode', '/explode')
        config.add_view(explode, route_name='explode')
        return config.make_wsgi_app()

local = {'REMOTE_ADDR': '127.0.0.1'}
remote = {'REMOTE_ADDR': '203.0.113.9'}

dev = TestApp(build_toolbar_app())
print('local sees the button :', 'pDebugToolbarHandle' in dev.get('/', extra_environ=local).text)
print('remote sees the button:', 'pDebugToolbarHandle' in dev.get('/', extra_environ=remote).text)
print('history, local        :', dev.get('/_debug_toolbar/', extra_environ=local).status)
print('history, remote       :', dev.get('/_debug_toolbar/', extra_environ=remote, status=404).status)

crash = dev.get('/explode', extra_environ=local, status=500)
print('interactive traceback :', 'Werkzeug Debugger' in crash.text)

off = TestApp(build_toolbar_app(**{'debugtoolbar.enabled': 'false'}))
print('disabled              :', 'pDebugToolbarHandle' in off.get('/', extra_environ=local).text)
assert 'pDebugToolbarHandle' in dev.get('/', extra_environ=local).text
assert 'pDebugToolbarHandle' not in dev.get('/', extra_environ=remote).text

The remote client got neither the button nor the history page (a 404), and the unhandled exception produced the Werkzeug-derived debugger page for the local client. That page, with its console on every frame, is the dangerous part.

Settings

Setting Effect Default

debugtoolbar.hosts

Client addresses (or networks such as 192.168.1.0/24) that may see the toolbar and the exception handler. Compared with REMOTE_ADDR.

127.0.0.1 and ::1

debugtoolbar.enabled

Switch for both the overlay and the exception handler.

true

debugtoolbar.intercept_exc

debug (interactive traceback with a console), display (traceback page without the console) or false (exceptions propagate to the server).

debug

debugtoolbar.show_on_exc_only

Only inject the toolbar into responses when an exception occurred.

false

debugtoolbar.intercept_redirects

Show an interim page instead of following a redirect, so that the redirecting request can be inspected.

false

debugtoolbar.panels, extra_panels

Replace or extend the list of per-request panels.

all built-in panels

debugtoolbar.global_panels, extra_global_panels

Replace or extend the global panels (introspection, routes, settings, tweens, versions).

all built-in global panels

debugtoolbar.active_panels

Panels that should always be active, for those panels (such as profiling) that are opt-in.

none

debugtoolbar.exclude_prefixes

Request paths starting with one of these (one per line in an .ini) are not tracked.

/favicon.ico

debugtoolbar.max_request_history, max_visible_requests

How many requests are kept in memory, and how many the sidebar shows.

100 and 10

debugtoolbar.includes

Extra includes applied to the toolbar’s internal application, the place to register custom panels.

none

debugtoolbar.button_style

Inline CSS for the toolbar button.

none

debugtoolbar.reload_templates, reload_assets, reload_resources, debug_notfound, debug_routematch, prevent_http_cache

Development switches that the toolbar copies into the matching pyramid.* settings.

false

The history lives in the memory of the process, which is why the toolbar is not suitable for a multi-process server that load balances requests between workers: the panel link may be served by a worker that never saw the request.

The panels

Panel What it shows

Versions

The versions of the installed Python packages, the Python version and the platform.

Settings

The deployment settings (registry.settings) of the application, plus the live toolbar settings tab.

HTTP Headers

Request and response headers of the page.

Request Vars

Objects attached to the request and the WSGI environment.

Renderings

Each rendering performed for the page: the renderer, the template and the values passed to it.

Session

Session data on the way in and out, if the session was used.

Logging

The records logged while that request was handled, a good partner to the logging described above.

Performance

Timing for the request, and Python profiling when it is switched on from the Settings tab or with the pdtb_active cookie.

Routes

The configured routes (a global panel).

Tweens

The tween chain and whether each entry was explicit or implicit (a global panel).

SQLAlchemy

The SQL statements of the request with timing, with links to re-run a SELECT or EXPLAIN it.

Introspection

A rendering of Pyramid’s introspector data.

The two panels that pay for the toolbar on Bookshelf Shelves are SQLAlchemy (each shelf page that shows an author for every book is visibly one query per book) and Renderings (the values a template receives). Custom panels are Python classes derived from pyramid_debugtoolbar.panels.DebugPanel, registered with config.add_debugtoolbar_panel inside a module listed in debugtoolbar.includes; the toolbar documentation walks through a full example, including its two-phase model (data recorded during the original request, rendered during a later toolbar request).

Controlling who sees it

The host list is the first line of defence and a weak one. It compares the address in REMOTE_ADDR, which behind a reverse proxy on the same machine is the proxy’s address, normally 127.0.0.1. Every visitor of the public site then passes the host check. The example makes the point with the address the application would see through such a proxy:

through_local_proxy = {'REMOTE_ADDR': '127.0.0.1'}   # what the app sees when nginx runs on the same host
print('internet visitor behind a local proxy sees the toolbar:',
      'pDebugToolbarHandle' in dev.get('/', extra_environ=through_local_proxy).text)
assert 'pDebugToolbarHandle' in dev.get('/', extra_environ=through_local_proxy).text

That is the situation behind the toolbar documentation’s warning about a machine with its HTTP port exposed. If you must expose a development instance (a shared staging server), add a second gate with the set_debugtoolbar_request_authorization directive, which takes a callback that receives the request and decides per request; it runs after a successful host check. Use the modern API of the request in the callback (the documentation’s example uses the old authenticated_userid function from pyramid.security, which is gone in Pyramid 2.x):

import secrets

from pyramid.interfaces import ISecurityPolicy
from pyramid.session import SignedCookieSessionFactory
from pyramid.settings import aslist
from zope.interface import implementer

@implementer(ISecurityPolicy)
class SessionPolicy:
    """A minimal policy: the identity is the user id stored in the signed session."""
    def identity(self, request):
        return request.session.get('userid')
    def authenticated_userid(self, request):
        return request.session.get('userid')
    def permits(self, request, context, permission):
        return False
    def remember(self, request, userid, **kw):
        request.session['userid'] = userid
        return []
    def forget(self, request, **kw):
        request.session.pop('userid', None)
        return []

def admin_only_toolbar(request):
    admins = aslist(request.registry.settings.get('shelves.toolbar_admins', ''))
    userid = request.authenticated_userid
    return bool(userid) and userid in admins

def login(request):
    request.session['userid'] = request.params['user']
    return Response('ok')

with Configurator(settings={'shelves.toolbar_admins': 'alice'}) as config:
    config.set_session_factory(SignedCookieSessionFactory(secrets.token_hex(32)))
    config.set_security_policy(SessionPolicy())
    config.include('pyramid_debugtoolbar')
    config.set_debugtoolbar_request_authorization(admin_only_toolbar)
    config.add_route('home', '/')
    config.add_view(home, route_name='home')
    config.add_route('login', '/login')
    config.add_view(login, route_name='login')
    gated = TestApp(config.make_wsgi_app(), extra_environ=local)

anonymous = 'pDebugToolbarHandle' in gated.get('/').text
gated.get('/login', params={'user': 'bob'})
bob = 'pDebugToolbarHandle' in gated.get('/').text
gated.get('/login', params={'user': 'alice'})
alice = 'pDebugToolbarHandle' in gated.get('/').text
print('anonymous:', anonymous, ' bob:', bob, ' alice:', alice)
assert (anonymous, bob, alice) == (False, False, True)

(The TestApp client passes the host check by presenting the loopback address. The session secret is generated at start-up with secrets, never written into a source file; a real application takes it from its settings.) Two limits of the callback are documented: it does not protect the toolbar’s static files, which remain reachable, and a callback that returns false hides the toolbar but is only as strong as the identity it consults. Treat all of this as defence in depth for a staging server, not as permission to run the toolbar on the public site.

Keeping debug features out of production

A deployment pipeline can check the configuration before it starts serving. The function below, which a project would call from its main function, refuses to start a production-labelled configuration that includes the toolbar or switches on one of the debug flags. shelves.env is an ordinary application setting set in each .ini file.

from pyramid.settings import asbool

DEBUG_FLAGS = ('pyramid.debug_authorization', 'pyramid.debug_notfound',
               'pyramid.debug_routematch', 'pyramid.debug_templates',
               'pyramid.reload_templates', 'pyramid.debug_all')

def check_production_settings(settings):
    if settings.get('shelves.env') != 'production':
        return
    problems = [name for name in DEBUG_FLAGS if asbool(settings.get(name, 'false'))]
    if 'pyramid_debugtoolbar' in aslist(settings.get('pyramid.includes', '')):
        problems.append('pyramid_debugtoolbar in pyramid.includes')
    if problems:
        raise RuntimeError('debug features enabled in production: ' + ', '.join(problems))

check_production_settings({'shelves.env': 'development',
                           'pyramid.includes': 'pyramid_debugtoolbar'})      # fine
try:
    check_production_settings({'shelves.env': 'production',
                               'pyramid.includes': 'pyramid_debugtoolbar',
                               'pyramid.debug_routematch': 'true'})
except RuntimeError as exc:
    print(exc)
else:
    raise AssertionError('should have been refused')

The same variables can be set in the environment (PYRAMID_DEBUG_ALL, PYRAMID_RELOAD_TEMPLATES), so a production container should be started with a clean environment for those names. The toolbar is also not the only debugging aid a project has; the command-line tools covered in the page on command-line tools and scripts of this section (proutes, pviews, ptweens, pshell) answer many of the same questions without running a browser, and pserve --reload restarts the process when the source changes. breakpoint() works under Waitress for single-threaded debugging, with the caveat that the console must be attached to the process.

Hosted Error Tracking With Sentry

A log file answers "what happened on this server"; an error tracker answers "which exceptions are new since the last release and how many users do they touch". Sentry’s Python SDK has a Pyramid integration. When pyramid is installed it is enabled automatically by sentry_sdk.init, and it reports exceptions that no exception view handled and those whose exception view returned a 500 response. Passing the integration explicitly allows options such as transaction_style (route_name or route_pattern), which controls how requests are grouped into transactions.

import os

import sentry_sdk
from sentry_sdk.integrations.pyramid import PyramidIntegration

sentry_events = []

def keep_locally(event, hint):
    sentry_events.append(event)
    return None            # returning None drops the event: nothing leaves the process in this demo

sentry_sdk.init(
    dsn=os.environ.get('SENTRY_DSN'),             # from the environment, never from source
    integrations=[PyramidIntegration(transaction_style='route_pattern')],
    send_default_pii=False,                       # no user ids, cookies, Authorization headers or IP addresses
    traces_sample_rate=0.0,
    environment=os.environ.get('SHELVES_ENV', 'development'),
    before_send=keep_locally,
)

def burn(request):
    raise RuntimeError('shelf index is corrupt')

with Configurator() as config:
    config.add_route('shelf', '/shelves/{shelf_id}')
    config.add_view(burn, route_name='shelf')
    sentry_app = TestApp(config.make_wsgi_app(), extra_environ=quiet)

try:
    sentry_app.get('/shelves/3', headers={'Authorization': 'Bearer demo-token'})
except RuntimeError:
    pass

event = sentry_events[0]
print(event['transaction'], event['level'], event['exception']['values'][-1]['type'])
print('Authorization header present in the event:',
      'Bearer demo-token' in str(event.get('request', {}).get('headers', {})))
assert event['transaction'] == '/shelves/{shelf_id}'
assert 'Bearer demo-token' not in str(event)

Notes from the run: with before_send a callback can inspect or discard an event right before it would be sent, which is also the hook for scrubbing; with route_pattern the transaction is the pattern, not each shelf id, which keeps the grouping useful; in this test setup the callback was called twice for the one failing request (a pyramid and a wsgi event for the same exception, as the event data shows), so the example looks at the first event and does not assert on the count. The decision that matters is send_default_pii: leave it False unless you have a lawful basis for sending personal data to the service, and scrub the rest in before_send. Because the SDK hooks into Pyramid itself, no tween or middleware is added to your configuration; the DSN is read from the environment so that it is never committed.

For a generic WSGI application the SDK also provides sentry_sdk.integrations.wsgi.SentryWsgiMiddleware, documented on the Sentry WSGI integration page.

OpenTelemetry for WSGI

OpenTelemetry is the vendor-neutral way to export traces and metrics. For a Pyramid application there are two levels of integration, and both were run for this page.

The generic WSGI middleware. opentelemetry-instrumentation-wsgi provides OpenTelemetryMiddleware, which wraps any WSGI application and creates one server span per request with the standard HTTP attributes. It knows nothing about Pyramid routes, so the span is named after the method and the concrete path (GET /shelves/3).

from opentelemetry import trace
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
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()                 # a real deployment configures an OTLP exporter instead
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

traced = TestApp(OpenTelemetryMiddleware(basic_app, tracer_provider=provider))
traced.get('/shelves/3?q=1')
span = exporter.get_finished_spans()[0]
print(span.name, span.attributes['http.status_code'])
assert span.name == 'GET /shelves/3'
exporter.clear()

The Pyramid instrumentation. opentelemetry-instrumentation-pyramid adds a tween and can name the span after the matched route. It offers two methods: PyramidInstrumentor().instrument() instruments every Configurator created afterwards, and PyramidInstrumentor().instrument_config(config) instruments one. If the application sets the pyramid.tweens setting explicitly, the tween opentelemetry.instrumentation.pyramid.trace_tween_factory must be listed there as well.

from opentelemetry.instrumentation.pyramid import PyramidInstrumentor

def shelf_view(request):
    return Response('ok')

config = Configurator()
PyramidInstrumentor().instrument_config(config)
config.add_route('shelf', '/shelves/{shelf_id}')
config.add_view(shelf_view, route_name='shelf')
instrumented = TestApp(config.make_wsgi_app())
instrumented.get('/shelves/3')
spans = exporter.get_finished_spans()
print([s.name for s in spans])
assert spans, 'no span exported'
PyramidInstrumentor().uninstrument_config(config)

Pick one of the two for a given application; stacking both gave, in the run, a server span from the middleware plus an internal span named after the route, which is rarely what you want. The environment variable OTEL_PYTHON_PYRAMID_EXCLUDED_URLS (or the generic OTEL_PYTHON_EXCLUDED_URLS) takes a comma-separated list of regular expressions to keep health checks out of the traces. Span names with the concrete path are a classic cause of a metrics backend running out of cardinality; with the generic middleware, prefer the route-aware Pyramid instrumentation.

The three tools do different jobs and combine well: pyramid_exclog or Sentry for exceptions, the access log (or the tween above) for a record of every request, OpenTelemetry for the timing of work inside it. The AI section of this site shows how traces and logs reach an observability backend (Observability with OpenTelemetry); the Pyramid-specific part is only where the instrumentation hooks in.

A Production Checklist

Check Why

production.ini has no pyramid_debugtoolbar include, and the package is not installed in the production image.

The toolbar’s interactive console is remote code execution for anyone who reaches it.

All pyramid.debug_* and pyramid.reload_templates settings are false, and PYRAMID_* variables are not set.

They change response bodies, add noise and cost time.

The root logger is WARN (or INFO), not DEBUG; sqlalchemy.engine is not listed.

DEBUG output is large and, for SQLAlchemy, contains parameter values.

exc_logger has a handler, or an error tracker is wired in.

Without it production exceptions leave only a line in the server’s own output.

exclog.extra_info is off, or exclog.get_message and exclog.hidden_cookies limit what is written.

extra_info writes headers and form parameters.

Access logs show the real client address.

Configure the proxy trust at the server; do not parse forwarding headers in logging code.

Logs go to stderr in containers, or are rotated on virtual machines.

Disks fill up; container platforms collect the standard streams.

Credentials (Sentry DSN, SMTP password, session secrets) come from the environment or a secret store.

Nothing sensitive in .ini files that are committed.

Any script or .wsgi entry point calls setup_logging.

Only pserve does it for you.

Settings and environment handling are covered in Settings, Environment & .ini Files, and the way errors become responses in Errors & Exception Views; the same transaction tween that aborts the database work for a failed request is described in SQLAlchemy, Transactions & Migrations. Running the whole thing in a container is the subject of the deployment page of this section, with Docker and Kubernetes for the platforms. For logging and error reporting on the other two Python frameworks of this site, see the Tornado page on options and logging linked above and, for an ASGI-world counterpart of TransLogger, the middleware page of FastAPI: Middleware, CORS & Proxies.

What Has Changed

  • The logging chapter of the 2.1 documentation (last updated 2026-03-11) describes the same approach as always: a fileConfig section in the .ini file, applied by pserve or setup_logging. There is no Pyramid-specific logging API to learn.

  • Pyramid 2.0 dropped Python 2; the logging calls above use the standard library of Python 3.10+.

  • The module-level functions authenticated_userid and unauthenticated_userid are no longer in pyramid.security (an import check on 2.1 finds neither); the request attribute request.authenticated_userid, backed by a security policy, replaces them. Examples that import the functions, including the one on the current toolbar documentation page, need the request-based form.

  • The documentation of pyramid_exclog (last updated 2022) still says "Pyramid 1.5 and better", refers to pyramid.security.unauthenticated_id and spells one setting excview.ignore; the add-on itself ran on Pyramid 2.1 in the examples above, and the setting that works is exclog.ignore.

  • pyramid_debugtoolbar 4.12.1 (2024) is the current release; its docs describe the toolbar as derived from flask-debugtoolbar and Werkzeug’s debugger code. The debugtoolbar.intercept_exc default of debug is the setting to change first when exposing a staging server.

  • Paste’s TransLogger still works on Python 3.13 and remains the access-log mechanism the Pyramid documentation describes, but its .ini filter cannot accept level names (see above) and the package is old.