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.
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 tosetup_logging). The WSGI application factory does not configure it, which is why a script or amod_wsgideployment 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) unlesspropagate = 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.fileConfigapplies.
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 |
|---|---|---|
|
The dotted name of the logger the section configures. |
Required for every logger except |
|
|
On a logger it is the threshold for creating records; on a handler it is the threshold for emitting them. |
|
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. |
|
|
Set to |
|
Handler class and its constructor arguments as a Python tuple. |
|
|
A |
Attributes are those of |
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
Source: pyramid.paster (Pyramid 2.1 API)
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 |
|---|---|---|---|
|
|
|
Libraries (Waitress, SQLAlchemy, |
|
|
|
The package logger is yours: it is the place to log decisions you want to read in production. |
|
|
not listed |
At |
|
not listed |
|
Added by |
|
not listed |
|
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 anexceptblock to include the traceback atERRORlevel, or passexc_info=Trueto another level. -
Never log secrets, session cookies,
Authorizationheaders, password form fields or full payment data. A line such aslog.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]
Source: Registering Tweens (Pyramid 2.1)
Points to note in that example:
-
The tween is placed with
over=EXCVIEWso 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 thefinallyblock withstatusstill 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.iniprints 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)stherefore works with every handler, including those of other libraries. Alogging.Filterattached 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 |
|---|---|---|
|
|
For each request, which route matched (route name, pattern, match dictionary, predicates) or that no route matched. |
|
|
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. |
|
|
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. |
|
|
A debug flag for template renderers. Not documented on the core environment page; template add-ons that support 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 = Falseis not optional once logging is configured. By defaultTransLoggerattaches its own console handler and setspropagate = Falseon thewsgilogger, so you would get a second, unconfigured stream. -
TransLoggersets the level of thewsgilogger toDEBUGwhen it is created, which happens afterpservehas applied the.inifile. Thelevel = INFOonlogger_wsgiis therefore overridden; the effective filter is the handler’s level. -
Give the
wsgiloggerpropagate = 0when it has its own handler, otherwise every access line also reaches the root logger’s handlers. -
Do not set
logging_levelorset_logger_levelfrom the.inifile. The filter factory in Paste 3.10.1 converts those strings withlogging._levelNames, an attribute that Python 3 no longer has, and fails at start-up. Leave the defaults and set levels in the logging sections. -
TransLoggerreports theREMOTE_ADDRthe 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_proxyin Waitress, covered on the deployment page of this section). Fix the address at the server, not by parsingX-Forwarded-Forin 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]
Source: pyramid_exclog documentation
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 |
|---|---|---|
|
Exception types never logged. Dotted names or built-in names, whitespace or line separated. |
|
|
If true, the message also contains |
false |
|
Dotted name of a function |
none |
|
Names of cookies whose value is replaced by |
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]
Source: pyramid_exclog settings
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
Source: pyramid_exclog: Using
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
Source: pyramid_debugtoolbar: Usage
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 |
|---|---|---|
|
Client addresses (or networks such as |
|
|
Switch for both the overlay and the exception handler. |
|
|
|
|
|
Only inject the toolbar into responses when an exception occurred. |
|
|
Show an interim page instead of following a redirect, so that the redirecting request can be inspected. |
|
|
Replace or extend the list of per-request panels. |
all built-in panels |
|
Replace or extend the global panels (introspection, routes, settings, tweens, versions). |
all built-in global panels |
|
Panels that should always be active, for those panels (such as profiling) that are opt-in. |
none |
|
Request paths starting with one of these (one per line in an |
|
|
How many requests are kept in memory, and how many the sidebar shows. |
100 and 10 |
|
Extra includes applied to the toolbar’s internal application, the place to register custom panels. |
none |
|
Inline CSS for the toolbar button. |
none |
|
Development switches that the toolbar copies into the matching |
|
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 ( |
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 |
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 |
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
Source: pyramid_debugtoolbar: Settings
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 |
|---|---|
|
The toolbar’s interactive console is remote code execution for anyone who reaches it. |
All |
They change response bodies, add noise and cost time. |
The root logger is |
|
|
Without it production exceptions leave only a line in the server’s own output. |
|
|
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 |
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 |
Any script or |
Only |
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
fileConfigsection in the.inifile, applied bypserveorsetup_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_useridandunauthenticated_useridare no longer inpyramid.security(an import check on 2.1 finds neither); the request attributerequest.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 topyramid.security.unauthenticated_idand spells one settingexcview.ignore; the add-on itself ran on Pyramid 2.1 in the examples above, and the setting that works isexclog.ignore. -
pyramid_debugtoolbar4.12.1 (2024) is the current release; its docs describe the toolbar as derived fromflask-debugtoolbarand Werkzeug’s debugger code. Thedebugtoolbar.intercept_excdefault ofdebugis the setting to change first when exposing a staging server. -
Paste’s
TransLoggerstill works on Python 3.13 and remains the access-log mechanism the Pyramid documentation describes, but its.inifilter cannot accept level names (see above) and the package is old.