Writing Custom structlog Processors
A structlog processor is the smallest unit of logging policy you can write: three arguments, one return value, and complete freedom over the record in between. This page covers the contract in detail, the two processor shapes that appear in real services — enrichers and droppers — and the concurrency rules for the stateful ones. It builds on the structlog processors and pipelines guide, part of the modern Python logging libraries deep dive section.
return surfaces as an error inside the next processor, which is why these are worth unit-testing in isolation.Prerequisites
pip install "structlog>=24.1.0,<26.0.0" \
"pytest>=8.0.0,<9.0.0"
Implementation
Step 1 — Write an enricher. The most common processor adds fields. Use setdefault so a value bound at the call site wins over the default.
import os
SERVICE = os.environ.get("SERVICE_NAME", "unknown")
VERSION = os.environ.get("SERVICE_VERSION", "0")
def add_service_metadata(logger, method_name: str, event_dict: dict) -> dict:
event_dict.setdefault("service", SERVICE)
event_dict.setdefault("version", VERSION)
return event_dict
Step 2 — Write a rewriter. Redaction is the canonical case: walk the dict, replace what matches, return it. Keep the key set explicit rather than pattern-matching every value, for the reasons set out in redacting sensitive data in log records.
SECRET_KEYS = frozenset({"password", "token", "api_key", "authorization", "session"})
def redact(logger, method_name: str, event_dict: dict) -> dict:
for key in list(event_dict):
if key.lower() in SECRET_KEYS:
event_dict[key] = "[redacted]"
return event_dict
Place this early — before anything that might copy values into a message string, and certainly before the renderer. A redaction processor at the end of the chain protects the rendered output and nothing else.
Step 3 — Write a dropper. DropEvent is the only way to discard. Place the processor as early as it can still make a correct decision, because everything after it is work avoided.
import structlog
NOISY_ROUTES = {"/healthz", "/readyz", "/metrics"}
def drop_probe_traffic(logger, method_name: str, event_dict: dict) -> dict:
if event_dict.get("route") in NOISY_ROUTES and event_dict.get("status", 200) < 400:
raise structlog.DropEvent # a failing probe still gets through
return event_dict
The status < 400 guard is the part worth copying. A health check that starts returning 503 is exactly the record you want, and a filter written as "drop everything from /healthz" removes it.
Step 4 — Write a stateful processor safely. A sampler or deduplicator holds counters that every worker thread touches. Use a class, guard the state with a lock, and keep the critical section to the counter update.
import threading
import time
import structlog
class BurstSampler:
"""First N of an event pass in full, then one per interval, with a suppressed count."""
def __init__(self, burst: int = 5, interval: float = 1.0, floor: str = "warning"):
self.burst, self.interval = burst, interval
self.floor = floor
self._state: dict[str, list] = {}
self._lock = threading.Lock()
def __call__(self, logger, method_name: str, event_dict: dict) -> dict:
if method_name in {"warning", "error", "critical", "exception"}:
return event_dict # never sample the rare ones
key = str(event_dict.get("event", ""))
now = time.monotonic()
with self._lock: # counters only — nothing expensive in here
seen, last, dropped = self._state.get(key, (0, 0.0, 0))
seen += 1
if seen <= self.burst:
self._state[key] = (seen, now, 0)
return event_dict
if now - last >= self.interval:
self._state[key] = (seen, now, 0)
event_dict["suppressed"] = dropped
return event_dict
self._state[key] = (seen, last, dropped + 1)
raise structlog.DropEvent
Keying on event_dict["event"] rather than on the rendered line is what makes this work: the event name is the stable event class, while the rendered message contains the IDs that make every occurrence unique. The same reasoning, in standard-library terms, is in rate limiting and sampling noisy loggers.
Step 5 — Register it in the right position.
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
add_service_metadata,
redact, # after enrichment, before the renderer
drop_probe_traffic, # after 'route' exists
BurstSampler(burst=5, interval=1.0),
structlog.processors.JSONRenderer(), # last, always
],
cache_logger_on_first_use=True,
)
Configuration options
| Concern | Choice | Recommended |
|---|---|---|
| Shape | function vs class | class when it has state or configuration |
| Mutation | in place vs copy | in place |
| Drop | DropEvent vs None |
DropEvent, always |
| Level filtering | processor vs wrapper | make_filtering_bound_logger |
| Shared state | lock vs none | a lock around counters only |
| Position | — | after what it reads, before the renderer |
| I/O | — | never inside a processor |
Verification
Test processors as plain functions. They take three arguments and return a dict, which makes them the easiest part of a logging setup to cover.
import pytest
import structlog
def test_enricher_does_not_override_the_caller():
out = add_service_metadata(None, "info", {"event": "x", "service": "explicit"})
assert out["service"] == "explicit" # setdefault, not assignment
def test_redaction_masks_known_keys():
out = redact(None, "info", {"event": "login", "token": "sk-live-9f3c"})
assert out["token"] == "[redacted]"
def test_probe_filter_keeps_failures():
with pytest.raises(structlog.DropEvent):
drop_probe_traffic(None, "info", {"route": "/healthz", "status": 200})
kept = drop_probe_traffic(None, "info", {"route": "/healthz", "status": 503})
assert kept["status"] == 503 # a failing probe is the point
def test_sampler_keeps_the_onset():
sampler = BurstSampler(burst=3, interval=999)
passed = 0
for _ in range(50):
try:
sampler(None, "info", {"event": "retrying"})
passed += 1
except structlog.DropEvent:
pass
assert passed == 3 # the first three, then nothing
Expected Output:
test_processors.py::test_enricher_does_not_override_the_caller PASSED
test_processors.py::test_redaction_masks_known_keys PASSED
test_processors.py::test_probe_filter_keeps_failures PASSED
test_processors.py::test_sampler_keeps_the_onset PASSED
Then verify the position in the assembled chain, so a later edit cannot move the renderer:
def test_renderer_is_last():
chain = structlog.get_config()["processors"]
assert isinstance(chain[-1], structlog.processors.JSONRenderer)
Common mistakes
AttributeError: 'NoneType' object has no attribute 'get'
Error signature: the traceback points at a structlog-provided processor, not at yours.
Root cause: your processor fell off the end of a branch without returning the dict.
Remediation: return event_dict on every path, and unit-test the processor directly — the error surfaces one step downstream from the actual mistake.
The filter never fires
Error signature: volume is unchanged and the dropped records still appear. Root cause: the processor reads a key that a later processor adds. Remediation: move it after the enricher that provides the field, and assert the ordering in a test.
The sampler drops errors
Error signature: an incident timeline is missing the failures that triggered it.
Root cause: the sampler ran for every method name.
Remediation: return immediately for warning, error, critical and exception.
Composing processors well
A chain of ten small processors is easier to reason about than three large ones, and the discipline that keeps it that way is worth stating: each processor should have one reason to change.
One concern per processor. A processor that adds service metadata and redacts secrets and drops probe traffic is three policies in one function, and changing any of them means re-reading all three. Splitting them costs three function calls per record — a few hundred nanoseconds — and buys independent testing, independent ordering, and the ability to disable one without touching the others.
Configuration in the constructor, not the environment. A processor that reads os.environ inside __call__ re-reads it per record and cannot be tested with different settings in the same process. Take the settings as constructor arguments and build the instance where the chain is assembled, which is also where a reviewer expects to see the configuration.
class SampleByLevel:
def __init__(self, rates: dict[str, float]):
self.rates = rates # decided once, at configuration time
def __call__(self, logger, method_name: str, event_dict: dict) -> dict:
rate = self.rates.get(method_name, 1.0)
if rate < 1.0 and _deterministic_hash(event_dict) % 100 >= rate * 100:
raise structlog.DropEvent
return event_dict
Deterministic decisions where possible. A sampler that uses a random number produces a different result for the same event on every replica, which makes the aggregate rate correct and any single investigation frustrating. Hashing a stable field instead means the same event is kept or dropped consistently across the fleet, so a trace that survives on one service survives on all of them — the same reasoning that makes trace-ID-based sampling the default for distributed tracing.
Processors that need state elsewhere
Occasionally a processor needs something that is expensive to compute: a feature-flag value, a tenant's configuration, a mapping loaded from a database. None of that belongs in the chain, because the chain runs on the calling thread inside the log call.
The pattern that works is a cache populated elsewhere and read cheaply here. A background task refreshes the mapping on a timer; the processor does a dictionary lookup with a default. If the lookup misses, the processor proceeds without the field rather than blocking to fetch it — a missing field is a small loss, and a network call inside a log statement is a large one.
class TenantEnricher:
def __init__(self, cache: dict[str, str]):
self.cache = cache # refreshed by a background task
def __call__(self, logger, method_name: str, event_dict: dict) -> dict:
tenant = event_dict.get("tenant_id")
if tenant:
name = self.cache.get(tenant) # a dict lookup, never a fetch
if name:
event_dict["tenant_name"] = name
return event_dict
| Need | Do | Do not |
|---|---|---|
| Configuration | constructor arguments | read the environment per record |
| Expensive lookups | a cache refreshed elsewhere | fetch inside __call__ |
| Sampling | hash a stable field | use a random number |
| Shared counters | a lock around the counter only | a lock around the whole body |
| Failure inside the processor | let it raise in tests, guard in production | swallow it silently either way |
What happens when a processor raises
An exception in a processor propagates out of the logging call and into the code that was logging, which is almost never what you want in production and exactly what you want in tests. The safe shape is a guard that is explicit about which exceptions it tolerates, placed only around the parts that can genuinely fail — a serialisation of an unknown object, a regex over user-supplied text — rather than around the whole function, which turns a bug into silence.
def enrich(logger, method_name: str, event_dict: dict) -> dict:
try:
event_dict["payload_size"] = len(json.dumps(event_dict.get("payload", {})))
except (TypeError, ValueError):
event_dict["payload_size"] = -1 # explicit, and visible in a query
return event_dict
The -1 matters more than it looks: a field that silently disappears when serialisation fails is indistinguishable from one that was never set, while a sentinel value shows up in a query and tells you the enrichment is failing.
Related
- structlog processors and pipelines — the parent guide: the chain, the renderer, and ProcessorFormatter.
- Testing structlog output with pytest — asserting on captured events rather than on rendered text.
- structlog in Celery workers — the same chain in a forking worker.
- Redacting sensitive data in log records — what a redaction processor has to cover.
- Rate limiting and sampling noisy loggers — the same sampler, written for the standard library.
Frequently Asked Questions
Can a processor be a class instead of a function?
Yes — anything callable with the three-argument signature works, and a class is the natural choice when the processor has configuration or state. structlog's own TimeStamper and JSONRenderer are classes. Give it a __call__ method with the same signature and construct it in the processors list.
Should a processor mutate the event dict or return a new one?
Mutate it. The dict is created per log call and belongs to the chain, so copying it at every step multiplies allocations for no benefit. The exception is a processor that conditionally rewrites a value bound by the caller: returning a modified copy there makes it obvious the original binding is unchanged for anyone else holding that logger.
How do I make a processor skip certain loggers?
Read the logger name from the event dict, which stdlib.add_logger_name puts there — but only if that processor runs first. The logger argument your processor receives is the underlying logger object, which under some factories has no useful name, so the event dict is the reliable source.
Is it safe to do I/O in a processor?
No. Processors run on the calling thread inside the log call, so a network lookup or a file read there blocks whatever was logging, and under asyncio it blocks the event loop. Anything expensive belongs behind a cache populated elsewhere, or in a handler on the far side of a queue.