HTTP and Webhook Logging Handlers
A logging handler that sends records over HTTP puts a network call inside the logging path, and the logging path runs on every thread that logs. That is occasionally the right trade — a notification to a chat webhook when something needs a human, a record that must reach a system with no collector — and it is a poor default for general log shipping. This page covers when the trade is worth making, and the queue, batching, timeout and rate limit that make it safe when it is. It is a task article under handler architecture, part of the Python logging fundamentals and structured data section.
Prerequisites
pip install "requests>=2.31.0,<3.0.0"
QueueHandler, QueueListener and HTTPHandler are in the standard library.
Implementation
Step 1 — Decide which records justify a network handler. The strongest case is a small, high-value subset: records that need a human's attention now, sent to a chat or paging webhook. The weakest is general log shipping, which a collector reading standard output does more robustly — it survives network partitions, buffers on disk and never puts the network in the request path, as covered in log shipping and collection. A filter that selects only the records meant for the webhook keeps the handler's scope narrow.
import logging
class NotifyFilter(logging.Filter):
"""Only records explicitly marked for notification reach the webhook."""
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno >= logging.ERROR and getattr(record, "notify", False)
Step 2 — Put the handler behind a queue. The request thread must never make the HTTP call. A QueueHandler on the logger enqueues and returns in microseconds; a QueueListener on a background thread owns the HTTP handler and makes the calls. A bounded queue means an unreachable endpoint fills the queue and drops records rather than growing memory. The pattern is developed in non-blocking logging with QueueHandler.
import queue
from logging.handlers import QueueHandler, QueueListener
notify_queue: "queue.Queue[logging.LogRecord]" = queue.Queue(maxsize=1000)
listener = QueueListener(notify_queue, webhook_handler, respect_handler_level=True)
listener.start()
notify_logger = logging.getLogger("notify")
notify_logger.addHandler(QueueHandler(notify_queue))
Step 3 — Batch, with a short timeout. The standard HTTPHandler sends one request per record and has no timeout, so a slow endpoint holds the listener indefinitely. A small subclass that accumulates records and posts them together, with a timeout of a few seconds, bounds the cost of a slow endpoint to one batch.
import json
import sys
import time
import threading
import requests
from prometheus_client import Counter
DELIVERY_FAILURES = Counter("webhook_delivery_failures_total", "Failed webhook posts")
class BatchingWebhookHandler(logging.Handler):
def __init__(self, url: str, max_batch: int = 20, max_wait: float = 5.0,
timeout: float = 3.0):
super().__init__()
self.url, self.max_batch, self.max_wait, self.timeout = url, max_batch, max_wait, timeout
self._buf: list[dict] = []
self._last = time.monotonic()
self._lock = threading.Lock()
self._session = requests.Session()
def emit(self, record: logging.LogRecord) -> None:
with self._lock:
self._buf.append({"level": record.levelname, "logger": record.name,
"message": self.format(record)})
due = len(self._buf) >= self.max_batch or \
time.monotonic() - self._last >= self.max_wait
batch, self._buf = (self._buf, []) if due else ([], self._buf)
if batch:
self._post(batch)
def _post(self, batch: list[dict]) -> None:
self._last = time.monotonic()
try:
self._session.post(self.url, data=json.dumps({"records": batch}),
headers={"content-type": "application/json"},
timeout=self.timeout)
except requests.RequestException as exc:
# 1. Never log this through a logger that routes back here.
DELIVERY_FAILURES.inc()
print(f"webhook delivery failed: {exc.__class__.__name__}", file=sys.stderr)
def flush(self) -> None:
with self._lock:
batch, self._buf = self._buf, []
if batch:
self._post(batch)
Step 4 — Rate limit and aggregate. During an incident the records worth notifying about are produced in bursts, and a webhook receiving hundreds of identical messages is both useless and likely to throttle the sender. A limit on records per minute, with a summary of how many were suppressed, keeps the channel readable.
class RateLimitFilter(logging.Filter):
def __init__(self, per_minute: int = 10):
super().__init__()
self.per_minute, self._window, self._count, self._suppressed = per_minute, 0, 0, 0
def filter(self, record: logging.LogRecord) -> bool:
window = int(time.time() // 60)
if window != self._window:
if self._suppressed:
record.msg = f"{record.msg} (+{self._suppressed} suppressed last minute)"
self._window, self._count, self._suppressed = window, 0, 0
self._count += 1
if self._count > self.per_minute:
self._suppressed += 1
return False
return True
Step 5 — Never report delivery failures through the same path. A handler that logs its own errors through the logging system can send an error record — about the endpoint failing — to the failing endpoint, which fails, which logs another error. Delivery failures belong in a counter and on standard error, never in a logger that routes to the handler that failed.
Expected Output: during an endpoint outage, the service is unaffected and the failures are counted.
webhook delivery failed: ConnectTimeout
webhook_delivery_failures_total 14
notify_queue_dropped_total 0
p99 request latency 211 ms (unchanged)
Why this is rarely the right primary path
It is worth being explicit about why the standard advice is to ship logs through a collector rather than from the application, because the HTTP handler looks simpler and that simplicity is misleading.
The failure modes are inverted. A collector reading standard output keeps working when the network is partitioned, buffers on the node's disk, and retries without the application's involvement. An HTTP handler has none of that: when the endpoint is unreachable, records are lost as soon as the queue fills, and nothing outside the process can recover them.
Credentials live in the application. A webhook URL or an ingestion token must be configured in every service that uses the handler, rotated in every service when it changes, and protected in every service's secret store. A collector holds one copy.
The network path belongs to the request's process. Even behind a queue, the handler's connection pool, TLS handshakes and retries consume resources in the same process that serves requests. At low volume this is invisible; at high volume it competes.
Shutdown loses data. Records in the queue when the process exits are lost unless the listener is stopped and the handler flushed, as described in graceful shutdown and telemetry flush. A collector reading from a file loses nothing on application shutdown.
For a chat notification, none of these matters much: the volume is tiny, losing a notification during a network outage is acceptable, and there is no collector-side equivalent of a formatted alert message. For general log shipping, all of them matter, and the collector wins.
Designing the notification itself
A record that reaches a human through a chat channel is read differently from one in a log store, and a few choices about its content make it far more useful.
Lead with what happened and where. The first line a reader sees should name the service, the environment and the event in plain terms — "checkout (prod): payment provider rejecting all charges" — because notifications are read on phones and in previews where only that line is visible. The formatted log record, with its timestamp and logger name first, is the wrong shape for this audience.
Link to the investigation, not the evidence. Including a trace identifier or a pre-built query link lets the reader go from the notification to the relevant trace or log search in one click. Including the full traceback in the message is usually counterproductive: it is unreadable in a chat client, and it may contain data that should not be broadcast to everyone in a channel.
Say whether action is needed. A notification that is informational and one that requires somebody to act should look different, and the handler is the right place to make that distinction — a field on the record, set by the code that knows, mapped to a mention or a colour by the formatter. Channels where every message looks equally urgent train people to ignore all of them.
These are formatting decisions, so they belong in a dedicated formatter attached to the webhook handler rather than in the log call. The call site keeps logging a structured record; the webhook's formatter turns it into something a person can act on.
Configuration options
| Setting | Value | Why |
|---|---|---|
| Scope | a filtered subset | the network is for high-value records only |
| Position | behind QueueHandler |
request threads never wait on the network |
| Queue bound | ~1 000 records | an outage drops rather than grows memory |
| Batch size | 10–20 records | fewer requests, bounded payload |
| Batch wait | 5 s | latency of notification vs request count |
| Request timeout | 3 s | a slow endpoint costs one batch |
| Rate limit | 10 per minute, with summary | readable channel, no throttling |
| Failure reporting | counter and stderr | never back through the handler |
Verification
Stop the endpoint and confirm the service does not notice.
# point the handler at an address that drops connections, then load-test
WEBHOOK_URL=http://10.255.255.1:9 python loadtest.py --rps 200 --duration 60
Expected Output: unchanged latency, and failures counted rather than propagated.
p99 latency 208 ms
webhook failures 12
notify queue dropped 0
A latency increase during this test means the HTTP call is happening on a request thread — the queue is missing or the handler is attached directly as well as behind it.
Common mistakes
The standard HTTPHandler attached directly. Error signature: service latency rising when the log endpoint is slow. Root cause: one synchronous request per record on the calling thread, with no timeout. Remediation: a batching handler behind a queue listener.
Using it for general log shipping. Error signature: logs lost during every network blip. Root cause: no disk buffer and no retry outside the process. Remediation: ship through a collector; keep the HTTP handler for notifications.
No rate limit. Error signature: a flooded channel and a throttled sender during an incident. Root cause: every notify-worthy record posted. Remediation: limit and aggregate.
Logging delivery failures through logging. Error signature: a feedback loop of error records aimed at a failing endpoint. Root cause: the handler's error path routes back to itself. Remediation: a counter and standard error.
No flush at shutdown. Error signature: the last notifications before a crash never arrive. Root cause: records left in the queue and the batch buffer. Remediation: stop the listener and flush the handler on termination.
Frequently Asked Questions
Should a Python service ship its logs over HTTP directly?
Generally not as its primary path. Writing to standard output and letting a collector ship is more robust, because it survives network problems, buffers on disk and keeps the network out of the request path. A direct HTTP handler is reasonable for a small number of high-value records, such as notifications to a chat channel.
Is logging.handlers.HTTPHandler suitable for production?
Not on its own. It sends one synchronous request per record from the calling thread, with no batching and no timeout by default, so a slow endpoint stalls every thread that logs. Behind a queue listener, with a batching subclass and a timeout, it becomes usable.
What happens when the webhook endpoint is down?
With a bounded queue in front, records accumulate until the queue is full and are then dropped, and the application is unaffected. Without one, every logging call waits for the connection to time out, which can take down the service that was trying to report a problem.
How do I avoid flooding a chat channel during an incident?
Rate limit and aggregate. Send at most a few messages per minute, and when records are suppressed, send a summary of how many. A channel receiving hundreds of identical alerts during an outage is both useless and likely to throttle the sender.