Redact Secrets in Sentry Error Reports

An error tracker is the highest-value target for accidental credential disclosure in a typical Python service, and the least examined. It captures more context than any log line — the local variables in every frame of the traceback, the last hundred log messages as breadcrumbs, the request headers and body, the process environment — and it ships all of it to a store with long retention that a large part of the engineering organisation can read. Configuring what it may capture is a short task with an outsized payoff. This page covers it, extending the secret types and redaction topic.

Problem 1: frame locals carry the unwrapped value

# ANTI-PATTERN: the raw string lives in this frame when the call raises
def call_api(settings):
    token = settings.api_token.get_secret_value()      # a named local
    return httpx.post(URL, headers={"Authorization": f"Bearer {token}"})   # raises

The settings object masks itself, so a report referencing settings shows nothing sensitive. But token is a plain string in the failing frame, and an error tracker capturing frame locals sends it with the event. This is by far the most common route, and it is invisible in code review because the code looks correct — the field is wrapped, the unwrap is at the boundary, and the leak comes from a feature of the tracker rather than from the code.

Two fixes, applied together. Turn off local-variable capture in the SDK, or restrict it, so the channel is closed globally. And keep unwrapped values out of named locals where practical — passing settings.api_token.get_secret_value() directly as an argument means the value exists as a temporary rather than as a named local, which some capture implementations will not record.

Four channels through which a tracker captures data An error event carries frame local variables, breadcrumbs from earlier log lines and requests, the inbound request's headers and body, and process context — each needing its own control. frame locals breadcrumbs request headers and body process context one event all four channels at once long-retention store broadly readable, searchable
A logging filter touches one of these channels; the other three need their own configuration.

Problem 2: breadcrumbs replay everything that happened first

# ANTI-PATTERN: this log line becomes a breadcrumb attached to any later error
logger.debug("calling %s", url_with_query_token)

Breadcrumb capture records recent log records, outbound HTTP requests and database queries, then attaches them to whatever error happens next. A DEBUG line that would have been discarded by your production log level can still be captured as a breadcrumb, because the SDK’s breadcrumb level is configured separately from the logging handler’s.

That decoupling surprises people, and it means a service with a carefully configured production log level can still ship debug-level detail to the tracker. Set the breadcrumb level explicitly rather than inheriting it, exclude the noisy library loggers that log full URLs, and treat breadcrumbs as though they will be read by everyone who can see errors — because they will.

Problem 3: request capture that includes headers

# ANTI-PATTERN: the integration sends headers by default
sentry_sdk.init(dsn=DSN, send_default_pii=True)     # includes cookies and auth headers

The inbound Authorization header, session cookies and a form body containing a password are all credentials belonging to a user, not to your service, and no typed field in your settings model has anything to do with them. Enabling personally-identifiable-information capture, which several tutorials suggest for better debugging, turns every error into a record of whoever’s credentials were in flight.

Leave that setting off, and add a before_send hook that removes the header and body fields you know about regardless. The hook runs in-process before transmission, which is the only place scrubbing genuinely prevents exposure — server-side rules apply after the data has already been sent and stored.

Secure implementation

# observability.py — configure what may leave the process, before it leaves
import logging
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration

SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key", "proxy-authorization"}
SENSITIVE_KEYS = {"password", "token", "secret", "api_key", "signing_key"}


def _scrub(event: dict, hint: dict) -> dict | None:
    # 1. request headers: replace by name, never by pattern
    request = event.get("request", {})
    headers = request.get("headers", {})
    for name in list(headers):
        if name.lower() in SENSITIVE_HEADERS:
            headers[name] = "[redacted]"
    request.pop("data", None)           # 2. bodies are never worth the risk

    # 3. belt and braces: strip known key names from any frame locals that survive
    for value in event.get("exception", {}).get("values", []):
        for frame in value.get("stacktrace", {}).get("frames", []):
            for var in list(frame.get("vars", {})):
                if any(k in var.lower() for k in SENSITIVE_KEYS):
                    frame["vars"][var] = "[redacted]"
    return event


sentry_sdk.init(
    dsn=SENTRY_DSN,
    send_default_pii=False,             # 4. no cookies, no auth headers, no user data
    include_local_variables=False,      # 5. closes the largest channel outright
    max_breadcrumbs=25,
    before_send=_scrub,                 # 6. runs in-process, before transmission
    integrations=[
        LoggingIntegration(
            level=logging.INFO,         # 7. breadcrumbs from INFO, not DEBUG
            event_level=logging.ERROR,
        )
    ],
)

include_local_variables=False is the single highest-value line here, and the one teams most often resist, because frame locals are genuinely useful when debugging. The compromise that works is to keep them off globally and to attach the specific context you need explicitly — a set_context call with the values you have chosen — so debugging information is something you opted into rather than everything the interpreter happened to be holding.

The before_send hook is written to fail safe in one respect worth copying: it removes by name from known structures rather than searching serialised text. Name-based removal is exact, cheap, and does not depend on a value’s shape. It also has a known blind spot — a credential stored under an unexpected key name — which is precisely why it sits behind the two settings above rather than in front of them.

Where scrubbing has to happen relative to transmission A before_send hook runs inside the process before the event is transmitted, while server-side scrubbing only applies after the data has already been sent and stored. event built in your process before_send scrub here — nothing has left transmitted over the network stored too late server-side rules act on data that has already been sent and written
Only the in-process hook prevents transmission; everything downstream is cleanup after the fact.

Treating captured events as long-retention storage

