logging references

This commit is contained in:
John Lancaster
2026-07-02 23:22:55 -05:00
parent b3d4e55a15
commit 94dd47cc19
4 changed files with 562 additions and 0 deletions
+3
View File
@@ -18,6 +18,8 @@ Use this skill to produce idiomatic Python logging guidance or a small logging s
Load references only when needed:
- Python logging overview, library guidance, handlers, and dictConfig schema: [Python logging references](./references/python-logging-docs.md)
- Minimal network logging example with a receiver and queue-backed client: [Network logging minimal example](./references/network-logging-minimal-example.md)
- HTTP JSON logging example with `httpx` and a queue-backed client: [HTTPX logging handler example](./references/httpx-logging-handler-example.md)
## When to Use
@@ -127,6 +129,7 @@ def run(count: int) -> None:
- If structured logs are required: keep the same logger and handler topology, but switch formatter output to JSON or a structured formatter.
- If console and file output are needed: add one file or rotating-file handler and attach it centrally.
- If multiple processes write to one file: use a queue/listener or process-safe collection path rather than opening the same file independently in each process.
- If logs must cross a network: send records to a receiver or collector from a queue-backed handler, keep the receiver responsible for final destinations, and avoid exposing unauthenticated logging ports.
- If a framework logger is noisy: add a named logger override with a level and leave unrelated logger propagation alone.
## Completion Checks
@@ -0,0 +1,280 @@
# HTTPX Logging Handler Example
Use this reference when a Python application needs to send log records to an HTTP endpoint with [`httpx`](https://www.python-httpx.org/). The example follows the same boundaries as the TCP network example: feature modules use normal named loggers, application startup configures logging once, a queue keeps HTTP I/O off the caller path, and the receiver or collector owns final routing.
Source docs to keep nearby:
- [HTTPX clients](https://www.python-httpx.org/advanced/clients/) for connection pooling and shared request configuration.
- [HTTPX timeouts](https://www.python-httpx.org/advanced/timeouts/) for connect, read, write, and pool timeout behavior.
- [HTTPX JSON requests](https://www.python-httpx.org/quickstart/#sending-json-encoded-data) for posting JSON payloads.
- [HTTPX exceptions](https://www.python-httpx.org/quickstart/#exceptions) for `RequestError`, `HTTPStatusError`, and `HTTPError` handling.
- [Dealing with handlers that block](https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block) for why slow handlers should sit behind `QueueHandler` and `QueueListener`.
- [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler) and [`QueueListener`](https://docs.python.org/3/library/logging.handlers.html#queuelistener) for queue-backed logging mechanics.
- [`logging.makeLogRecord`](https://docs.python.org/3/library/logging.html#logging.makeLogRecord) for rebuilding records from serialized fields.
## Minimal Topology
Send each record as a JSON HTTP request to a collector endpoint. Use `httpx.Client`, not the top-level `httpx.post`, because a handler may send many records to the same host and should reuse connections.
```text
application code -> named logger -> QueueHandler -> QueueListener -> HTTPX JSON handler -> HTTP receiver or collector
```
Application modules stay ordinary:
```python title="feature.py"
import logging
logger = logging.getLogger(__name__)
def sync_customer(customer_id: str) -> None:
logger.info("Syncing customer %s", customer_id)
```
The module does not know whether logs are written locally, sent over HTTP, or forwarded by a platform collector.
## Minimal Receiver
This receiver is for local testing. A production deployment would usually send the same JSON shape to a managed log collector, OpenTelemetry collector, service endpoint, or internal ingestion API.
```python title="log_http_receiver.py"
import json
import logging
import logging.config
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"console": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "console",
"stream": "ext://sys.stdout",
}
},
"root": {"level": "INFO", "handlers": ["console"]},
}
class LogRecordRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != "/logs":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
try:
payload = json.loads(body.decode("utf-8"))
record = logging.makeLogRecord(payload)
except Exception:
logging.getLogger(__name__).exception("Dropped malformed log record")
self.send_error(400)
return
logger = logging.getLogger(record.name)
if logger.isEnabledFor(record.levelno):
logger.handle(record)
self.send_response(204)
self.end_headers()
def log_message(self, format: str, *args: object) -> None:
logging.getLogger("http.server").debug(format, *args)
def main() -> None:
logging.config.dictConfig(LOGGING)
server = ThreadingHTTPServer(("127.0.0.1", 9021), LogRecordRequestHandler)
with server:
logging.getLogger(__name__).info("Listening for HTTP log records")
server.serve_forever()
if __name__ == "__main__":
main()
```
### Receiver Mechanics
- The receiver accepts `POST /logs` with a JSON body and returns `204` when the record is accepted.
- `makeLogRecord` turns the JSON dictionary back into a standard `LogRecord`, so the receiver can use the normal logger hierarchy.
- Receiver-side filtering still works, but client-side filtering is better when volume matters because it avoids serializing and transmitting records that will be discarded.
- `log_message` is redirected into the logging system at `DEBUG` so access logs do not pollute normal output.
- Binding to `127.0.0.1` keeps the demo local. If the receiver is reachable across a network, put authentication, TLS, rate limits, and request size limits in front of it.
## Client Configuration
The client side uses a queue-backed logging handler. The listener thread owns the `httpx.Client`, posts JSON records, and closes the connection pool during shutdown.
```python title="logging_config.py"
import copy
import logging
import logging.handlers
import queue
import httpx
class PreservingQueueHandler(logging.handlers.QueueHandler):
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
copied = copy.copy(record)
copied.message = copied.getMessage()
copied.msg = copied.message
copied.args = None
if copied.exc_info is not None and copied.exc_text is None:
copied.exc_text = logging.Formatter().formatException(copied.exc_info)
copied.exc_info = None
return copied
class HttpxJsonLogHandler(logging.Handler):
def __init__(self, collector_url: str, token: str | None = None) -> None:
super().__init__()
headers = {"content-type": "application/json"}
if token is not None:
headers["authorization"] = f"Bearer {token}"
timeout = httpx.Timeout(2.0, connect=1.0, write=2.0, pool=1.0)
self.client = httpx.Client(base_url=collector_url, headers=headers, timeout=timeout)
def emit(self, record: logging.LogRecord) -> None:
try:
response = self.client.post("/logs", json=self._payload_from_record(record))
response.raise_for_status()
except httpx.HTTPError:
self.handleError(record)
def close(self) -> None:
self.client.close()
super().close()
def _payload_from_record(self, record: logging.LogRecord) -> dict[str, object]:
payload: dict[str, object] = {
"name": record.name,
"levelno": record.levelno,
"levelname": record.levelname,
"pathname": record.pathname,
"lineno": record.lineno,
"funcName": record.funcName,
"created": record.created,
"process": record.process,
"processName": record.processName,
"threadName": record.threadName,
"msg": record.getMessage(),
"args": None,
}
if record.exc_text is not None:
payload["exc_text"] = record.exc_text
return payload
def configure_logging(
collector_url: str = "http://127.0.0.1:9021",
token: str | None = None,
) -> logging.handlers.QueueListener:
log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=1000)
queue_handler = PreservingQueueHandler(log_queue)
http_handler = HttpxJsonLogHandler(collector_url, token)
listener = logging.handlers.QueueListener(log_queue, http_handler, respect_handler_level=True)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers[:] = [queue_handler]
listener.start()
return listener
```
```python title="app.py"
import logging
from logging_config import configure_logging
logger = logging.getLogger(__name__)
def main() -> None:
listener = configure_logging()
try:
logger.info("Service started")
logger.warning("Example warning from the HTTPX client")
finally:
listener.stop()
if __name__ == "__main__":
main()
```
Start `log_http_receiver.py` first, then run `app.py`. The receiver should print the records using its own formatter.
## HTTPX Mechanics
- `httpx.Client` keeps a connection pool. That matters for log handlers because repeated top-level `httpx.post(...)` calls would create new connections instead of reusing them.
- `base_url` makes the handler explicit about the collector host while keeping the endpoint path short.
- `timeout` is explicit. HTTPX has default timeouts, but logging code should state its tolerance for connect, write, read, and pool waits.
- `response.raise_for_status()` turns non-2xx responses into `HTTPStatusError`, which then goes through the logging handler's normal error path.
- `close()` closes the HTTPX connection pool. Pair this with `listener.stop()` during application shutdown so queued records are sent and resources are released.
## Logging Mechanics
- The application still configures logging once. Feature modules only call named loggers.
- The HTTP handler sits behind `QueueHandler` and `QueueListener` because HTTP requests can block on DNS, connection pooling, TLS, request writes, response reads, and collector back pressure.
- `PreservingQueueHandler` copies the record and formats exception text before clearing `exc_info`, so the queued record is safe to serialize and still carries useful traceback information.
- The handler serializes a deliberate subset of `LogRecord` fields. Sending `record.__dict__` wholesale is easy, but it can include unserializable objects, accidental high-cardinality fields, or data the collector should not receive.
- Authentication is represented as an optional bearer token header. In real applications, read tokens from a secret manager or runtime configuration, not from source code.
## Rationale Behind The Pattern
### Prefer HTTP When The Receiver Is Already An HTTP API
HTTP is a good fit when logs go to a collector, gateway, ingestion service, or internal API that already expects JSON over HTTPS. It also gives you familiar deployment controls: TLS termination, authentication, reverse proxies, rate limiting, request size limits, and conventional status codes.
### Keep HTTP Off The Caller Path
Even a fast collector can become slow during deploys, network incidents, or downstream outages. Queueing makes that failure mode a logging concern instead of a request-latency concern.
### Use A Bounded Queue For Honest Back Pressure
The example uses `queue.Queue(maxsize=1000)` so overload becomes visible. The default `QueueHandler.enqueue()` uses `put_nowait()`, so a full queue calls `handleError()`. For production, decide whether to drop logs, block briefly, spill to disk, or switch to a platform collector.
### Filter Before Sending
The collector can reject records, but rejected records already consumed CPU, queue capacity, and network bandwidth. Use client-side logger and handler levels to avoid sending noisy records unless the deployment explicitly needs them.
## Production Checklist
Before using this beyond a local demo:
1. Use HTTPS and authenticate clients. Treat the collector endpoint as an ingestion boundary, not a public anonymous API.
2. Set request size limits and reject malformed payloads early.
3. Decide the outage policy for collector failures and full queues.
4. Add service, environment, instance, request, trace, or tenant identifiers as explicit serialized fields when operators need correlation.
5. Redact or avoid secrets before records leave the process.
6. Normalize untrusted newline-containing values if the final destination is line-oriented.
7. Tune timeouts and queue size under load, not only with a happy-path local receiver.
8. Prefer a managed collector, OpenTelemetry pipeline, or platform-native logging when one already exists.
## Review Questions
Use these questions when reviewing HTTPX logging code:
- Does application code only call named loggers, without direct HTTP calls from feature modules?
- Is the HTTP handler behind a queue for web, async, worker, or high-throughput paths?
- Does the handler use a reusable `httpx.Client` rather than top-level request functions?
- Are timeouts explicit and short enough for a logging path?
- Are collector failures, non-2xx responses, and full queues handled deliberately?
- Does shutdown stop the listener and close the HTTPX client?
- Are authentication, TLS, secrets, request size, and high-cardinality context handled deliberately?
@@ -0,0 +1,277 @@
# Network Logging Minimal Example
Use this reference when an application needs to send Python logs across a network to a receiver process. The example is intentionally small, but it keeps the important production-shaped boundaries: application modules use normal named loggers, startup code configures routing once, network I/O happens away from the caller path, and the receiver owns final formatting and destinations.
Source docs to keep nearby:
- [Sending and receiving logging events across a network](https://docs.python.org/3/howto/logging-cookbook.html#sending-and-receiving-logging-events-across-a-network) for the standard socket-listener recipe.
- [Dealing with handlers that block](https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block) for why `QueueHandler` and `QueueListener` belong in front of slow handlers.
- [`SocketHandler`](https://docs.python.org/3/library/logging.handlers.html#sockethandler) for the built-in network handler and its pickle-based default wire format.
- [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler) and [`QueueListener`](https://docs.python.org/3/library/logging.handlers.html#queuelistener) for queue-backed logging mechanics.
- [`logging.makeLogRecord`](https://docs.python.org/3/library/logging.html#logging.makeLogRecord) for recreating a `LogRecord` from serialized fields.
- [`socketserver`](https://docs.python.org/3/library/socketserver.html) for a tiny TCP receiver.
- [logging configuration security considerations](https://docs.python.org/3/library/logging.config.html#security-considerations) for treating remote logging configuration and importable config objects as trusted inputs only.
## Minimal Topology
Run one receiver process near the final logging destination. Application processes send serialized records to it, and the receiver decides how those records are formatted, filtered, written, rotated, or forwarded.
```text
application code -> named logger -> QueueHandler -> QueueListener -> JSON TCP handler -> receiver -> final handlers
```
This shape is useful because network handlers can block. Even a socket handler can pause on DNS, connection setup, back pressure, or a slow collector. The queue keeps normal request, worker, or CLI code from doing that work directly.
It also keeps application code boring in the best way:
```python title="feature.py"
import logging
logger = logging.getLogger(__name__)
def process_order(order_id: str) -> None:
logger.info("Processing order %s", order_id)
```
The feature module does not know whether logs go to a terminal, a file, a socket, or a collector. That decision belongs to application startup.
## Receiver
The receiver accepts newline-delimited JSON records, recreates `LogRecord` objects, and routes them through local logging configuration.
```python title="log_receiver.py"
import json
import logging
import logging.config
import socketserver
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"console": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "console",
"stream": "ext://sys.stdout",
}
},
"root": {"level": "INFO", "handlers": ["console"]},
}
class LogRecordHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
for line in self.rfile:
try:
payload = json.loads(line.decode("utf-8"))
record = logging.makeLogRecord(payload)
except Exception:
logging.getLogger(__name__).exception("Dropped malformed log record")
continue
logger = logging.getLogger(record.name)
if logger.isEnabledFor(record.levelno):
logger.handle(record)
class LogRecordServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
def main() -> None:
logging.config.dictConfig(LOGGING)
with LogRecordServer(("127.0.0.1", 9020), LogRecordHandler) as server:
logging.getLogger(__name__).info("Listening for log records")
server.serve_forever()
if __name__ == "__main__":
main()
```
### Receiver Mechanics
- `dictConfig` is local to the receiver. Client processes do not decide the final formatter, file handler, rotation policy, or downstream sink.
- `makeLogRecord` rebuilds a logging record from plain fields. This is the same reconstruction step used by the cookbook socket receiver, but this example uses JSON instead of unpickling bytes from the network.
- The receiver looks up `logging.getLogger(record.name)` so package-level logger names still route through the normal logging hierarchy.
- The `isEnabledFor` check lets receiver-side logger levels suppress records before handlers run. Client-side filtering is still preferred when possible because it avoids wasted network traffic.
- Binding to `127.0.0.1` makes the demo local-only. Binding to `0.0.0.0` changes the trust boundary and should be paired with network controls, authentication, or a real collector protocol.
## Client Configuration
Configure logging once at application startup. The root logger writes to a queue, and a listener thread sends records over TCP.
```python title="logging_config.py"
import copy
import json
import logging
import logging.handlers
import queue
import socket
class PreservingQueueHandler(logging.handlers.QueueHandler):
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
copied = copy.copy(record)
copied.message = copied.getMessage()
copied.msg = copied.message
copied.args = None
if copied.exc_info is not None and copied.exc_text is None:
copied.exc_text = logging.Formatter().formatException(copied.exc_info)
copied.exc_info = None
return copied
class JsonTcpHandler(logging.Handler):
def __init__(self, host: str, port: int, timeout: float = 2.0) -> None:
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
self._socket: socket.socket | None = None
def emit(self, record: logging.LogRecord) -> None:
try:
payload = self._payload_from_record(record)
message = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self._send(message + b"\n")
except Exception:
self._close_socket()
self.handleError(record)
def close(self) -> None:
self._close_socket()
super().close()
def _close_socket(self) -> None:
if self._socket is not None:
self._socket.close()
self._socket = None
def _send(self, message: bytes) -> None:
if self._socket is None:
self._socket = socket.create_connection((self.host, self.port), self.timeout)
self._socket.sendall(message)
def _payload_from_record(self, record: logging.LogRecord) -> dict[str, object]:
payload: dict[str, object] = {
"name": record.name,
"levelno": record.levelno,
"levelname": record.levelname,
"pathname": record.pathname,
"lineno": record.lineno,
"funcName": record.funcName,
"created": record.created,
"process": record.process,
"processName": record.processName,
"threadName": record.threadName,
"msg": record.getMessage(),
"args": None,
}
if record.exc_text is not None:
payload["exc_text"] = record.exc_text
return payload
def configure_logging(host: str = "127.0.0.1", port: int = 9020) -> logging.handlers.QueueListener:
log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=1000)
queue_handler = PreservingQueueHandler(log_queue)
network_handler = JsonTcpHandler(host, port)
listener = logging.handlers.QueueListener(log_queue, network_handler, respect_handler_level=True)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers[:] = [queue_handler]
listener.start()
return listener
```
```python title="app.py"
import logging
from logging_config import configure_logging
logger = logging.getLogger(__name__)
def main() -> None:
listener = configure_logging()
try:
logger.info("Service started")
logger.warning("Example warning from the client")
finally:
listener.stop()
if __name__ == "__main__":
main()
```
Start `log_receiver.py` first, then run `app.py`. The receiver should print the records using its own formatter.
### Client Mechanics
- `PreservingQueueHandler` copies the record before mutating it for queue transfer. The standard `QueueHandler.prepare()` intentionally formats and strips unpickleable fields; overriding it is the documented escape hatch when the listener side needs custom serialization or exception text.
- `queue.Queue(maxsize=1000)` makes back pressure visible. An unbounded queue is simpler, but a bounded queue forces a production decision about whether to drop, block, buffer elsewhere, or fail when the collector cannot keep up.
- `QueueListener` owns the slow handler thread. It should be stopped during application shutdown so queued records are processed before exit.
- `JsonTcpHandler` keeps a TCP connection open after the first event. That avoids a connection handshake per log record while keeping the example small enough to inspect.
- `handleError()` preserves standard logging error behavior. In production, set an explicit policy for dropped records and collector outages rather than assuming logs always arrive.
## Rationale Behind The Pattern
### Use JSON Instead Of The Default Pickle Payload
The built-in `SocketHandler` sends a pickled record dictionary. That is convenient on a trusted local path, but unpickling network input is a poor default at a trust boundary. JSON is not a complete security boundary by itself, but it is inspectable, language-neutral, and avoids executing pickle payloads.
If you use `SocketHandler` anyway, override `makePickle()` with a safer encoding, sign payloads with a scheme such as HMAC, or keep the listener strictly inside a trusted local network.
### Put The Network Handler Behind A Queue
The cookbook explicitly calls out network handlers as potentially blocking. A queue is the smallest standard-library pattern that separates business code from slow handler work. This matters for web requests, async event loops, worker hot paths, and CLIs where the user should not wait on a collector timeout.
### Centralize Final Destinations In The Receiver
Multiple processes writing one file directly is a common source of garbled output, failed rotation, and confusing retention behavior. A receiver process serializes that responsibility: clients emit events, and one process writes or forwards them according to one logging configuration.
### Keep Logger Names Stable
Use module loggers such as `logging.getLogger(__name__)`. Do not create a logger per request, user, socket, tenant, or connection. Put those values in structured fields or formatted messages instead. Logger objects are singletons and are not freed during normal execution, so unbounded logger names become an avoidable memory and routing problem.
### Filter Early When Volume Matters
The receiver can filter records, but records already crossed the network by then. Set client-side logger or handler levels so routine `DEBUG` records are not serialized and transmitted unless the deployment is intentionally collecting them.
## Production Checklist
Before using this beyond a local demo:
1. Protect the receiver with a trusted network boundary, TLS, a VPN, mutual authentication, or a real log collector. Do not expose an unauthenticated logging port to untrusted clients.
2. Decide the outage policy: drop records, block briefly, buffer locally, retry with backoff, or fail startup when the receiver is unavailable.
3. Size the queue and choose the overflow behavior deliberately. The default `QueueHandler.enqueue()` uses `put_nowait()`, so a full bounded queue goes through `handleError()`.
4. Include service, environment, instance, request, trace, or tenant identifiers when operators need cross-service correlation.
5. Redact or avoid secrets before records leave the process.
6. Escape or normalize untrusted newline-containing values if the final destination is line-oriented and vulnerable to log injection confusion.
7. Load-test the receiver and validate shutdown behavior before relying on the logs during incidents.
8. Prefer a managed collector, OpenTelemetry pipeline, syslog, container stdout collection, or platform-native logging when the deployment environment already provides one.
## Review Questions
Use these questions when reviewing network logging code:
- Does normal application code only call named loggers, without attaching handlers in feature modules?
- Is network or file I/O behind a queue for web, async, worker, or high-throughput paths?
- Is the wire format safe for the trust boundary, or does it rely on unpickling unauthenticated input?
- Are logger and handler levels set so noisy records are filtered before crossing the network?
- Is collector failure behavior explicit and tested?
- Does shutdown stop the listener and flush the queue?
- Are secrets, user-controlled newlines, and high-cardinality context handled deliberately?