Skip to content

Commit 9e2e38f

Browse files
authored
feat(logging): configurable standard-library log filters (#195)
Adds `logging_record_filters` to `LoggingConfig`: a map of exact logger name to `logging.Filter` instances, attached during `LoggingInstrument.bootstrap()` and removed pairwise during `teardown()`. Every framework config inherits it. Filters go on the logger rather than a handler or a structlog processor because `logging.Logger.handle` runs them before `callHandlers`, where Sentry's `LoggingIntegration` patches in. A filter can therefore demote or drop a foreign `LogRecord` before any handler renders it and before Sentry creates an issue, whatever order the bootstrapper installs the two instruments in — only `FreeBootstrapper` installs logging first. `LitestarLoggingInstrument` now overrides `_configure_structlog_loggers` instead of the whole `bootstrap()`. Its override reimplemented the base's steps verbatim, so any new step the base gained was silently skipped for Litestar users; filter attachment was the first such step. Default is empty, no new runtime dependency, existing configs unaffected.
1 parent 6c4e07b commit 9e2e38f

5 files changed

Lines changed: 478 additions & 3 deletions

File tree

‎CONTEXT.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,12 @@ and traceback together. One event is one rendered line is one physical line of s
5757
never diverge.
5858
_Avoid_: formatted message — reserve that for Sentry's own `logentry.formatted` field, which holds
5959
a rendered line only until the seam lifts the message out of it.
60+
61+
**Record filter**:
62+
A `logging.Filter` attached to a named standard-library logger through `logging_record_filters`. It
63+
runs inside `Logger.handle`, so it sees a `LogRecord` before any handler renders it and before
64+
Sentry's patched `callHandlers` turns it into an issue; it may rewrite the record or drop it. The
65+
logger name is exact — a filter on `package` never sees `package.child`.
66+
_Avoid_: processor — a structlog processor runs on an event dict inside the rendering chain, too
67+
late to change what Sentry captured; `logging_extra_processors` takes those. Also avoid bare
68+
"filter" where a handler filter or `structlog.stdlib.filter_by_level` could be meant.

‎docs/introduction/configuration.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Additional parameters:
114114
- `logging_buffer_capacity`
115115
- `logging_extra_processors`
116116
- `logging_unset_handlers`
117+
- `logging_record_filters` - standard-library `logging.Filter` instances to attach to named loggers (see below).
117118
- `logging_time_stamper` - a `structlog.processors.TimeStamper` instance controlling timestamp format (default: `TimeStamper(fmt="iso")`). Pass a custom instance to change the format or enable UTC:
118119

119120
```python
@@ -125,6 +126,40 @@ config = FastAPIConfig(
125126
)
126127
```
127128

129+
### Filtering standard-library log records
130+
131+
`logging_record_filters` attaches `logging.Filter` instances to loggers by name. A filter sees every
132+
record its logger emits, before any handler renders it, and may rewrite the record or return `False`
133+
to drop it — useful for demoting a third-party error you already handle:
134+
135+
```python
136+
import logging
137+
138+
from lite_bootstrap import FreeBootstrapper, FreeConfig
139+
140+
141+
class ExpectedFailureFilter(logging.Filter):
142+
def filter(self, record: logging.LogRecord) -> bool:
143+
if record.levelno == logging.ERROR and "expected failure" in record.getMessage():
144+
record.levelno = logging.WARNING
145+
record.levelname = "WARNING"
146+
return True
147+
148+
149+
bootstrapper = FreeBootstrapper(
150+
bootstrap_config=FreeConfig(
151+
logging_record_filters={"some_library.worker": (ExpectedFailureFilter(),)},
152+
),
153+
)
154+
```
155+
156+
- **Set both `levelno` and `levelname`** when changing a level: handlers read them independently.
157+
- **The logger name is exact.** A filter on `some_library` never sees `some_library.worker`, and
158+
`""` is the root logger, matching only records logged through root itself. That is standard
159+
`logging` behaviour: a logger consults its own filters, then its ancestors' *handlers*.
160+
- **Teardown removes only what it attached**, leaving filters your application put on the same
161+
logger in place.
162+
128163
### Structlog Litestar
129164

130165
When using Litestar, the `StructlogPlugin` is automatically registered, which enables `request.logger` inside route handlers:

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,8 @@ def _build_logging_middleware_config(self) -> "LoggingMiddlewareConfig":
226226
exclude=excluded_paths or None,
227227
)
228228

