xtr-logging 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_logging-1.0.0/LICENSE +21 -0
- xtr_logging-1.0.0/PKG-INFO +536 -0
- xtr_logging-1.0.0/README.md +509 -0
- xtr_logging-1.0.0/pyproject.toml +155 -0
- xtr_logging-1.0.0/pyproject.toml.orig +149 -0
- xtr_logging-1.0.0/src/xtr_logging/__init__.py +161 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/__init__.py +8 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/__init__.py +34 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/level_mapping.py +83 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_capture.py +248 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_capture_handler.py +134 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_handler.py +121 -0
- xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_logger.py +67 -0
- xtr_logging-1.0.0/src/xtr_logging/config/__init__.py +89 -0
- xtr_logging-1.0.0/src/xtr_logging/config/capture_spec.py +85 -0
- xtr_logging-1.0.0/src/xtr_logging/config/channel_filter.py +54 -0
- xtr_logging-1.0.0/src/xtr_logging/config/formatter_builder.py +58 -0
- xtr_logging-1.0.0/src/xtr_logging/config/formatter_specs.py +52 -0
- xtr_logging-1.0.0/src/xtr_logging/config/handler_builder.py +252 -0
- xtr_logging-1.0.0/src/xtr_logging/config/handler_specs.py +162 -0
- xtr_logging-1.0.0/src/xtr_logging/config/logging_config.py +191 -0
- xtr_logging-1.0.0/src/xtr_logging/config/processor_builder.py +69 -0
- xtr_logging-1.0.0/src/xtr_logging/config/processor_specs.py +116 -0
- xtr_logging-1.0.0/src/xtr_logging/config/services.py +63 -0
- xtr_logging-1.0.0/src/xtr_logging/config/wrapper_handler_specs.py +159 -0
- xtr_logging-1.0.0/src/xtr_logging/decorator/__init__.py +5 -0
- xtr_logging-1.0.0/src/xtr_logging/decorator/as_processor.py +61 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/__init__.py +35 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/capture_conflict_error.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/circular_handler_reference_error.py +18 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/empty_stack_error.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/invalid_configuration_error.py +22 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/invalid_option_error.py +26 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/mixed_channel_filter_error.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/not_processable_handler_error.py +18 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/unknown_channel_error.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/unknown_handler_error.py +30 -0
- xtr_logging-1.0.0/src/xtr_logging/exception/unknown_service_error.py +27 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/__init__.py +18 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/console_formatter.py +94 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/formatter_interface.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/json_batch_mode.py +19 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/json_formatter.py +104 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/line_formatter.py +155 -0
- xtr_logging-1.0.0/src/xtr_logging/formatter/normalizer.py +163 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/__init__.py +53 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/abstract_handler.py +81 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/abstract_processing_handler.py +107 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/buffer_handler.py +138 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/console_handler.py +131 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/deduplication_handler.py +168 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fallback_group_handler.py +67 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/filter_handler.py +162 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/__init__.py +11 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/activation_strategy_interface.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/channel_level_activation_strategy.py +53 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/error_level_activation_strategy.py +42 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed_handler.py +193 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/formattable_handler_interface.py +23 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/group_handler.py +105 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/handler_interface.py +43 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/null_handler.py +35 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/processable_handler_interface.py +31 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/queue_handler.py +141 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/rotating_file_handler.py +124 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/sampling_handler.py +130 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/stream_handler.py +119 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/syslog_handler.py +156 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/test_handler.py +108 -0
- xtr_logging-1.0.0/src/xtr_logging/handler/what_failure_group_handler.py +56 -0
- xtr_logging-1.0.0/src/xtr_logging/integration/__init__.py +1 -0
- xtr_logging-1.0.0/src/xtr_logging/integration/wireup.py +88 -0
- xtr_logging-1.0.0/src/xtr_logging/log_context.py +73 -0
- xtr_logging-1.0.0/src/xtr_logging/log_record.py +75 -0
- xtr_logging-1.0.0/src/xtr_logging/logger.py +246 -0
- xtr_logging-1.0.0/src/xtr_logging/logger_factory.py +250 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/__init__.py +25 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/context_vars_processor.py +43 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/hostname_processor.py +40 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/introspection_processor.py +92 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/placeholder_processor.py +115 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/process_id_processor.py +31 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/processor_interface.py +29 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/processor_registry.py +81 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/tag_processor.py +45 -0
- xtr_logging-1.0.0/src/xtr_logging/processor/uid_processor.py +65 -0
- xtr_logging-1.0.0/src/xtr_logging/py.typed +0 -0
- xtr_logging-1.0.0/src/xtr_logging/verbosity.py +48 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-logging
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Channels, handlers, processors and formatters for Python, behind one logger interface.
|
|
5
|
+
Keywords: logging,channels,handlers,processors,structured-logging
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: System :: Logging
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: msgspec>=0.18
|
|
19
|
+
Requires-Dist: typing-extensions>=4.4
|
|
20
|
+
Requires-Dist: xtr-clock>=1.0,<2
|
|
21
|
+
Requires-Dist: xtr-logging-contracts>=1.0,<2
|
|
22
|
+
Requires-Dist: xtr-service-contracts>=1.0,<2
|
|
23
|
+
Requires-Dist: wireup>=2.12 ; extra == 'wireup'
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Provides-Extra: wireup
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
# xtr-logging
|
|
31
|
+
|
|
32
|
+
**Channels, handlers, processors and formatters for Python — behind one logger interface.**
|
|
33
|
+
|
|
34
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
35
|
+
<img alt="core dependencies: 5" src="https://img.shields.io/badge/core%20deps-5-3FB950">
|
|
36
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
37
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why?
|
|
44
|
+
|
|
45
|
+
Code that logs should depend on one small interface, not on how logging is set up. Where records
|
|
46
|
+
go — a file, syslog, standard error, nowhere unless something failed — is configuration.
|
|
47
|
+
|
|
48
|
+
What you get:
|
|
49
|
+
|
|
50
|
+
- 🧩 **One `LoggerInterface`** — eight severities plus `log()`, with a context mapping. A
|
|
51
|
+
library takes one and defaults to `NullLogger`.
|
|
52
|
+
- 📡 **Channels** — one logger per concern (`app`, `security`, `db`), sharing handlers.
|
|
53
|
+
- 🫧 **Handlers bubble** — a stack consulted in order; a record stops where a handler keeps it.
|
|
54
|
+
- 🤞 **Fingers crossed** — buffer a request's whole log, and write it only if something failed.
|
|
55
|
+
- 🔧 **Processors** — enrich every record: placeholders, request ids, hostnames, call sites,
|
|
56
|
+
ambient context.
|
|
57
|
+
- 📋 **Configuration as data** — channels, handlers and processors, readable from TOML or JSON.
|
|
58
|
+
- 🔁 **Standard library bridge** — third-party `logging` output flows into your channels, and
|
|
59
|
+
yours can flow out.
|
|
60
|
+
- 🕰️ **An injectable clock** — record times come from [xtr-clock](https://github.com/xterr/python-xtr-clock),
|
|
61
|
+
so a test freezes them.
|
|
62
|
+
- 🤝 **A contract a library can depend on alone** — the interface lives in
|
|
63
|
+
[xtr-logging-contracts](https://github.com/xterr/python-xtr-logging-contracts), which has one
|
|
64
|
+
dependency, so a library that only logs never installs any of this.
|
|
65
|
+
- 🪶 **Five core dependencies** — `msgspec`, `typing-extensions`, `xtr-clock`,
|
|
66
|
+
`xtr-logging-contracts` and `xtr-service-contracts`.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
logger.error("payment {order} failed", {"order": order.id, "exception": error})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv add xtr-logging # everything but the container integration
|
|
76
|
+
uv add "xtr-logging[wireup]" # + a logger per channel from a wireup container
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from xtr_logging import Logger, PlaceholderProcessor, StreamHandler
|
|
85
|
+
from xtr_logging_contracts import Level
|
|
86
|
+
|
|
87
|
+
logger = Logger(
|
|
88
|
+
"app",
|
|
89
|
+
handlers=[StreamHandler("var/log/app.log", Level.INFO)],
|
|
90
|
+
processors=[PlaceholderProcessor()],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger.info("user {user} logged in", {"user": "ana"})
|
|
94
|
+
# [2026-09-24T12:30:45.123456+03:00] app.INFO: user ana logged in {"user":"ana"} []
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Code that only *uses* a logger asks for the interface:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from xtr_logging_contracts import LoggerInterface, NullLogger
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Checkout:
|
|
104
|
+
def __init__(self, logger: LoggerInterface | None = None) -> None:
|
|
105
|
+
self._logger = logger or NullLogger()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## The logger interface
|
|
109
|
+
|
|
110
|
+
The interface, `Level`, `Context`, `NullLogger`, `AbstractLogger` and `LoggerAware` belong to
|
|
111
|
+
[xtr-logging-contracts](https://github.com/xterr/python-xtr-logging-contracts) and are imported
|
|
112
|
+
from there, not from here. This package exports only what it owns — loggers, handlers,
|
|
113
|
+
processors, formatters and configuration — so there is exactly one place each name comes from and
|
|
114
|
+
no chance of two packages disagreeing about what `LoggerInterface` is.
|
|
115
|
+
|
|
116
|
+
So a library that only logs depends on `xtr-logging-contracts` at runtime and keeps `xtr-logging`
|
|
117
|
+
as a dev dependency for its tests; an application depends on both and wires them together.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
class LoggerInterface(Protocol):
|
|
121
|
+
def emergency(self, message: str, /, context: Context | None = None) -> None: ...
|
|
122
|
+
def alert(self, message: str, /, context: Context | None = None) -> None: ...
|
|
123
|
+
def critical(self, message: str, /, context: Context | None = None) -> None: ...
|
|
124
|
+
def error(self, message: str, /, context: Context | None = None) -> None: ...
|
|
125
|
+
def warning(self, message: str, /, context: Context | None = None) -> None: ...
|
|
126
|
+
def notice(self, message: str, /, context: Context | None = None) -> None: ...
|
|
127
|
+
def info(self, message: str, /, context: Context | None = None) -> None: ...
|
|
128
|
+
def debug(self, message: str, /, context: Context | None = None) -> None: ...
|
|
129
|
+
def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None: ...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The rules:
|
|
133
|
+
|
|
134
|
+
- `context` is a mapping of anything. Formatters describe what they cannot serialise; a value
|
|
135
|
+
never makes logging fail.
|
|
136
|
+
- An exception to report goes under `context["exception"]`. Formatters print its class, message,
|
|
137
|
+
origin and cause, and the traceback if asked.
|
|
138
|
+
- `{key}` placeholders in the message are filled from context by `PlaceholderProcessor`, not by
|
|
139
|
+
the logger, so a handler can still see the template and the values apart.
|
|
140
|
+
|
|
141
|
+
`AbstractLogger` implements the eight methods on top of `log()`, so an implementation writes one
|
|
142
|
+
method. With the `LoggerAware` mixin a class gets a `logger` that is a `NullLogger` until
|
|
143
|
+
`set_logger()` is called.
|
|
144
|
+
|
|
145
|
+
> ruff's `PLE1205` assumes every `logger.info(...)` is the standard library's and flags the
|
|
146
|
+
> context mapping as a stray format argument. Ignore it in projects using this interface.
|
|
147
|
+
|
|
148
|
+
### Levels
|
|
149
|
+
|
|
150
|
+
The eight RFC 5424 severities, valued so they compare as integers:
|
|
151
|
+
|
|
152
|
+
| Level | Value | RFC 5424 | | Level | Value | RFC 5424 |
|
|
153
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
154
|
+
| `DEBUG` | 100 | 7 | | `ERROR` | 400 | 3 |
|
|
155
|
+
| `INFO` | 200 | 6 | | `CRITICAL` | 500 | 2 |
|
|
156
|
+
| `NOTICE` | 250 | 5 | | `ALERT` | 550 | 1 |
|
|
157
|
+
| `WARNING` | 300 | 4 | | `EMERGENCY` | 600 | 0 |
|
|
158
|
+
|
|
159
|
+
Anywhere a level is accepted, `Level.parse` reads it: a `Level`, its value, an RFC 5424 severity,
|
|
160
|
+
or a name in any case (`"error"`). Anything else raises `InvalidLevelError`.
|
|
161
|
+
|
|
162
|
+
## Records, handlers and bubbling
|
|
163
|
+
|
|
164
|
+
Each call becomes an immutable `LogRecord`: `datetime`, `channel`, `level`, `message`, `context`,
|
|
165
|
+
and `extra` (what processors added, kept apart so a processor never overwrites the caller).
|
|
166
|
+
|
|
167
|
+
A logger offers the record to its handlers in stack order. A handler handles records at its
|
|
168
|
+
`level` or above; with `bubble=False` a record it handled goes no further:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
logger = Logger(
|
|
172
|
+
"app",
|
|
173
|
+
[
|
|
174
|
+
StreamHandler("var/log/errors.log", Level.ERROR, bubble=False), # errors stop here
|
|
175
|
+
StreamHandler("var/log/app.log"), # everything else
|
|
176
|
+
],
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`push_handler()` puts a handler on top, `pop_handler()` takes it off. `with_name("security")`
|
|
181
|
+
returns a logger for another channel that shares these handlers.
|
|
182
|
+
|
|
183
|
+
A handler or processor that raises propagates to the caller. Pass
|
|
184
|
+
`exception_handler=` to a `Logger` to receive such failures instead, or wrap handlers in a
|
|
185
|
+
`WhatFailureGroupHandler`. A handler that logs while handling a record is stopped three levels
|
|
186
|
+
deep, with a warning, rather than recursing until the stack overflows.
|
|
187
|
+
|
|
188
|
+
### Handlers
|
|
189
|
+
|
|
190
|
+
| Handler | Does |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `StreamHandler` | Writes to a stream or a file, opened on first write, parent directories created |
|
|
193
|
+
| `RotatingFileHandler` | One file per day (or any `date_format`), keeping the newest `max_files` |
|
|
194
|
+
| `SyslogHandler` | Syslog over UDP or a socket such as `/dev/log`, with the right severity |
|
|
195
|
+
| `ConsoleHandler` | Standard error, or any stream set later, its level following `-v` verbosity, coloured on a terminal |
|
|
196
|
+
| `NullHandler` | Swallows records at its level |
|
|
197
|
+
| `TestHandler` | Keeps records in memory for assertions |
|
|
198
|
+
| `FingersCrossedHandler` | Buffers everything; writes it all once one record is bad enough |
|
|
199
|
+
| `BufferHandler` | Buffers records and writes them as a batch on `close()` |
|
|
200
|
+
| `GroupHandler` | Sends every record to every member |
|
|
201
|
+
| `WhatFailureGroupHandler` | A group where a failing member never stops the others |
|
|
202
|
+
| `FallbackGroupHandler` | Tries members in order until one succeeds |
|
|
203
|
+
| `FilterHandler` | Passes a level range, or a list of levels, to the handler it wraps |
|
|
204
|
+
| `DeduplicationHandler` | Drops an error already written in the last `time` seconds |
|
|
205
|
+
| `SamplingHandler` | Passes one record in `factor` |
|
|
206
|
+
| `QueueHandler` | Writes on a background thread, so logging never waits on I/O |
|
|
207
|
+
| `StdlibHandler` | Hands records to a standard-library logger |
|
|
208
|
+
|
|
209
|
+
### Fingers crossed
|
|
210
|
+
|
|
211
|
+
The handler to run in production. Records are buffered; nothing is written until one
|
|
212
|
+
reaches the action level, and then the whole buffer is — so a failed request leaves its full
|
|
213
|
+
story, and a healthy one leaves nothing:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from xtr_logging import FingersCrossedHandler, Logger, StreamHandler
|
|
217
|
+
from xtr_logging_contracts import Level
|
|
218
|
+
|
|
219
|
+
logger = Logger("app", [FingersCrossedHandler(StreamHandler("var/log/app.log"), Level.ERROR)])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`ChannelLevelActivationStrategy` sets a different trigger per channel, `buffer_size` caps the
|
|
223
|
+
buffer, and `passthru_level` keeps records at that level even when nothing triggered. Call
|
|
224
|
+
`reset()` between the requests or messages of a long-running process so one unit of work does not
|
|
225
|
+
bleed into the next.
|
|
226
|
+
|
|
227
|
+
## Processors
|
|
228
|
+
|
|
229
|
+
A processor is any callable `(LogRecord) -> LogRecord`. Logger processors run once per record,
|
|
230
|
+
and only once some handler will handle it:
|
|
231
|
+
|
|
232
|
+
| Processor | Adds |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| `PlaceholderProcessor` | Fills `{placeholders}` in the message from context |
|
|
235
|
+
| `ContextVarsProcessor` | Whatever is bound with `bind_context()` / `bound_context()` |
|
|
236
|
+
| `UidProcessor` | A random id shared by every record until `reset()` |
|
|
237
|
+
| `IntrospectionProcessor` | The file, line, function and module that logged |
|
|
238
|
+
| `HostnameProcessor`, `ProcessIdProcessor` | The machine, the process |
|
|
239
|
+
| `TagProcessor` | A fixed list of tags |
|
|
240
|
+
|
|
241
|
+
Ambient context rides along without being passed to every call. It lives in a context variable,
|
|
242
|
+
so it is separate per thread and per asyncio or anyio task:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from xtr_logging import bound_context
|
|
246
|
+
|
|
247
|
+
with bound_context({"request_id": request.id, "user": user.id}):
|
|
248
|
+
handle(request) # every record logged in here carries both
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Declare a processor where it is written, and a factory attaches it:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from xtr_logging import LogRecord, as_processor
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@as_processor(channel="billing")
|
|
258
|
+
def add_tenant(record: LogRecord) -> LogRecord:
|
|
259
|
+
return record.with_extra({"tenant": current_tenant()})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`channel=` limits a processor to one channel. `handler=` attaches it to a handler instead;
|
|
263
|
+
handlers are shared, so it then applies on every channel that handler serves. Higher `priority`
|
|
264
|
+
runs first.
|
|
265
|
+
|
|
266
|
+
## Formatters
|
|
267
|
+
|
|
268
|
+
| Formatter | Renders |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| `LineFormatter` | `[%datetime%] %channel%.%level_name%: %message% %context% %extra%` |
|
|
271
|
+
| `JsonFormatter` | One JSON object per record, or a batch as an array or as lines |
|
|
272
|
+
| `ConsoleFormatter` | A short line with the level coloured |
|
|
273
|
+
|
|
274
|
+
`LineFormatter` also knows `%level%` and `%context.KEY%` / `%extra.KEY%` for one entry. It keeps
|
|
275
|
+
each record on one line unless `allow_inline_line_breaks` is set, and prints tracebacks when
|
|
276
|
+
`include_stacktraces` is set. `Normalizer`, which both formatters build on, reduces any value to
|
|
277
|
+
plain data, cutting nesting and collections short at a limit.
|
|
278
|
+
|
|
279
|
+
## Configuration
|
|
280
|
+
|
|
281
|
+
A `LoggingConfig` describes channels, handlers and processors as data. It builds nothing; a
|
|
282
|
+
`LoggerFactory` does:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from xtr_logging import LoggerFactory, LoggingConfig
|
|
286
|
+
from xtr_logging.config import (
|
|
287
|
+
ConsoleHandlerSpec,
|
|
288
|
+
FingersCrossedHandlerSpec,
|
|
289
|
+
PlaceholderProcessorSpec,
|
|
290
|
+
StreamHandlerSpec,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
CONFIG = LoggingConfig(
|
|
294
|
+
channels=("security", "billing"),
|
|
295
|
+
handlers={
|
|
296
|
+
"main": FingersCrossedHandlerSpec(action_level="error", handler="file"),
|
|
297
|
+
"file": StreamHandlerSpec(path="var/log/prod.log"),
|
|
298
|
+
"console": ConsoleHandlerSpec(channels=("!event",)),
|
|
299
|
+
},
|
|
300
|
+
processors=(PlaceholderProcessorSpec(),),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
factory = LoggerFactory(CONFIG)
|
|
304
|
+
security = factory.logger("security")
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Or read it from a file, TOML here:
|
|
308
|
+
|
|
309
|
+
```toml
|
|
310
|
+
channels = ["security"]
|
|
311
|
+
|
|
312
|
+
[handlers.main]
|
|
313
|
+
type = "fingers_crossed"
|
|
314
|
+
action_level = "error"
|
|
315
|
+
handler = "file"
|
|
316
|
+
|
|
317
|
+
[handlers.file]
|
|
318
|
+
type = "stream"
|
|
319
|
+
path = "var/log/prod.log"
|
|
320
|
+
formatter = { type = "json" }
|
|
321
|
+
|
|
322
|
+
[handlers.audit]
|
|
323
|
+
type = "rotating_file"
|
|
324
|
+
path = "var/log/audit.log"
|
|
325
|
+
max_files = 30
|
|
326
|
+
channels = ["security"]
|
|
327
|
+
level = "notice"
|
|
328
|
+
|
|
329
|
+
[[processors]]
|
|
330
|
+
type = "placeholder"
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
config = LoggingConfig.from_mapping(tomllib.loads(Path("logging.toml").read_text()))
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The rules:
|
|
338
|
+
|
|
339
|
+
- **Channels.** `app` (`default_channel`) always exists. `channels` adds more, and a channel
|
|
340
|
+
named in any handler's `channels` is declared too. Asking for any other channel raises
|
|
341
|
+
`UnknownChannelError`.
|
|
342
|
+
- **Channel filters.** `channels: "security"` or `["a", "b"]` includes; `"!event"` or
|
|
343
|
+
`["!a", "!b"]` excludes. Mixing the two raises `MixedChannelFilterError`. A filter applies only
|
|
344
|
+
to a handler on a channel's stack, not to one nested in another handler.
|
|
345
|
+
- **Nesting.** A wrapper names what it wraps (`handler: file`, `members: [a, b]`). A handler
|
|
346
|
+
named that way, or marked `nested`, is left off every channel's stack.
|
|
347
|
+
- **Priority.** Higher is consulted first; ties keep declaration order.
|
|
348
|
+
- **Services.** `type: service` names an object you supply, as do a formatter given by name and
|
|
349
|
+
an `activation_strategy`:
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
LoggerFactory(CONFIG, services=Services(handlers={"sentry": SentryHandler(dsn)}))
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Everything is checked as the configuration is made. A typo'd key, a level that does not exist or
|
|
356
|
+
a value of the wrong type raises `InvalidConfigurationError` naming the path. A wrapper naming a
|
|
357
|
+
missing handler raises `UnknownHandlerError`, and wrappers nesting each other in a loop raise
|
|
358
|
+
`CircularHandlerReferenceError`.
|
|
359
|
+
|
|
360
|
+
Handler types: `stream`, `rotating_file`, `syslog`, `console`, `null`, `stdlib`, `service`,
|
|
361
|
+
`fingers_crossed`, `buffer`, `filter`, `deduplication`, `sampling`, `queue`, `group`,
|
|
362
|
+
`whatfailuregroup`, `fallbackgroup`. Processor types: `placeholder`, `context_vars`, `uid`,
|
|
363
|
+
`introspection`, `hostname`, `process_id`, `tags`, `service`.
|
|
364
|
+
|
|
365
|
+
The factory builds every handler once, as it is made, and shares each between channels. Files
|
|
366
|
+
open on first write. `reset()` ends a unit of work. `close()` writes whatever is buffered or
|
|
367
|
+
queued; use the factory as a context manager, or close it on shutdown. `set_verbosity()` sets
|
|
368
|
+
every console handler at once, from command-line flags:
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
factory.set_verbosity(Verbosity.from_count(args.verbose, quiet=args.quiet, silent=args.silent))
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The map: `--silent` prints nothing, `-q`
|
|
375
|
+
errors and up, no flag warnings and up, `-v` notices, `-vv` info, `-vvv` everything.
|
|
376
|
+
`set_console_stream(stream, colors=...)` points every console handler at another stream — a
|
|
377
|
+
command's error output — with colours forced on or off. [xtr-console](https://github.com/xterr/python-xtr-console)
|
|
378
|
+
does both for every command it runs when its container provides the factory.
|
|
379
|
+
|
|
380
|
+
## The standard library
|
|
381
|
+
|
|
382
|
+
Libraries you depend on — httpx, SQLAlchemy, uvicorn — log through `logging`. Left alone, their
|
|
383
|
+
records go wherever `logging` is set up to send them, and none of your channels see them. A
|
|
384
|
+
`capture` section brings them in:
|
|
385
|
+
|
|
386
|
+
```toml
|
|
387
|
+
channels = ["db"]
|
|
388
|
+
|
|
389
|
+
[capture]
|
|
390
|
+
level = "warning" # the threshold for every stdlib logger not listed below
|
|
391
|
+
|
|
392
|
+
[capture.loggers]
|
|
393
|
+
httpx = "info" # a level of its own; httpx._client and every child follow it
|
|
394
|
+
"sqlalchemy.engine" = { level = "warning", channel = "db" } # and a channel of its own
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Captured records become records on a channel — `app` unless `channel` or a logger's entry says
|
|
398
|
+
otherwise — and go through its processors and handlers like any other: fingers-crossed, JSON,
|
|
399
|
+
files. `extra=` values become context, `exc_info` becomes `context["exception"]`, and the
|
|
400
|
+
original time is kept. A logger's entry covers its children; the most specific name wins.
|
|
401
|
+
|
|
402
|
+
**Nothing is written twice.** Capture does not add a handler next to the ones already there; it
|
|
403
|
+
takes the standard library's output over:
|
|
404
|
+
|
|
405
|
+
- every existing stdlib handler is moved aside — a root `StreamHandler` from `basicConfig`, one
|
|
406
|
+
a library put on its own logger — and every logger propagates to the root, where one capture
|
|
407
|
+
handler is the only output;
|
|
408
|
+
- a handler attached while capture is on — by `addHandler`, `basicConfig` or `dictConfig` — is
|
|
409
|
+
held aside rather than attached, and the capture's own handler cannot be removed;
|
|
410
|
+
- a record with nowhere else to go, from a logger reconfigured not to propagate, is captured
|
|
411
|
+
instead of printed raw by `logging.lastResort`.
|
|
412
|
+
|
|
413
|
+
The factory installs the capture as it is built and gives everything back — handlers, levels,
|
|
414
|
+
flags, `Logger.addHandler` itself — on `close()`. A `stdlib` handler, which sends records into
|
|
415
|
+
`logging`, cannot be combined with a capture: its records would come straight back and be lost,
|
|
416
|
+
so the configuration refuses it with `CaptureConflictError`.
|
|
417
|
+
|
|
418
|
+
Without a factory, `StdlibCapture` does the same, as a context manager or with `install()` and
|
|
419
|
+
`release()`:
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
from xtr_logging.bridge.stdlib import StdlibCapture
|
|
423
|
+
|
|
424
|
+
with StdlibCapture(logger, levels={"httpx": "info"}, routes={"sqlalchemy": db_logger}):
|
|
425
|
+
serve()
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The other way round, `StdlibHandler` hands records to a stdlib logger, keeping their time,
|
|
429
|
+
channel and context. `StdlibLogger` puts the interface in front of a plain `logging.Logger` for
|
|
430
|
+
code that keeps `logging` as its backend.
|
|
431
|
+
|
|
432
|
+
## Wiring with a container
|
|
433
|
+
|
|
434
|
+
With the `wireup` extra, a service asks for the default channel by the interface, and for any
|
|
435
|
+
other by qualifying it with the channel's name:
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
from typing import Annotated
|
|
439
|
+
|
|
440
|
+
from wireup import Inject, injectable
|
|
441
|
+
|
|
442
|
+
from xtr_logging_contracts import LoggerInterface
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@injectable
|
|
446
|
+
class Checkout:
|
|
447
|
+
def __init__(
|
|
448
|
+
self,
|
|
449
|
+
logger: LoggerInterface,
|
|
450
|
+
audit: Annotated[LoggerInterface, Inject(qualifier="security")],
|
|
451
|
+
) -> None: ...
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
from xtr_logging.integration import wireup as logging_integration
|
|
456
|
+
|
|
457
|
+
container = wireup.create_async_container(
|
|
458
|
+
injectables=[app.services, *logging_integration.injectables(CONFIG)],
|
|
459
|
+
)
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The container also provides the `LoggerFactory`, to close or reset it.
|
|
463
|
+
|
|
464
|
+
## Time
|
|
465
|
+
|
|
466
|
+
Every record is stamped by an [xtr-clock](https://github.com/xterr/python-xtr-clock)
|
|
467
|
+
`ClockInterface`. Pass one as `clock=` to a `Logger`, a `LoggerFactory` or a
|
|
468
|
+
`DeduplicationHandler`; without one they read whichever clock is in force, so a test freezes
|
|
469
|
+
every record's time without touching the logger:
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
from xtr_clock import Clock, MockClock
|
|
473
|
+
|
|
474
|
+
with Clock.using(MockClock("2026-09-24 12:00:00")):
|
|
475
|
+
logger.info("frozen") # stamped 2026-09-24 12:00:00+00:00
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Records captured from the standard library keep their original time, in the local zone like a
|
|
479
|
+
native record.
|
|
480
|
+
|
|
481
|
+
## Testing your application
|
|
482
|
+
|
|
483
|
+
`TestHandler` keeps what it handles:
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
from xtr_logging import Logger, TestHandler
|
|
487
|
+
from xtr_logging_contracts import Level
|
|
488
|
+
|
|
489
|
+
handler = TestHandler()
|
|
490
|
+
checkout = Checkout(Logger("app", [handler]))
|
|
491
|
+
|
|
492
|
+
checkout.pay(order)
|
|
493
|
+
|
|
494
|
+
assert handler.has_record_that_contains("payment", Level.ERROR)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
It also answers `has_records(level)`, `has_record(message, level, context)`,
|
|
498
|
+
`has_record_that_matches(pattern, level)` and `has_record_that_passes(predicate, level)`, and
|
|
499
|
+
keeps the rendered text in `formatted`. With a factory, configure a `service` handler and pass a
|
|
500
|
+
`TestHandler`, or fetch any configured handler with `factory.handler(name)`.
|
|
501
|
+
|
|
502
|
+
## Errors
|
|
503
|
+
|
|
504
|
+
Everything the library raises derives from `LoggingError` and carries typed attributes.
|
|
505
|
+
|
|
506
|
+
| Error | Raised when |
|
|
507
|
+
| --- | --- |
|
|
508
|
+
| `InvalidLevelError` | A value names no level (also a `ValueError`) |
|
|
509
|
+
| `InvalidOptionError` | An option has a value its handler or processor cannot use |
|
|
510
|
+
| `EmptyStackError` | A handler or processor is popped from an empty stack |
|
|
511
|
+
| `InvalidConfigurationError` | Configuration data does not fit, with the path to what is wrong |
|
|
512
|
+
| `MixedChannelFilterError` | A channel list mixes `foo` and `!foo` |
|
|
513
|
+
| `UnknownChannelError` | A logger or processor names a channel that is not declared |
|
|
514
|
+
| `UnknownHandlerError` | A wrapper, processor or lookup names a handler that is not defined |
|
|
515
|
+
| `CircularHandlerReferenceError` | Wrappers nest each other in a loop |
|
|
516
|
+
| `UnknownServiceError` | Configuration names a service that was not supplied |
|
|
517
|
+
| `NotProcessableHandlerError` | A processor targets a handler that runs none |
|
|
518
|
+
| `CaptureConflictError` | A `stdlib` handler is configured while capture is on |
|
|
519
|
+
|
|
520
|
+
## Development
|
|
521
|
+
|
|
522
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
523
|
+
`packages/xtr-logging`; run the commands below from there. The `python-xtr-logging` repository is a
|
|
524
|
+
read-only copy, so send issues and pull requests to the monorepo.
|
|
525
|
+
|
|
526
|
+
```sh
|
|
527
|
+
uv sync --all-extras
|
|
528
|
+
uv run ruff check . && uv run ruff format --check .
|
|
529
|
+
uv run basedpyright
|
|
530
|
+
uv run ty check
|
|
531
|
+
uv run pytest
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## License
|
|
535
|
+
|
|
536
|
+
MIT — see [LICENSE](LICENSE).
|