90 lines
3 KiB
Python
90 lines
3 KiB
Python
"""
|
|
Non-blocking TCP log handler.
|
|
Sends JSON-lines to a remote server in a daemon background thread.
|
|
Never blocks the main application — drops records when queue is full.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import queue
|
|
import socket
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
class TCPLogHandler(logging.Handler):
|
|
def __init__(self, host: str, port: int, service: str, timeout: float = 3.0):
|
|
super().__init__()
|
|
self.host = host
|
|
self.port = port
|
|
self.service = service
|
|
self.timeout = timeout
|
|
self._queue: queue.Queue[str] = queue.Queue(maxsize=2000)
|
|
self._sock: socket.socket | None = None
|
|
self._lock = threading.Lock()
|
|
self._thread = threading.Thread(target=self._worker, daemon=True, name="tcp-log")
|
|
self._thread.start()
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
try:
|
|
entry = {
|
|
"ts": datetime.now(tz=timezone.utc).isoformat(),
|
|
"level": record.levelname,
|
|
"service": self.service,
|
|
"logger": record.name,
|
|
"msg": self.format(record),
|
|
}
|
|
self._queue.put_nowait(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
except queue.Full:
|
|
pass # drop — never block the caller
|
|
|
|
def _connect(self) -> bool:
|
|
try:
|
|
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
with self._lock:
|
|
self._sock = sock
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
def _close_sock(self) -> None:
|
|
with self._lock:
|
|
if self._sock:
|
|
try:
|
|
self._sock.close()
|
|
except OSError:
|
|
pass
|
|
self._sock = None
|
|
|
|
def _worker(self) -> None:
|
|
while True:
|
|
line = self._queue.get()
|
|
sent = False
|
|
while not sent:
|
|
with self._lock:
|
|
sock = self._sock
|
|
if sock is None:
|
|
if not self._connect():
|
|
time.sleep(5)
|
|
continue
|
|
with self._lock:
|
|
sock = self._sock
|
|
try:
|
|
sock.sendall(line.encode("utf-8")) # type: ignore[union-attr]
|
|
sent = True
|
|
except OSError:
|
|
self._close_sock()
|
|
time.sleep(2)
|
|
|
|
|
|
def setup_tcp_logging(service: str, host: str, port: int) -> TCPLogHandler | None:
|
|
"""Attach TCP handler to root logger. Returns handler or None if disabled."""
|
|
if not host or not port:
|
|
return None
|
|
handler = TCPLogHandler(host=host, port=port, service=service)
|
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
logging.getLogger().addHandler(handler)
|
|
logging.getLogger().info("TCP log handler started → %s:%d", host, port)
|
|
return handler
|