229-
def bootstrap(self) -> None:
230-
self._unset_handlers()
229+
def _configure_structlog_loggers(self) -> None:
230+
"""Register Litestar's StructlogPlugin instead of calling ``structlog.configure`` directly."""
231231
self.bootstrap_config.application_config.plugins.append(
232232
StructlogPlugin(
233233
config=StructlogConfig(
@@ -245,7 +245,6 @@ def bootstrap(self) -> None:
245245
),
246246
)
247247
)
248-
self._configure_foreign_loggers()
249248

250249

251250
@dataclasses.dataclass(kw_only=True)

‎lite_bootstrap/instruments/logging_instrument.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class LoggingConfig(BaseConfig):
7373
logging_unset_handlers: list[str] = dataclasses.field(
7474
default_factory=list,
7575
)
76+
logging_record_filters: dict[str, tuple[logging.Filter, ...]] = dataclasses.field(default_factory=dict)
7677
logging_time_stamper: "structlog.processors.TimeStamper | None" = None
7778
logging_enabled: bool = True
7879

@@ -84,6 +85,9 @@ class LoggingInstrument(BaseInstrument[LoggingConfig]):
8485
_logger_factory: "MemoryLoggerFactory | None" = dataclasses.field(
8586
default_factory=lambda: None, init=False, repr=False, compare=False
8687
)
88+
_attached_filters: list[tuple[logging.Logger, logging.Filter]] = dataclasses.field(
89+
default_factory=list, init=False, repr=False, compare=False
90+
)
8791

8892
@property
8993
def structlog_pre_chain_processors(self) -> list[typing.Any]:
@@ -160,15 +164,32 @@ def _configure_foreign_loggers(self) -> None:
160164
root_logger.addHandler(stream_handler)
161165
root_logger.setLevel(self.bootstrap_config.logging_log_level)
162166

167+
def _attach_record_filters(self) -> None:
168+
"""Attach each configured filter to its logger, remembering the pair for teardown."""
169+
for logger_name, record_filters in self.bootstrap_config.logging_record_filters.items():
170+
target_logger = logging.getLogger(logger_name)
171+
for record_filter in record_filters:
172+
target_logger.addFilter(record_filter)
173+
self._attached_filters.append((target_logger, record_filter))
174+
175+
def _detach_record_filters(self) -> None:
176+
"""Remove only the filters this instrument attached, leaving any others in place."""
177+
for target_logger, record_filter in self._attached_filters:
178+
target_logger.removeFilter(record_filter)
179+
self._attached_filters.clear()
180+
163181
def bootstrap(self) -> None:
164182
self._unset_handlers()
165183
self._configure_structlog_loggers()
166184
self._configure_foreign_loggers()
185+
self._attach_record_filters()
167186

168187
def teardown(self) -> None:
169188
"""Reset structlog and root logger.
170189
171190
Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten.
191+
Filters from ``logging_record_filters`` are removed one pair at a time, so a filter the
192+
application or another library put on the same logger survives.
172193
173194
Best-effort cleanup: errors from individual ``handler.close()`` calls and from the memory
174195
logger factory's ``close_handlers()`` are collected and re-raised together via
@@ -188,6 +209,7 @@ def teardown(self) -> None:
188209
errors.append((type(h).__name__, e))
189210
root_logger.setLevel(logging.WARNING)
190211
finally:
212+
self._detach_record_filters()
191213
if self._logger_factory is not None:
192214
try:
193215
self._logger_factory.close_handlers()

0 commit comments

Comments
 (0)