Skip to main content

redact-secret-adapters

Host integrations for Redact Secret in Python: value-based redaction for the standard library's logging, plus the shared fail-closed walker.

pip install redact-secret redact-secret-adapters

logging

import logging
from redact_secret_adapters.logging_filter import RedactSecretFilter

handler = logging.StreamHandler()
handler.addFilter(RedactSecretFilter())
logging.getLogger().addHandler(handler)

The filter formats msg with args before scanning, then clears the arguments so a downstream formatter cannot rebuild the original. It replaces exc_info with redacted traceback text, scans cached exc_text and stack_info, and redacts any extra_fields=[...] you name. What a named extra can be:

Extra value Becomes
str the masked string
dict, list, tuple (and subclasses), an exception a masked copy, walked to the limits below; a subclass comes back as the plain container
int, float, bool, None itself, unchanged
anything else: a set, bytes, a dataclass, a datetime, any other object [REDACTED:ERROR]

The last row fails closed on purpose. The filter cannot know what a %(ctx)s format or a JSON formatter's default=str would print for an arbitrary object, so it never hands one over unscanned. Convert such a value to a dict or a str yourself before logging it if you want it kept. A container whose read raises (a __getitem__, keys() or slice that raises) becomes [REDACTED:ERROR] for that entry, or for the whole container when it cannot be listed at all; it never raises into the logging call. If the message cannot be formatted (a bad % format, a raising __str__), it becomes [REDACTED:ERROR] rather than raising into the logging call.

RedactSecretFilter(scan_and_redact, ...) accepts an injected scanner; with no argument it uses redact_secret.scan_and_redact.

Where to attach it

A logging.Filter runs only where it is attached. In an application with more than one handler, placement is the security decision, not the filter. The supported setup is one line per emitting handler:

import logging
from redact_secret_adapters.logging_filter import RedactSecretFilter

console = logging.StreamHandler()
audit = logging.FileHandler("audit.log")

redact = RedactSecretFilter()  # no per-record state: one instance can be shared
for handler in (console, audit):
    handler.addFilter(redact)  # every emitting handler, not the logger
    logging.getLogger().addHandler(handler)

This is not global automatic protection. A handler added anywhere else — by a library, by logging.basicConfig, by a child logger of your own — is unprotected until it, too, carries the filter.

Placement Covers Leaves unprotected
Every emitting handler (supported) that handler, and any handler that runs after it on the same record a handler attached later without the filter
A handler on an ancestor logger records that propagate to it, including from child loggers a handler the child carries itself
A logger records logged directly on that logger records propagated from child loggers — Logger.filter never runs for an ancestor
The QueueListener's sink handler the final destination the record while it sits on the queue, and anywhere a QueueHandler subclass sends it (a socket, a multiprocessing queue)

Handlers run in the order they were added and the filter mutates the record in place, so a filtered handler also protects every handler after it — and an unfiltered handler that runs before it emits plaintext. Do not rely on order: filter each one.

For a QueueHandler/QueueListener pair, attach the filter to the QueueHandler. It runs in the emitting thread, so only masked records cross the queue:

handler = logging.handlers.QueueHandler(records)
handler.addFilter(RedactSecretFilter())
listener = logging.handlers.QueueListener(records, logging.StreamHandler())

python/tests/test_logging_placement.py asserts each supported placement and, as synthetic negative controls, that plaintext really does escape each wrong one.

The filter changes nothing else about your configuration: each handler keeps its own formatter, named extra_fields still render, exception logging still works, and a record with no finding is formatted byte-for-byte as it would be without the filter. Masking an already-masked record again is a no-op, so two filtered handlers on one record are safe. What the filter does not cover: record attributes you did not name in extra_fields, and anything a custom formatter adds after it runs.

Counting what happened

Unreleased. on_outcome reports one summary per unit — one logging record, one span. It is observational: increment your own counters from it. Nothing here creates a logger, a handler, an exporter or a network client.

from redact_secret_adapters.logging_filter import RedactSecretFilter


def observe(outcome):  # LogRecordOutcome(level=..., values=ValueCounts(...))
    metrics.increment("log.records", level=outcome.level)
    metrics.increment("log.redacted_values", outcome.values.redacted)


handler.addFilter(RedactSecretFilter(on_outcome=observe))
from redact_secret_adapters.otel import create_redacting_span_processor


def observe(outcome):  # SpanOutcome(values=ValueCounts(...), dropped=False)
    if outcome.dropped:
        metrics.increment("span.dropped_unredactable")


provider.add_span_processor(create_redacting_span_processor(next_processor, on_outcome=observe))

A ValueCounts is six non-negative integers and nothing else — there is no field for a value, a record attribute, a key, an offset or an exception message:

Count Means
scanned Leaves handed to the core. A leaf a bound refused before the core saw it is not one of these
findings Findings the core reported, summed. Not distinct credentials: one credential in five leaves is five findings
redacted Leaves whose text the core changed. Lower than findings when an action leaves text alone (a warn)
blocked Leaves replaced whole by BLOCK_MARKER
limited Values replaced by LIMIT_MARKER; never scanned
failed Values replaced by ERROR_MARKER, plus the CYCLE_MARKER case

The units follow placement: a record through two filtered handlers is two passes and reports twice, which is what a per-handler count means — the second pass finds nothing left to redact. SpanOutcome.dropped is this processor's own decision (a masked value would not write back); it is not a claim that an exporter succeeded, nor that a span was sampled out.

Both observers run after the record or span is fully masked, so neither can turn a protected one into an unprotected one, and anything they raise is swallowed, never read, and never re-raised. The re-entrancy guard is thread-local: an observer that logs or traces does not recurse, and one thread never suppresses another's outcome.

Masking callbacks (Langfuse and similar)

