Added TraceRite CLI error logging. Simplified logging setup.

This commit is contained in:
2026-08-31 05:34:33 +00:00
parent 77a34753d9
commit 4232304a00
3 changed files with 120 additions and 125 deletions
+93 -101
View File
@@ -1,10 +1,9 @@
"""Access log formatting, adapted from uvicorn's logging module. """Logging integration: tracerite loading and colored access log formatting.
Unlike uvicorn's AccessFormatter, the colored fields (``client``, ``status``, The access log middleware supplies colored fields (``client``, ``status``,
``method``, ``host``, ``path``, ``extra``, ``timing``) are supplied by the ``method``, ``host``, ``path``, ``extra``, ``timing``) via ``extra=``. When
access log middleware via ``extra=``. When colors are disabled the ANSI colors are disabled the ANSI escape codes are stripped from the assembled
escape codes are stripped from the assembled output so the same formatting output so the same formatting code path produces plain text.
code path produces plain text.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,46 +11,59 @@ from __future__ import annotations
import logging import logging
import re import re
import sys import sys
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
from typing import Any, Literal from typing import Literal
import tracerite
from uvicorn.config import Config
from .accesslog import AccessLogMiddleware
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s" ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s"
ACCESS_LOGGER = "fastapi_vue.access"
def strip_ansi(text: str) -> str: def strip_ansi(text: str) -> str:
return ANSI_ESCAPE_RE.sub("", text) return ANSI_ESCAPE_RE.sub("", text)
LogConfigPatch = Callable[[dict[str, Any]], None] class Formatter(logging.Formatter):
"""Formatter for the combined HTTP/WebSocket access log.
_LOG_CONFIG_PATCHES: list[LogConfigPatch] = [] Instantiation always loads tracerite, and with ``access=True`` also
installs the access-log middleware: ``dictConfig`` builds formatters while
uvicorn applies ``log_config``, which happens before the app is loaded —
def log_config_patch(patch: LogConfigPatch) -> LogConfigPatch: including in reload/worker subprocesses that re-import the config without
"""Register a best-effort log config patch (applied by patch_log_config).""" calling ``fastapi_vue.server.run()`` again.
_LOG_CONFIG_PATCHES.append(patch)
return patch
def patch_log_config(log_config: Any) -> Any: # noqa: ANN401
"""Apply registered patches to a uvicorn log_config.
Users presumably base their config on uvicorn's default dict, but any
shape is tolerated: each patch is applied on a best-effort basis and
silently skipped when the config does not have the expected structure.
Non-dict configs (e.g. an ini file path) pass through untouched.
""" """
if not isinstance(log_config, dict):
return log_config def __init__(
config = deepcopy(log_config) self,
for patch in _LOG_CONFIG_PATCHES: fmt: str | None = None,
with suppress(Exception): datefmt: str | None = None,
patch(config) style: Literal["%", "{", "$"] = "%",
return config use_colors: bool | None = None,
*,
access: bool = False,
) -> None:
tracerite.load()
if access:
install_access_log()
if use_colors in (True, False):
self.use_colors = use_colors
else:
self.use_colors = sys.stdout.isatty()
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
def formatMessage(self, record: logging.LogRecord) -> str:
formatted = super().formatMessage(record)
if not self.use_colors:
formatted = strip_ansi(formatted)
return formatted
class WebSocketChatterFilter(logging.Filter): class WebSocketChatterFilter(logging.Filter):
@@ -71,55 +83,20 @@ class WebSocketChatterFilter(logging.Filter):
return not msg.startswith(self._PREFIXES) return not msg.startswith(self._PREFIXES)
class AccessFormatter(logging.Formatter): _installed = False
"""Formatter for the combined HTTP/WebSocket access log.
Instantiation installs the middleware patch: ``dictConfig`` builds this
formatter while uvicorn applies ``log_config``, which happens before the
app is loaded — including in reload/worker subprocesses that re-import the
config without calling ``fastapi_vue.server.run()`` again.
"""
def __init__(
self,
fmt: str | None = None,
datefmt: str | None = None,
style: Literal["%", "{", "$"] = "%",
use_colors: bool | None = None,
):
install_access_log()
if use_colors in (True, False):
self.use_colors = use_colors
else:
self.use_colors = sys.stdout.isatty()
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
def formatMessage(self, record: logging.LogRecord) -> str:
formatted = super().formatMessage(record)
if not self.use_colors:
formatted = strip_ansi(formatted)
return formatted
def install_access_log() -> None: def install_access_log() -> None:
"""Wrap apps loaded by uvicorn with AccessLogMiddleware (idempotent).""" """Wrap apps loaded by uvicorn with AccessLogMiddleware, once per process.
from uvicorn.config import Config
from .accesslog import AccessLogMiddleware The guard is deliberately module-level: reload/worker subprocesses
re-import this module, resetting it so the patch is re-applied there.
if getattr(Config, "_fastapi_vue_accesslog", False): """
global _installed
if _installed:
return return
Config._fastapi_vue_accesslog = True # type: ignore[attr-defined] _installed = True
if hasattr(Config, "load_app"): # uvicorn < 0.40
original_load_app = Config.load_app
def load_app(self): # noqa: ANN001, ANN202
app = original_load_app(self)
return app if isinstance(app, AccessLogMiddleware) else AccessLogMiddleware(app)
Config.load_app = load_app # type: ignore[method-assign]
else:
original_load = Config.load original_load = Config.load
def load(self): # noqa: ANN001, ANN202 def load(self): # noqa: ANN001, ANN202
@@ -130,39 +107,54 @@ def install_access_log() -> None:
Config.load = load # type: ignore[method-assign] Config.load = load # type: ignore[method-assign]
@log_config_patch def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, ANN201
def _patch_access_log(config: dict[str, Any]) -> None: """Patch a uvicorn log_config dict for our logging, best-effort.
"""Rewire a uvicorn log_config dict for our colored access logging.
Logs to a private ``fastapi_vue.access`` logger: uvicorn's protocol-level Users presumably base their config on uvicorn's default dict, but any
access logging is driven by ``uvicorn.access.hasHandlers()``, so we must shape is tolerated: pieces that do not fit the config's structure are
not attach handlers to that logger (``access_log=False`` strips them). silently skipped. Non-dict configs (e.g. an ini file path) pass through
untouched.
Always adds an unreferenced NullHandler whose Formatter instantiation
loads tracerite in every process uvicorn applies the config in, and a
filter dropping stock uvicorn's WebSocket chatter from ``uvicorn.error``.
With ``access_log``, additionally rewires the ``access`` formatter to our
Formatter and attaches its handler to our ``fastapi_vue.access`` logger.
We must not attach handlers to ``uvicorn.access``: uvicorn gates its own
protocol-level access logging on ``uvicorn.access.hasHandlers()``.
""" """
formatters = config.get("formatters") if not isinstance(log_config, dict):
if isinstance(formatters, dict): return log_config
formatters["access"] = { config = deepcopy(log_config)
"()": "fastapi_vue.logging.AccessFormatter",
"fmt": ACCESS_LOG_FMT, with suppress(Exception):
"use_colors": None, config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
config["handlers"]["fastapi_vue"] = {
"class": "logging.NullHandler",
"formatter": "fastapi_vue",
} }
handlers = config.get("handlers") with suppress(Exception):
if not isinstance(handlers, dict): filters = config.setdefault("filters", {})
return filters["ws_chatter"] = {"()": "fastapi_vue.logging.WebSocketChatterFilter"}
handler_filters = config["handlers"]["default"].setdefault("filters", [])
if "ws_chatter" not in handler_filters:
handler_filters.append("ws_chatter")
loggers = config.setdefault("loggers", {}) if access_log:
if isinstance(loggers, dict) and "access" in handlers: with suppress(Exception):
loggers["fastapi_vue.access"] = { config["formatters"]["access"] = {
"()": "fastapi_vue.logging.Formatter",
"fmt": ACCESS_LOG_FMT,
"use_colors": None,
"access": True,
}
with suppress(Exception):
if "access" in config["handlers"]:
config.setdefault("loggers", {})[ACCESS_LOGGER] = {
"handlers": ["access"], "handlers": ["access"],
"level": "INFO", "level": "INFO",
"propagate": False, "propagate": False,
} }
default = handlers.get("default") return config
if isinstance(default, dict):
filters = config.setdefault("filters", {})
if isinstance(filters, dict):
filters["ws_chatter"] = {"()": "fastapi_vue.logging.WebSocketChatterFilter"}
handler_filters = default.setdefault("filters", [])
if isinstance(handler_filters, list) and "ws_chatter" not in handler_filters:
handler_filters.append("ws_chatter")
+10 -8
View File
@@ -7,12 +7,14 @@ from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import tracerite
import uvicorn import uvicorn
from uvicorn import Config, Server from uvicorn import Config, Server
from .hostutil import parse_endpoints from .hostutil import parse_endpoints
from .logging import install_access_log, patch_log_config from .logging import install_access_log, patch_log_config
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,11 +39,12 @@ def run( # noqa: PLR0913
directory. True enables reload without setting a reload directory. directory. True enables reload without setting a reload directory.
False disables reload and clears any reload_dirs. False disables reload and clears any reload_dirs.
workers: Number of worker processes (requires uvicorn.run, single endpoint only). workers: Number of worker processes (requires uvicorn.run, single endpoint only).
access_log: Enable our colored HTTP/WebSocket access logging (uvicorn's access_log: Enable our colored HTTP/WebSocket access logging middleware
own access log is disabled either way). (uvicorn's own access logging is always bypassed).
log_config: Logging config passed to uvicorn. When access_log is log_config: Logging config passed to uvicorn. Dict configs are patched
enabled, dict configs are patched best-effort for our access log best-effort (see fastapi_vue.logging.patch_log_config): tracerite
formatting (see fastapi_vue.logging.patch_log_config). loading and WebSocket chatter filtering are always installed, and
when access_log is enabled the access formatting is rewired too.
**uvicorn_config: Additional uvicorn config options (overrides all other settings). **uvicorn_config: Additional uvicorn config options (overrides all other settings).
""" """
@@ -57,9 +60,8 @@ def run( # noqa: PLR0913
if access_log: if access_log:
install_access_log() install_access_log()
log_config = patch_log_config(log_config) uvicorn_config["access_log"] = False # We always bypass uvicorn's own access logging
uvicorn_config["access_log"] = False uvicorn_config["log_config"] = patch_log_config(log_config, access_log=access_log)
uvicorn_config["log_config"] = log_config
conf: dict[str, object] = {"app": app, "reload": bool(reload), "workers": workers} conf: dict[str, object] = {"app": app, "reload": bool(reload), "workers": workers}
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
+1
View File
@@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"zstandard>=0.23.0", "zstandard>=0.23.0",
"blake3>=1.0.8", "blake3>=1.0.8",
"tracerite>=2.6.5",
] ]
[project.urls] [project.urls]