mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(web): use src.logging_config in the web process; routine requests to DEBUG
The web interface had its own logging setup (web_interface/logging_config.py)
that replaced the root handlers with a plain stdout formatter. The web
service's journal lines therefore never carried a syslog priority, so
`journalctl -p err -u ledmatrix-web` returned nothing while errors were
logged, and the line shape differed from the display's (the log viewer's
prefix stripping only matched the display format). It also ran after the
module-level managers were built, so their INFO lines at import (including
"Re-removed N uninstalled plugin(s)") were dropped.
app.py now calls src.logging_config.setup_logging() first thing, the same as
run.py: journald priorities under systemd, LEDMATRIX_DEBUG honoured,
LEDMATRIX_JSON_LOGGING still selects JSON.
Per-request logging moves to web_interface/request_logging.py. Every request
used to be logged at INFO, so the UI's polling filled the journal
("GET /api/v3/errors/summary - 200" every minute per tab). Now a successful
GET/HEAD/OPTIONS is DEBUG, a successful write is INFO, 4xx WARNING, 5xx
ERROR. Durations use perf_counter and print to 0.1ms.
The duplicate module is deleted; nothing else imported it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): one thread-safe TTL cache for the web process
web_interface/cache.py becomes a small TTLCache class (lock-guarded,
monotonic clock) with the existing get_cached/set_cached/delete_cached/
invalidate_cache helpers kept on top of a shared instance, so the api_v3
callers are unchanged.
Bugs fixed:
- set_cached(ttl_seconds=...) ignored its TTL; only the reader's value
counted and get_cached defaulted to 60s. An entry now expires after the TTL
it was stored with; a reader's ttl_seconds can only shorten that. Both
current callers pass the same value on both sides (fonts_catalog 300s,
system_status 10s), so their observable TTLs are unchanged.
- get_cached deleted expired keys without a lock; two threads reading the
same expired key could raise KeyError (reproduced), which the endpoints
turned into a 500.
app.py's two hand-rolled systemctl caches (_ap_mode_cache, 30s, and
_ledmatrix_service_cache, 15s) now share one helper over a private
TTLCache, with the same TTLs. The AP-mode check used to retry on every
request after a failure (and log an ERROR each time); a failure now keeps the
last known answer for the TTL, as the display-service check already did. With
no systemctl at all (a dev machine) it answers False without forking.
Left alone as not TTL memoisation: the gzip cache (size-bounded, keyed by URL
and version), the settings search index (keyed by installed-plugin set), the
widget bundle (keyed by file fingerprint) and CacheManager (cross-process).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): web logging and TTL cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only ask systemctl about known units
Codacy flagged the systemctl argv built from a variable. The unit now has
to be one of two literals, and anything else raises.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): response_time_ms reads the same clock request_logging stamps
request_logging now stamps request.start_time from perf_counter, but
success_response still subtracted it from time.time(), so metadata
reported ~1.8e12 ms. Found testing on ledpi.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
"""The web process logs the way the display process does.
|
|
|
|
web_interface/app.py used to call its own setup (web_interface/logging_config.py)
|
|
which replaced the root handlers with a plain stdout formatter. Under systemd
|
|
every line then reached the journal as PRIORITY=6, so
|
|
|
|
journalctl -p err -u ledmatrix-web
|
|
|
|
showed nothing while the web interface was logging errors. It also logged
|
|
every request at INFO, including what the UI polls: the journal on a Pi showed
|
|
``GET /api/v3/errors/summary - 200`` every minute per open tab.
|
|
"""
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from web_interface import request_logging
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The real app, imported the way systemd runs it
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CHILD = textwrap.dedent("""
|
|
import logging
|
|
import web_interface.app as web_app
|
|
|
|
# Startup reconciliation may try to reinstall plugins; not this test's job.
|
|
web_app._reconciliation_started = True
|
|
client = web_app.app.test_client()
|
|
client.get('/api/v3/errors/summary')
|
|
client.get('/favicon.ico')
|
|
client.get('/api/v3/no-such-endpoint')
|
|
logging.getLogger('web_interface.probe').error('probe error line')
|
|
logging.getLogger('web_interface.probe').info('probe info line')
|
|
""")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def journal_output(tmp_path_factory):
|
|
"""Run the child with stdout as a file systemd would call the journal.
|
|
|
|
systemd sets JOURNAL_STREAM to the dev:ino of the stream it captures;
|
|
src.logging_config only adds priorities when stdout really is that stream,
|
|
so hand the child a file and name that file's dev:ino.
|
|
"""
|
|
out_path = tmp_path_factory.mktemp("journal") / "stdout.txt"
|
|
with open(out_path, "wb") as out:
|
|
st = os.fstat(out.fileno())
|
|
env = dict(os.environ)
|
|
env.update({
|
|
"JOURNAL_STREAM": f"{st.st_dev}:{st.st_ino}",
|
|
"PYTHONUTF8": "1",
|
|
"EMULATOR": "true",
|
|
"PYTHONPATH": str(PROJECT_ROOT),
|
|
})
|
|
env.pop("LEDMATRIX_DEBUG", None)
|
|
env.pop("LEDMATRIX_JSON_LOGGING", None)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-c", _CHILD], cwd=str(PROJECT_ROOT), env=env,
|
|
stdout=out, stderr=subprocess.PIPE, timeout=180,
|
|
)
|
|
text = out_path.read_text(encoding="utf-8", errors="replace")
|
|
assert proc.returncode == 0, proc.stderr.decode(errors="replace")[-4000:]
|
|
return text.splitlines()
|
|
|
|
|
|
def test_error_reaches_the_journal_as_err(journal_output):
|
|
lines = [l for l in journal_output if "probe error line" in l]
|
|
assert lines, "\n".join(journal_output[-40:])
|
|
assert lines[0].startswith("<3>"), lines[0]
|
|
# Same readable shape as the display service (and what the log viewer strips).
|
|
assert " - ERROR - web_interface.probe - probe error line" in lines[0]
|
|
|
|
|
|
def test_info_reaches_the_journal_as_info(journal_output):
|
|
lines = [l for l in journal_output if "probe info line" in l]
|
|
assert lines and lines[0].startswith("<6>"), journal_output[-40:]
|
|
|
|
|
|
def test_polling_gets_are_not_logged_at_info(journal_output):
|
|
for path in ("/api/v3/errors/summary", "/favicon.ico"):
|
|
assert not [l for l in journal_output if f"GET {path} " in l], (
|
|
f"a successful GET {path} was logged by default")
|
|
|
|
|
|
def test_failed_request_is_still_logged(journal_output):
|
|
lines = [l for l in journal_output if "GET /api/v3/no-such-endpoint - 404" in l]
|
|
assert lines and lines[0].startswith("<4>"), journal_output[-40:]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The level policy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("method,status,level", [
|
|
("GET", 200, logging.DEBUG),
|
|
("GET", 304, logging.DEBUG),
|
|
("HEAD", 200, logging.DEBUG),
|
|
("OPTIONS", 204, logging.DEBUG),
|
|
("get", 200, logging.DEBUG),
|
|
("POST", 200, logging.INFO),
|
|
("PUT", 204, logging.INFO),
|
|
("DELETE", 200, logging.INFO),
|
|
("PATCH", 302, logging.INFO),
|
|
("GET", 404, logging.WARNING),
|
|
("POST", 400, logging.WARNING),
|
|
("GET", 500, logging.ERROR),
|
|
("POST", 503, logging.ERROR),
|
|
])
|
|
def test_request_log_level(method, status, level):
|
|
assert request_logging.request_log_level(method, status) == level
|
|
|
|
|
|
@pytest.fixture
|
|
def tiny_app():
|
|
app = Flask(__name__)
|
|
request_logging.init_app(app)
|
|
|
|
@app.route("/poll")
|
|
def poll():
|
|
return "ok"
|
|
|
|
@app.route("/save", methods=["POST"])
|
|
def save():
|
|
return "saved"
|
|
|
|
@app.route("/boom")
|
|
def boom():
|
|
return "no", 500
|
|
|
|
return app.test_client()
|
|
|
|
|
|
def test_hooks_log_each_request_once_at_its_level(tiny_app, caplog):
|
|
caplog.set_level(logging.DEBUG, logger="web_interface.api")
|
|
tiny_app.get("/poll")
|
|
tiny_app.post("/save")
|
|
tiny_app.get("/boom")
|
|
tiny_app.get("/missing")
|
|
got = [(r.levelno, r.getMessage().split(" (")[0]) for r in caplog.records
|
|
if r.name == "web_interface.api"]
|
|
assert got == [
|
|
(logging.DEBUG, "GET /poll - 200"),
|
|
(logging.INFO, "POST /save - 200"),
|
|
(logging.ERROR, "GET /boom - 500"),
|
|
(logging.WARNING, "GET /missing - 404"),
|
|
]
|
|
|
|
|
|
def test_duration_is_rounded(tiny_app, caplog):
|
|
caplog.set_level(logging.DEBUG, logger="web_interface.api")
|
|
tiny_app.post("/save")
|
|
msg = caplog.records[-1].getMessage()
|
|
duration = msg.rsplit("(", 1)[1]
|
|
assert duration.endswith("ms)") and len(duration.split(".")[1]) == len("0ms)"), msg
|
|
|
|
|
|
def test_success_response_timing_uses_the_same_clock():
|
|
# request_logging stamps request.start_time from perf_counter; a reader
|
|
# subtracting it from time.time() reported ~1.8e12 ms (found on a Pi).
|
|
from src.web_interface.api_helpers import success_response
|
|
app = Flask(__name__)
|
|
request_logging.init_app(app)
|
|
|
|
@app.route('/timed')
|
|
def timed():
|
|
return success_response(data={}, metadata={})
|
|
|
|
body = app.test_client().get('/timed').get_json()
|
|
assert 0 <= body['metadata']['response_time_ms'] < 10_000
|