from redact_secret_adapters.mask_secrets import mask_secrets

langfuse = Langfuse(mask=mask_secrets)

PII detection is opt-in

Credential detection needs no init step — the native extension loads on import redact_secret, unlike the JS package's mandatory await initialize(). PII detection does. It is opt-in, process-wide and one-shot, and the application turns it on:

import redact_secret

redact_secret.initialize(pii=["pii:global"])  # before the first record or span

Placement is the whole rule. Handlers are attached and tracer providers built at import time, so a module imported earlier can emit before the line above runs. Those records are scanned with PII off and report nothing — no exception, no warning, and no counter that tells them apart from a record that genuinely held nothing. These adapters cannot close that window, because the process, not the filter, owns the activation; it is pinned as a known limitation in python/tests/test_pii_activation.py rather than hidden. Enable PII first, then attach handlers and build providers.

The selection is one-shot: a later different one raises redact_secret.PiiActivationConflictError, and an empty selection is a different selection, not a neutral one.

Activation is not masking. Under the core's default policy, PII types are confidence-gated rather than always redacted: a High-confidence finding redacts, while Medium and Low resolve to warn — and a warn finding leaves the text alone. Enabling PII therefore still lets lower-confidence PII reach a handler or an exporter as plaintext. Pass your own policy mapping those findings to redact if you need them masked; these adapters decide nothing about policy. The counters make it observable: a record whose values.findings is non-zero while values.redacted stays at zero is exactly this case.

Fail-closed markers

Marker When
[REDACTED:BLOCKED] A block finding — the entire leaf is replaced
[REDACTED:ERROR] Any exception from the core; a value that cannot be read (a raising __str__, __getitem__ or keys()); any object the walker does not walk (see logging). Never the input, never the exception's message
[REDACTED:LIMIT_EXCEEDED] A value past a walk budget; never scanned, never passed through
[REDACTED:CYCLE] A self-referencing object

DEFAULT_LIMITS: max_depth 8, max_array_length 1000, max_object_keys 200, max_string_length 200000, max_total_leaves 5000, max_nodes 20000.

max_nodes counts every value the walk visits: containers and leaves alike, but not dict keys. A value reached by more than one path counts once per path. The walk does not track values it has already visited, so a structure built from shared lists or dicts is walked once per path, and without this budget its cost would grow exponentially. Past max_nodes, every value, a number included, becomes [REDACTED:LIMIT_EXCEEDED].

A limits override that is not a usable bound (None, NaN, a negative number, a bool, or not a number at all) falls back to that key's default, like the TypeScript walker's resolveLimit, instead of raising or disabling the bound. float("inf") is a valid bound and means none.

mask_secrets_with, mask_log_value_with and extra_fields share one walker, so the table under logging holds for all three: any object that is not a string, number, boolean, None, dict, list, tuple or exception becomes [REDACTED:ERROR]. The TypeScript walker instead serializes an object the way JSON.stringify would; Python has no single serialization to mirror.

Dict keys and attribute names are not scanned. The walker masks values only. Every dict key, and every attribute name copied from an exception's __dict__, reaches the handler unchanged, and so does the name of an extra_fields attribute. The OpenTelemetry processor likewise leaves every span, event, and link attribute key as it is. Do not put a secret in a key or an attribute name.

OpenTelemetry ([otel] extra)

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from redact_secret_adapters.otel import create_redacting_span_processor

provider = TracerProvider()
provider.add_span_processor(create_redacting_span_processor(BatchSpanProcessor(otlp_exporter)))

A span's name and status description, every string and string-sequence attribute (a None inside a sequence stays in place), every event's name and attributes, and every link's attributes are redacted before the span reaches the next processor, including OpenInference and GenAI semantic-convention attributes, without hardcoding either convention's attribute list. Attribute names are not scanned (see Fail-closed markers).

opentelemetry-sdk has no public way to change a span before export, so the adapter writes the private fields behind the read-only accessors (_name, _status, _attributes and its backing _dict) and reads each write back through the public accessor. If an SDK release moves one of those fields, the span is dropped rather than exported unredacted, and a RuntimeWarning is issued once per processor. See redact_secret_adapters.otel for details.

Supported range: opentelemetry-sdk>=1.16.0,<2 — CI runs tests/test_otel_host.py, a real TracerProvider/exporter round trip, at both ends of that range on every run.

Development

pip install -e "./python[otel,test]"
pytest

The fixture tests read the same JSON files in the repository's fixtures/ directory as the TypeScript suite; that shared file is what keeps the two languages equivalent.

License

MIT

Metadata

Release files for redact-secret-adapters 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for redact-secret-adapters 0.1.2
File Size Uploaded
redact_secret_adapters-0.1.2.tar.gz 26.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for redact-secret-adapters 0.1.2
File Interpreter ABI Platform
redact_secret_adapters-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 53.9 kB

Release files / redact_secret_adapters-0.1.2.tar.gz

Download URL redact_secret_adapters-0.1.2.tar.gz
Size 26.4 kB
Tags Source
SHA-256 checksum
How to use checksums
591ddf42df39f55e369a2d91e8d78b88f7774a069d750040cac5fd60cc272389
BLAKE2b-256 checksum
How to use checksums
7058163d5a639ba7f78f80cf3530969c6c1a59cf289277a6e9e88e652170b0ad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / redact_secret_adapters-0.1.2-py3-none-any.whl

Download URL redact_secret_adapters-0.1.2-py3-none-any.whl
Size 27.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
214da40f1129e02c96593e19dc5331183ea98d27f327eeb9f700de8bb51e80ee
BLAKE2b-256 checksum
How to use checksums
a6c1b1d2848e669b9d9b727fda5bb929ec2b9e1901cdd8dfdfe3be41dc5ac8f8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.2 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page