The mental model that leads to good decisions here is that an error tracker is a searchable database of your production internals with a retention period measured in months and an access list measured in dozens. Every question about what to capture becomes easy under that framing: would you write this value into a shared, searchable, months-long store? For a credential the answer is no regardless of how useful it would be during debugging.

That framing also settles the self-hosted question. Running your own instance removes the third-party exposure and does nothing about internal access or retention — a captured credential is still sitting in a store that a large group can search, and it still needs rotating. The benefit of self-hosting is that you control the retention policy and can delete decisively; the risk profile of capturing the value in the first place is essentially unchanged.

It also implies an incident procedure worth writing down before you need it. When a credential is found in a captured event, the sequence is: rotate the credential first, then delete the affected events, then fix the capture configuration, then check whether the same value reached any other retention system in the same period. Doing it in that order matters — deleting the evidence before rotating leaves a live credential in whatever copies existed elsewhere, including any alerting integration that forwarded the event into a chat channel.

Sampling deserves a mention in the same breath, because it changes the arithmetic rather than the principle. A service that samples one error in ten still transmits every field of the events it does send, so sampling reduces volume and cost without reducing the probability that a given credential eventually appears — it only delays it. Treat sampling as a budget decision and the capture configuration as the security decision, and never let a low sample rate stand in for scrubbing.

Gotchas and version-specific behaviour

  • Breadcrumb level is configured separately from your logging handler’s level, so a DEBUG line can be captured even when it is not logged.
  • send_default_pii=True enables cookie and header capture — leave it off, and do not enable it “temporarily” to debug an auth issue.
  • include_local_variables is the modern spelling of the frame-locals setting; older code may use with_locals.
  • before_send returning None drops the event entirely, which is a useful escape hatch for a noisy error class but discards signal.
  • Alerting integrations forward event content into chat and email, extending the exposure beyond the tracker’s own access controls.
  • The SDK captures a snapshot of the environment in some configurations, which can include variables you never intended to send.
  • A tracker’s own client key is a credential in your configuration; treat it like any other and keep it out of client-side bundles where the equivalent key is public by design.

There is one configuration decision that cuts across all four channels: which environments report at all. Sending events from local development and CI to the same project as production is convenient and quietly doubles the exposure, because those environments often hold test credentials that are real enough to matter and configurations that nobody has hardened. Either disable reporting outside deployed environments, or send to a separate project with its own short retention, so a developer machine’s stack trace never lands in the same searchable store as production’s.

Production parity checklist

  • include_local_variables=False and send_default_pii=False in every environment, not just production.
  • A before_send hook redacts known header names and drops request bodies before transmission.
  • Breadcrumb level is set explicitly and noisy library loggers are excluded.
  • Alerting integrations are reviewed for what event content they forward, and where.
  • A written procedure exists: rotate, delete events, fix capture, check other systems.
  • The tracker’s own key is stored as a SecretStr alongside every other credential.
Incident order when a credential is found in a captured event Rotate the credential first, then delete the affected events, then fix the capture configuration, then check whether the value reached other retention systems. 1. rotate the credential, first 2. delete events and any forwarded copies 3. fix capture close the channel 4. check elsewhere logs, chat, metrics deleting before rotating leaves a live credential in the copies you have not found
The order is the important part — rotation is cheap and deletion is not a substitute for it.

Frequently asked questions

Why do secrets appear in an error tracker when logs are clean?

Because a tracker captures more than a log line. It sends frame local variables, breadcrumbs, request headers and body, and process context — several channels that a logging filter never touches. The frame-locals channel is the one that catches teams who have done everything else correctly: the settings object masks itself, the log line is clean, and the raw string sits in a local variable in the frame that raised. Closing it is one SDK setting, and attaching deliberate context with set_context gives back most of the debugging value it removes.

Is disabling frame locals enough?

It removes the largest channel and not the others. Breadcrumbs record earlier log lines and outbound requests, request capture includes headers and possibly a body, and some configurations include environment context. A complete configuration addresses each channel explicitly — locals off, PII off, a before_send hook for headers and bodies, and a deliberate breadcrumb level. It is perhaps twenty lines in total and it is the difference between a tracker that is safe by configuration and one that is safe until somebody writes an unlucky line of code.

Where should scrubbing live — in the SDK or on the server?

In the SDK, before the event leaves the process. Server-side scrubbing depends on the event already having been transmitted and stored, which is exactly the exposure you are trying to avoid — and it does nothing about copies forwarded to chat or email by an alerting rule at ingest time. Server-side rules are a reasonable second layer for a large organisation that cannot audit every service’s SDK configuration, but they are not where the guarantee comes from.

Does a self-hosted tracker remove the problem?

It narrows it to your own systems, which helps with third-party exposure and does nothing about internal access and retention. Captured credentials still need rotating whether the store is yours or a vendor’s, because the exposure that matters is to everyone who can search the events. What self-hosting genuinely buys is control over retention and confident deletion, which shortens the window and makes the incident procedure faster — but it is not a substitute for configuring what gets captured.

Key takeaways

An error tracker captures through four channels, and a settings model’s typed fields protect none of them directly. Frame locals carry unwrapped values, breadcrumbs replay earlier log lines at a level you did not set, request capture includes credentials belonging to your users, and process context can include the environment. Each needs an explicit setting, and all of them together are a short block of initialisation code.

Do the scrubbing in-process with before_send, because that is the only point where the data has not yet been transmitted, and prefer name-based removal from known structures over pattern matching on serialised text. Then treat captured events for what they are — a searchable, long-retention store readable by a large group — and write down the incident order in advance: rotate, delete, fix the configuration, check what else received a copy. The broader disclosure surface is mapped on the secret types and redaction topic page.