lightlogger 0.1.0__py3-none-any.whl
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.
- lightlogger/__init__.py +249 -0
- lightlogger/buffer.py +89 -0
- lightlogger/handler.py +40 -0
- lightlogger/py.typed +0 -0
- lightlogger/server.py +147 -0
- lightlogger/sse.py +30 -0
- lightlogger/static/help.html +247 -0
- lightlogger/static/index.html +947 -0
- lightlogger-0.1.0.dist-info/METADATA +122 -0
- lightlogger-0.1.0.dist-info/RECORD +12 -0
- lightlogger-0.1.0.dist-info/WHEEL +4 -0
- lightlogger-0.1.0.dist-info/licenses/LICENSE +21 -0
lightlogger/__init__.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""lightlogger: a live web dashboard for your Python logs. Zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from lightlogger.buffer import LogBuffer, LogRecord, _current_group_id, capture_caller
|
|
15
|
+
from lightlogger.handler import LightloggerHandler
|
|
16
|
+
from lightlogger.server import LightloggerServer, create_server, serve_in_background
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"start",
|
|
22
|
+
"stop",
|
|
23
|
+
"debug",
|
|
24
|
+
"info",
|
|
25
|
+
"warn",
|
|
26
|
+
"error",
|
|
27
|
+
"var",
|
|
28
|
+
"request",
|
|
29
|
+
"group",
|
|
30
|
+
"help",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_HELP_TEXT = """\
|
|
34
|
+
lightlogger -- live web dashboard for your Python logs
|
|
35
|
+
========================================================
|
|
36
|
+
|
|
37
|
+
pip install lightlogger
|
|
38
|
+
|
|
39
|
+
Quickstart
|
|
40
|
+
----------
|
|
41
|
+
import lightlogger
|
|
42
|
+
lightlogger.start()
|
|
43
|
+
|
|
44
|
+
That's it. Open the printed URL (default http://127.0.0.1:4356) and watch
|
|
45
|
+
your logs stream in live, in a dark, searchable dashboard.
|
|
46
|
+
|
|
47
|
+
Public API
|
|
48
|
+
----------
|
|
49
|
+
|
|
50
|
+
start(port=4356, host="127.0.0.1", max_logs=5000,
|
|
51
|
+
capture_logging=True, open_browser=False)
|
|
52
|
+
Starts the dashboard server on a background thread. Calling it again
|
|
53
|
+
while already running is a no-op, not an error.
|
|
54
|
+
lightlogger.start(port=8080, open_browser=True)
|
|
55
|
+
|
|
56
|
+
stop()
|
|
57
|
+
Stops the server and detaches the logging handler. Mostly useful for
|
|
58
|
+
tests and notebooks -- most scripts never need to call this.
|
|
59
|
+
lightlogger.stop()
|
|
60
|
+
|
|
61
|
+
debug(msg, data=None)
|
|
62
|
+
Logs a debug-level message (grey in the UI).
|
|
63
|
+
lightlogger.debug("cache miss", data={"key": "user:42"})
|
|
64
|
+
|
|
65
|
+
info(msg, data=None)
|
|
66
|
+
Logs an info-level message (blue in the UI).
|
|
67
|
+
lightlogger.info("user logged in")
|
|
68
|
+
|
|
69
|
+
warn(msg, data=None)
|
|
70
|
+
Logs a warn-level message (yellow in the UI).
|
|
71
|
+
lightlogger.warn("retrying after timeout")
|
|
72
|
+
|
|
73
|
+
error(msg, data=None)
|
|
74
|
+
Logs an error-level message (red in the UI).
|
|
75
|
+
lightlogger.error("payment failed", data={"order_id": 123})
|
|
76
|
+
|
|
77
|
+
var(name, value)
|
|
78
|
+
Logs any variable as an expandable JSON blob under `name`.
|
|
79
|
+
lightlogger.var("cart", cart_dict)
|
|
80
|
+
|
|
81
|
+
request(method, url, status, duration_ms)
|
|
82
|
+
Logs one HTTP request/response as a single formatted line.
|
|
83
|
+
lightlogger.request("GET", "/api/users", 200, 12.4)
|
|
84
|
+
|
|
85
|
+
group(name)
|
|
86
|
+
Context manager that groups related log lines into a collapsible
|
|
87
|
+
tree in the UI. Nests, and is safe across threads and asyncio tasks
|
|
88
|
+
(each gets its own independent group context).
|
|
89
|
+
with lightlogger.group("process_order #4821"):
|
|
90
|
+
lightlogger.info("validating cart")
|
|
91
|
+
lightlogger.info("charging payment", data={"amount": 49.99})
|
|
92
|
+
with lightlogger.group("send_notifications"):
|
|
93
|
+
lightlogger.info("email sent")
|
|
94
|
+
|
|
95
|
+
Capturing stdlib `logging`
|
|
96
|
+
---------------------------
|
|
97
|
+
start(capture_logging=True) is the default: it attaches a handler to the
|
|
98
|
+
root logger, so your existing `logging` calls -- and third-party
|
|
99
|
+
libraries' -- appear in the dashboard automatically, with zero code
|
|
100
|
+
changes.
|
|
101
|
+
|
|
102
|
+
Gotcha: Python's root logger defaults to level WARNING. A plain
|
|
103
|
+
`logging.info(...)` call will NOT appear unless your app has already
|
|
104
|
+
raised the level itself, e.g. `logging.basicConfig(level=logging.INFO)`.
|
|
105
|
+
lightlogger deliberately never forces the root logger's level open --
|
|
106
|
+
doing so would also unmute your app's OTHER existing handlers, which is
|
|
107
|
+
more disruptive than "zero code changes" is meant to be. `.warning()` and
|
|
108
|
+
above always show up out of the box; `.debug()`/`.info()` need that one
|
|
109
|
+
extra line if you want them too.
|
|
110
|
+
|
|
111
|
+
Not for production
|
|
112
|
+
-------------------
|
|
113
|
+
Binds to 127.0.0.1 only by default. This is a local development tool, not
|
|
114
|
+
a production observability system.
|
|
115
|
+
|
|
116
|
+
Docs & source
|
|
117
|
+
-------------
|
|
118
|
+
https://github.com/Rahuwale123/lightlogger
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_buffer = LogBuffer(maxlen=5000)
|
|
122
|
+
_httpd: LightloggerServer | None = None
|
|
123
|
+
_thread: threading.Thread | None = None
|
|
124
|
+
_logging_handler: LightloggerHandler | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _emit(level: str, message: str, data: Any = None, *, logger_name: str = "lightlogger") -> None:
|
|
128
|
+
# skip=3: capture_caller's own frame, this frame, and the public
|
|
129
|
+
# debug/info/warn/error/var/request wrapper -> lands on the user's call site.
|
|
130
|
+
file, line = capture_caller(skip=3)
|
|
131
|
+
record: LogRecord = {
|
|
132
|
+
"time": time.time(),
|
|
133
|
+
"level": level,
|
|
134
|
+
"message": message,
|
|
135
|
+
"data": data,
|
|
136
|
+
"file": file,
|
|
137
|
+
"line": line,
|
|
138
|
+
"logger_name": logger_name,
|
|
139
|
+
"group_id": None,
|
|
140
|
+
"parent_group_id": _current_group_id.get(),
|
|
141
|
+
}
|
|
142
|
+
_buffer.add(record)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def start(
|
|
146
|
+
port: int = 4356,
|
|
147
|
+
host: str = "127.0.0.1",
|
|
148
|
+
max_logs: int = 5000,
|
|
149
|
+
capture_logging: bool = True,
|
|
150
|
+
open_browser: bool = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
global _httpd, _thread, _logging_handler
|
|
153
|
+
if _httpd is not None:
|
|
154
|
+
return # already running; start() is idempotent, not an error
|
|
155
|
+
|
|
156
|
+
_buffer.set_maxlen(max_logs)
|
|
157
|
+
|
|
158
|
+
if host == "0.0.0.0": # noqa: S104
|
|
159
|
+
print(
|
|
160
|
+
"lightlogger WARNING: binding to 0.0.0.0 exposes your logs to your "
|
|
161
|
+
"whole network. Only do this if you mean it."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
_httpd = create_server(_buffer, host, port)
|
|
165
|
+
_thread = serve_in_background(_httpd)
|
|
166
|
+
bound_port = _httpd.server_address[1]
|
|
167
|
+
print(f"lightlogger UI → http://{host}:{bound_port}")
|
|
168
|
+
|
|
169
|
+
if open_browser:
|
|
170
|
+
webbrowser.open(f"http://{host}:{bound_port}")
|
|
171
|
+
|
|
172
|
+
if capture_logging:
|
|
173
|
+
_logging_handler = LightloggerHandler(_buffer)
|
|
174
|
+
logging.getLogger().addHandler(_logging_handler)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def stop() -> None:
|
|
178
|
+
global _httpd, _thread, _logging_handler
|
|
179
|
+
if _logging_handler is not None:
|
|
180
|
+
logging.getLogger().removeHandler(_logging_handler)
|
|
181
|
+
_logging_handler = None
|
|
182
|
+
if _httpd is None:
|
|
183
|
+
return
|
|
184
|
+
_httpd.shutdown()
|
|
185
|
+
_httpd.server_close()
|
|
186
|
+
_httpd = None
|
|
187
|
+
_thread = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def debug(msg: str, data: Any | None = None) -> None:
|
|
191
|
+
_emit("debug", msg, data)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def info(msg: str, data: Any | None = None) -> None:
|
|
195
|
+
_emit("info", msg, data)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def warn(msg: str, data: Any | None = None) -> None:
|
|
199
|
+
_emit("warn", msg, data)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def error(msg: str, data: Any | None = None) -> None:
|
|
203
|
+
_emit("error", msg, data)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def var(name: str, value: Any) -> None:
|
|
207
|
+
_emit("info", name, value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def request(method: str, url: str, status: int, duration_ms: float) -> None:
|
|
211
|
+
_emit(
|
|
212
|
+
"info",
|
|
213
|
+
f"{method} {url} {status} {duration_ms:.1f}ms",
|
|
214
|
+
{"method": method, "url": url, "status": status, "duration_ms": duration_ms},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def help() -> None:
|
|
219
|
+
"""Print a quick-reference cheatsheet of the public API to stdout."""
|
|
220
|
+
print(_HELP_TEXT)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@contextmanager
|
|
224
|
+
def group(name: str) -> Iterator[None]:
|
|
225
|
+
group_id = uuid.uuid4().hex
|
|
226
|
+
parent_group_id = _current_group_id.get()
|
|
227
|
+
# skip=3: capture_caller's own frame, this generator's frame (resumed by
|
|
228
|
+
# @contextmanager's __enter__ via next()), and contextlib's
|
|
229
|
+
# _GeneratorContextManager.__enter__ itself -> lands on the user's
|
|
230
|
+
# `with lightlogger.group(...):` call site. Verified empirically in
|
|
231
|
+
# tests/test_buffer.py (test_group_captures_caller_file_and_line).
|
|
232
|
+
file, line = capture_caller(skip=3)
|
|
233
|
+
record: LogRecord = {
|
|
234
|
+
"time": time.time(),
|
|
235
|
+
"level": "group",
|
|
236
|
+
"message": name,
|
|
237
|
+
"data": None,
|
|
238
|
+
"file": file,
|
|
239
|
+
"line": line,
|
|
240
|
+
"logger_name": "lightlogger",
|
|
241
|
+
"group_id": group_id,
|
|
242
|
+
"parent_group_id": parent_group_id,
|
|
243
|
+
}
|
|
244
|
+
_buffer.add(record)
|
|
245
|
+
token = _current_group_id.set(group_id)
|
|
246
|
+
try:
|
|
247
|
+
yield
|
|
248
|
+
finally:
|
|
249
|
+
_current_group_id.reset(token)
|
lightlogger/buffer.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Ring buffer and SSE client fan-out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections import deque
|
|
9
|
+
from queue import Queue
|
|
10
|
+
from typing import Any, TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LogRecord(TypedDict):
|
|
14
|
+
time: float
|
|
15
|
+
level: str
|
|
16
|
+
message: str
|
|
17
|
+
data: Any
|
|
18
|
+
file: str
|
|
19
|
+
line: int
|
|
20
|
+
logger_name: str
|
|
21
|
+
group_id: str | None
|
|
22
|
+
parent_group_id: str | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def capture_caller(skip: int) -> tuple[str, int]:
|
|
26
|
+
# sys._getframe, not inspect.stack(): inspect.stack() reads source files
|
|
27
|
+
# off disk on every call, which is too slow for a logging hot path.
|
|
28
|
+
frame = sys._getframe(skip)
|
|
29
|
+
return frame.f_code.co_filename, frame.f_lineno
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Holds "the current group id" per thread/async-task, not a plain module
|
|
33
|
+
# global (which would leak across threads). Lives here rather than in
|
|
34
|
+
# __init__.py because handler.py needs it too (so plain logging.info() calls
|
|
35
|
+
# made inside a group() block nest correctly), and handler.py already
|
|
36
|
+
# imports from buffer.py -- importing from __init__.py instead would risk a
|
|
37
|
+
# circular import.
|
|
38
|
+
_current_group_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
39
|
+
"lightlogger_current_group_id", default=None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LogBuffer:
|
|
44
|
+
"""Bounded ring buffer of log records, with SSE client fan-out."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, maxlen: int = 5000) -> None:
|
|
47
|
+
self._records: deque[LogRecord] = deque(maxlen=maxlen)
|
|
48
|
+
# One lock for records and subscribers alike: append() is atomic under
|
|
49
|
+
# the GIL on its own, but the subscriber set isn't, so a single lock
|
|
50
|
+
# keeps this simple rather than mixing locked and lock-free paths.
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
self._subscribers: set[Queue[LogRecord]] = set()
|
|
53
|
+
|
|
54
|
+
def set_maxlen(self, maxlen: int) -> None:
|
|
55
|
+
# Rebuild in place rather than swap self._records for a fresh deque:
|
|
56
|
+
# deque(existing_deque, maxlen=N) already keeps only the newest N
|
|
57
|
+
# items, and callers who stashed a reference to this LogBuffer (e.g.
|
|
58
|
+
# future SSE subscribers) never observe a different object.
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._records = deque(self._records, maxlen=maxlen)
|
|
61
|
+
|
|
62
|
+
def add(self, record: LogRecord) -> None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._records.append(record)
|
|
65
|
+
subscribers = list(self._subscribers)
|
|
66
|
+
for subscriber in subscribers:
|
|
67
|
+
subscriber.put(record)
|
|
68
|
+
|
|
69
|
+
def snapshot(self) -> list[LogRecord]:
|
|
70
|
+
with self._lock:
|
|
71
|
+
return list(self._records)
|
|
72
|
+
|
|
73
|
+
def clear(self) -> None:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._records.clear()
|
|
76
|
+
|
|
77
|
+
def __len__(self) -> int:
|
|
78
|
+
with self._lock:
|
|
79
|
+
return len(self._records)
|
|
80
|
+
|
|
81
|
+
def subscribe(self) -> Queue[LogRecord]:
|
|
82
|
+
subscriber: Queue[LogRecord] = Queue()
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._subscribers.add(subscriber)
|
|
85
|
+
return subscriber
|
|
86
|
+
|
|
87
|
+
def unsubscribe(self, subscriber: Queue[LogRecord]) -> None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._subscribers.discard(subscriber)
|
lightlogger/handler.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LightloggerHandler: logging.Handler subclass for capture_logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from lightlogger.buffer import LogBuffer, LogRecord, _current_group_id
|
|
8
|
+
|
|
9
|
+
_LEVEL_NAMES = {
|
|
10
|
+
logging.DEBUG: "debug",
|
|
11
|
+
logging.INFO: "info",
|
|
12
|
+
logging.WARNING: "warn",
|
|
13
|
+
logging.ERROR: "error",
|
|
14
|
+
logging.CRITICAL: "error", # only 4 UI colors exist; CRITICAL folds into "error"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LightloggerHandler(logging.Handler):
|
|
19
|
+
"""Mirrors stdlib `logging` records into a `LogBuffer` (and its SSE fan-out)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, buffer: LogBuffer) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._buffer = buffer
|
|
24
|
+
|
|
25
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
26
|
+
try:
|
|
27
|
+
log_record: LogRecord = {
|
|
28
|
+
"time": record.created,
|
|
29
|
+
"level": _LEVEL_NAMES.get(record.levelno, "info"),
|
|
30
|
+
"message": record.getMessage(),
|
|
31
|
+
"data": None,
|
|
32
|
+
"file": record.pathname,
|
|
33
|
+
"line": record.lineno,
|
|
34
|
+
"logger_name": record.name,
|
|
35
|
+
"group_id": None,
|
|
36
|
+
"parent_group_id": _current_group_id.get(),
|
|
37
|
+
}
|
|
38
|
+
self._buffer.add(log_record)
|
|
39
|
+
except Exception:
|
|
40
|
+
self.handleError(record)
|
lightlogger/py.typed
ADDED
|
File without changes
|
lightlogger/server.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""ThreadingHTTPServer, daemon thread, and HTTP routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from http import HTTPStatus
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from queue import Empty
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from lightlogger import sse
|
|
15
|
+
from lightlogger.buffer import LogBuffer
|
|
16
|
+
|
|
17
|
+
# How long an /api/stream client blocks on its queue before it gets a
|
|
18
|
+
# heartbeat comment instead. Also doubles as the heartbeat period.
|
|
19
|
+
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LightloggerServer(ThreadingHTTPServer):
|
|
23
|
+
# SSE holds connections open indefinitely; without daemon threads per
|
|
24
|
+
# request, one slow client would starve every other route.
|
|
25
|
+
daemon_threads = True
|
|
26
|
+
allow_reuse_address = True
|
|
27
|
+
|
|
28
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
29
|
+
# An /api/stream client that goes away (tab closed, reconnect churn)
|
|
30
|
+
# can reset the connection at any point, including while a *later*,
|
|
31
|
+
# unrelated request is being read on a freshly accepted socket. The
|
|
32
|
+
# default handle_error() dumps a full traceback to stderr for that,
|
|
33
|
+
# which looks like a server bug when it's just a disconnected client
|
|
34
|
+
# -- same spirit as log_message() already staying quiet above.
|
|
35
|
+
exc_type = sys.exc_info()[0]
|
|
36
|
+
if exc_type is not None and issubclass(exc_type, (BrokenPipeError, ConnectionResetError)):
|
|
37
|
+
return
|
|
38
|
+
super().handle_error(request, client_address)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make_handler(buffer: LogBuffer, heartbeat_interval: float) -> type[BaseHTTPRequestHandler]:
|
|
42
|
+
class Handler(BaseHTTPRequestHandler):
|
|
43
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
44
|
+
pass # stay quiet on the user's stdout; they didn't ask for access logs
|
|
45
|
+
|
|
46
|
+
def do_GET(self) -> None:
|
|
47
|
+
if self.path == "/":
|
|
48
|
+
self._serve_index()
|
|
49
|
+
elif self.path == "/help":
|
|
50
|
+
self._serve_help()
|
|
51
|
+
elif self.path == "/api/logs":
|
|
52
|
+
self._serve_logs()
|
|
53
|
+
elif self.path == "/api/stream":
|
|
54
|
+
self._serve_stream()
|
|
55
|
+
else:
|
|
56
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
57
|
+
|
|
58
|
+
def do_POST(self) -> None:
|
|
59
|
+
if self.path == "/api/clear":
|
|
60
|
+
self._serve_clear()
|
|
61
|
+
else:
|
|
62
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
63
|
+
|
|
64
|
+
def _serve_clear(self) -> None:
|
|
65
|
+
buffer.clear()
|
|
66
|
+
self.send_response(HTTPStatus.OK)
|
|
67
|
+
self.send_header("Content-Length", "0")
|
|
68
|
+
self.end_headers()
|
|
69
|
+
|
|
70
|
+
def _serve_index(self) -> None:
|
|
71
|
+
html = (importlib.resources.files("lightlogger") / "static" / "index.html").read_bytes()
|
|
72
|
+
self.send_response(HTTPStatus.OK)
|
|
73
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
74
|
+
self.send_header("Content-Length", str(len(html)))
|
|
75
|
+
self.end_headers()
|
|
76
|
+
self.wfile.write(html)
|
|
77
|
+
|
|
78
|
+
def _serve_help(self) -> None:
|
|
79
|
+
html = (importlib.resources.files("lightlogger") / "static" / "help.html").read_bytes()
|
|
80
|
+
self.send_response(HTTPStatus.OK)
|
|
81
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
82
|
+
self.send_header("Content-Length", str(len(html)))
|
|
83
|
+
self.end_headers()
|
|
84
|
+
self.wfile.write(html)
|
|
85
|
+
|
|
86
|
+
def _serve_logs(self) -> None:
|
|
87
|
+
# default=str: log `data` can be any Python object the caller passed in.
|
|
88
|
+
body = json.dumps(buffer.snapshot(), default=str).encode("utf-8")
|
|
89
|
+
self.send_response(HTTPStatus.OK)
|
|
90
|
+
self.send_header("Content-Type", "application/json")
|
|
91
|
+
self.send_header("Content-Length", str(len(body)))
|
|
92
|
+
self.end_headers()
|
|
93
|
+
self.wfile.write(body)
|
|
94
|
+
|
|
95
|
+
def _serve_stream(self) -> None:
|
|
96
|
+
self.send_response(HTTPStatus.OK)
|
|
97
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
98
|
+
self.send_header("Cache-Control", "no-cache")
|
|
99
|
+
self.send_header("Connection", "keep-alive")
|
|
100
|
+
self.end_headers()
|
|
101
|
+
|
|
102
|
+
# BaseHTTPRequestHandler gives no clean "client closed the tab"
|
|
103
|
+
# signal for a response this long-lived, so disconnect is detected
|
|
104
|
+
# the standard way: attempt the write, and treat any failure
|
|
105
|
+
# (BrokenPipeError, ConnectionResetError, or a generic OSError) as
|
|
106
|
+
# the client having gone away. finally: unsubscribe() always runs,
|
|
107
|
+
# so a leaked subscriber queue can't accumulate per disconnect.
|
|
108
|
+
subscriber = buffer.subscribe()
|
|
109
|
+
try:
|
|
110
|
+
self.wfile.write(sse.format_retry())
|
|
111
|
+
self.wfile.flush()
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
record = subscriber.get(timeout=heartbeat_interval)
|
|
115
|
+
except Empty:
|
|
116
|
+
payload = sse.format_heartbeat()
|
|
117
|
+
else:
|
|
118
|
+
payload = sse.format_event(record)
|
|
119
|
+
self.wfile.write(payload)
|
|
120
|
+
self.wfile.flush()
|
|
121
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
122
|
+
pass
|
|
123
|
+
finally:
|
|
124
|
+
buffer.unsubscribe(subscriber)
|
|
125
|
+
|
|
126
|
+
return Handler
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_server(
|
|
130
|
+
buffer: LogBuffer,
|
|
131
|
+
host: str,
|
|
132
|
+
port: int,
|
|
133
|
+
heartbeat_interval: float = HEARTBEAT_INTERVAL_SECONDS,
|
|
134
|
+
) -> LightloggerServer:
|
|
135
|
+
"""Bind a server, auto-incrementing past `port` on OSError (port busy)."""
|
|
136
|
+
handler_cls = _make_handler(buffer, heartbeat_interval)
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
return LightloggerServer((host, port), handler_cls)
|
|
140
|
+
except OSError:
|
|
141
|
+
port += 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def serve_in_background(httpd: LightloggerServer) -> threading.Thread:
|
|
145
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
146
|
+
thread.start()
|
|
147
|
+
return thread
|
lightlogger/sse.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SSE framing helpers: exact wire format for text/event-stream responses.
|
|
2
|
+
|
|
3
|
+
Kept separate from server.py so the wire format (double-newline framing,
|
|
4
|
+
retry directive, heartbeat comment) is defined in exactly one place and can
|
|
5
|
+
be unit-tested without spinning up a real HTTP server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from lightlogger.buffer import LogRecord
|
|
13
|
+
|
|
14
|
+
RETRY_MS = 3000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_retry() -> bytes:
|
|
18
|
+
return f"retry: {RETRY_MS}\n\n".encode()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_event(record: LogRecord) -> bytes:
|
|
22
|
+
# default=str: log `data` can be any Python object the caller passed in.
|
|
23
|
+
payload = json.dumps(record, default=str)
|
|
24
|
+
return f"data: {payload}\n\n".encode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_heartbeat() -> bytes:
|
|
28
|
+
# A comment line (leading colon) per the SSE spec: browsers/proxies ignore
|
|
29
|
+
# its content but the bytes on the wire keep the connection looking alive.
|
|
30
|
+
return b": ping\n\n"
|