* Studio: exclude /api/export/status from request access logs The frontend polls /api/export/status every 5s to detect export start, so it fires continuously even when idle. Each poll emitted an info request_completed access line, making up most of the server access logs. Add it to _EXCLUDED_PATHS alongside /api/train/status. The endpoint is unchanged; export state is still logged by the export modules and streamed over SSE, so no signal is lost. * Studio: collapse hub download-progress polls in the access log download-status and gguf-download-progress (plus the dataset equivalents) are polled about twice a second for the whole download, so each emitted an info request_completed line. Add them to _QUIET_POLL_PATHS so they collapse to one heartbeat line per 10s instead of one per poll. * Studio: log hub download progress at 10% steps The access log carried no real progress, only poll pings. Emit one hub_download_progress line per 10% step from the shared snapshot progress reader, so an active download shows actual percentage without a line per poll. Throttled per job and resynced if the same download restarts. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: drop successful chat thread/project CRUD from the access log A single chat turn fans out about twenty requests under /api/chat/threads and /api/chat/projects (list, fetch, per-message forks, and the message writes) that only reflect the UI re-rendering. Suppress their 2xx access line so the log keeps the signal (generation, tool calls, code execution, engine stats) and errors. Non-2xx on these paths still log. * Studio: silence transformers torch_dtype deprecation warning transformers logs "`torch_dtype` is deprecated! Use `dtype` instead!" once at model-config load via logger.warning_once (logging, not warnings), so a warnings filter cannot catch it. Attach a small logging.Filter in setup_logging, which runs before any model config is parsed, to drop that record on the transformers loggers that emit it. * Studio: quiet inference load-progress polls and log throttled load progress The frontend polls /api/inference/load-progress about twice a second for the whole model load, so each emitted a request_completed line. Add it to _QUIET_POLL_PATHS (heartbeat) and emit one inference_load_progress line per 10% step from the load-progress route, so a load shows real percentage instead of a line per poll. * Studio: fully suppress download/load progress poll access lines The download-status, download-progress, gguf-download-progress, active-downloads and transport-status polls (model and dataset), plus inference load-progress, fire ~2x/s for the whole download or load. Their progress is now reported by the hub_download_progress / inference_load_progress events (and the viewer's progress line), so the per-poll access line adds nothing. Drop it on 2xx and keep it on errors, instead of the prior 10s heartbeat. Chat CRUD suppression is folded into the same _is_quiet_success helper. * Studio: suppress training-tab model/dataset download-progress polls The training tab polls /api/models/download-progress and /api/datasets/download-progress about twice a second for the whole prep phase. These are separate routes from the /api/hub equivalents and only scan the cache, so their 2xx access line adds nothing (on Windows they always read 0 since the bytes live in snapshots/, not blobs/). Suppress the 2xx line and keep errors, alongside /api/models/gguf-download-progress. * Studio: drop transient pre-auth 401 on chat thread/project polls On first load the SPA fires chat thread/project GETs before the initial token refresh, so they 401 until /api/auth/refresh runs and the retries succeed. That pre-auth 401 is a bootstrap artifact, not an error; suppress it alongside the already-quiet 2xx line. Genuine 4xx/5xx on these paths, the download/load poll 401s, and all /api/auth/* still log. * Studio: quiet tab-switch list polls and per-poll scan/reconnect logs Switching between the Train, Export, and Chat tabs refetches list endpoints on a timer, and each hit re-logs internal detail. Heartbeat /api/train/runs, /api/models/checkpoints, /api/models/local and /api/rag/knowledge-bases (10s window, first hit and errors still log), and downgrade two per-poll INFO lines to debug: the checkpoints scan summary ("Found N training runs") and the per-reconnect SSE resume line. The meaningful "replayed N missed steps" line, logged only when steps were actually replayed, stays at info. * Studio: enable tokenizer parallelism for dataset prep on Windows/macOS TOKENIZERS_PARALLELISM was forced off everywhere to stop datasets' forked map() workers from deadlocking, but that fork only happens on Linux. On spawn platforms (Windows/macOS) dataset.map() runs in-process (dataset_map_num_proc returns None), so disabling tokenizer parallelism leaves the fast tokenizer single-threaded and dataset prep runs serially on one core. Keep it off on Linux (fork safety) and on for spawn platforms, where there is no fork to deadlock. Measured ~7x faster tokenization (12.5s -> 1.7s for 20k rows on a 32-core Windows box). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: log throttled training status to the server log Training step/loss/epoch only went to the UI via SSE, so the server log showed inference engine_stats and train/runs heartbeats but nothing about the actual run. Emit one throttled training_progress line (step/total, percent, loss, epoch, eta) from the CUDA event pump: the first step, then at most every 30s, plus the final step, resyncing when a new run restarts the counter. Per-step UI streaming is unchanged. * Studio: quiet llama.cpp update-status polls and log throttled update progress The prebuilt llama.cpp update polls /api/llama/update-status about twice a second for the whole download and install. Suppress its 2xx access line (errors still log) and emit one throttled llama_update_progress line per 10% step from the status route, so the update shows progress without a line per poll. The existing "llama update: installing" and "llama update: success" events still bracket it. * Studio: quiet the export log-tail poll The Export tab polls /api/export/logs about once a second to stream the export subprocess output into the UI panel. Suppress its 2xx access line; the real progress is already logged as event-driven "Export subprocess status: <phase>" lines plus the subprocess start and checkpoint-loaded events, and errors still log. * studio: keep errors and mutations visible in access-log suppression Make the quiet-success access-log suppression GET-only so chat thread/project mutations (POST/PUT/DELETE) still log; only their list-poll 2xx and the transient pre-auth 401 are dropped. Suppress /api/export/status 2xx only (move it out of the all-status exclude set) so a 401/403/500 on it stays visible. Legacy /api/models and /api/datasets download-progress polls emit no hub_download_progress events, so heartbeat them via the 10s quiet-poll window instead of suppressing outright, keeping download visibility (notably on Linux). The event-emitting /api/hub download polls stay fully suppressed. Update and extend the middleware tests to cover GET-only suppression, the export-status error path, and the legacy download heartbeat. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: tighten access-log and training-progress comments Comment-only pass: collapse the multi-line explanations in the logging middleware and the throttled training-progress logger to fewer lines while keeping the rationale. No behavior change. * studio: log structured export_progress phases Emit a structured export_progress event per phase (consolidated in the server log like training and download progress) instead of a plain status string, and add a phase milestone at the start of the heavy export step so the merge/save/convert is visible in the server log, not only in the forwarded stdout panel. * Studio: reset training-progress log throttle on each new run start_training rebuilds the per-run progress state but left _last_progress_log_ts/_last_progress_log_step at their prior values. A run started within 30s of a previous one whose last logged step matched the new run's first step would hit the step == prev short-circuit and drop the promised first training_progress line, then stay suppressed until the old 30s window expired. Reset both fields when a new job is accepted. * Studio: keep post-bootstrap chat 401s visible in the access log The chat thread/project 401 suppression dropped every GET 401 on those prefixes, so a genuine expired-session 401 vanished alongside the transient pre-auth race. Gate the 401 drop on a per-middleware bootstrap latch that flips once /api/auth/refresh first succeeds: before that the 401s are the pre-refresh race and are suppressed; after it any chat 401 is a real failure and logs. Add a test for the post-refresh case. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: limit chat access-log suppression to the exact list polls The chat thread/project suppression matched by startswith, so it also dropped the 2xx access line for detail and message reads (/threads/{id}, /threads/{id}/messages, /threads/{id}/messages/{id}, /projects/{id}) that are not the high-frequency list polls, losing their access and latency logging. Match the two list paths exactly instead, so only the intended list polls (and their pre-auth 401 race) are suppressed while detail and message reads keep their access line. Add a regression test. * Studio: reset inference load-progress throttle for each load The load-progress throttle (_last_load_progress_step) is a module global that persisted across loads, so a cached or small load whose first sampled /api/inference/load-progress response already reported fraction=1.0 hit step == prev (10) from a prior completed load and emitted no inference_load_progress line, while that endpoint's access log is suppressed, leaving the new load with no progress signal. Arm the throttle at load initiation in _load_model_impl so each load's first step always logs. Add a regression test. * Studio: tighten logging comments Collapse a few verbose comments (tokenizer-parallelism note, torch_dtype filter, legacy download-poll heartbeat, chat list-path suppression) to fewer lines without changing intent or code. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
233 lines
8.6 KiB
Python
233 lines
8.6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Structured logging handlers and middleware.
|
|
|
|
LoggingMiddleware (request/response logging with timing),
|
|
filter_sensitive_data (structlog processor for sanitization), and
|
|
get_logger (factory for structured loggers).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
import structlog
|
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
|
|
from utils.native_path_leases import redact_native_paths
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
try:
|
|
raw = (os.environ.get(name) or "").strip()
|
|
return int(raw) if raw else default
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
# Collapse identical GET/2xx logs within the window (the SPA fans one invalidation
|
|
# into many list fetches). Mutations and errors always log. 0 = off.
|
|
_ACCESS_LOG_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_DEDUP_MS", 300)
|
|
# Liveness/UI polls whose line means only "still polling"; collapse to a longer
|
|
# heartbeat. First hit and errors still log. 0 = off.
|
|
_QUIET_POLL_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS", 10000)
|
|
_QUIET_POLL_PATHS = {
|
|
"/api/health",
|
|
"/api/auth/status",
|
|
"/api/inference/status",
|
|
"/api/inference/monitor",
|
|
# List polls the tabs refetch on a timer and on every tab switch.
|
|
"/api/train/runs",
|
|
"/api/models/checkpoints",
|
|
"/api/models/local",
|
|
"/api/rag/knowledge-bases",
|
|
# Legacy download polls emit no progress events (unlike /api/hub/*), so heartbeat them.
|
|
"/api/models/download-progress",
|
|
"/api/models/gguf-download-progress",
|
|
"/api/datasets/download-progress",
|
|
}
|
|
_DEDUP_MAP_MAX = 4096
|
|
_NATIVE_PATH_LEASE_RE = re.compile(
|
|
r"(?i)(\b(?:native_path_lease|nativePathLease)[\"']?\s*[:=]\s*[\"']?)[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"
|
|
)
|
|
_EXCLUDED_PATHS = {
|
|
"/api/train/status",
|
|
"/api/train/metrics",
|
|
"/api/train/hardware",
|
|
"/api/system",
|
|
}
|
|
_EXCLUDED_SUFFIXES = (
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".svg",
|
|
".ico",
|
|
".woff",
|
|
".woff2",
|
|
".ttf",
|
|
)
|
|
# GET polls whose 2xx line carries no signal (their progress/phase events and the UI
|
|
# do), so drop it entirely; non-2xx still logs. Only /api/hub download polls emit
|
|
# events; the legacy /api/models and /api/datasets ones heartbeat via _QUIET_POLL_PATHS.
|
|
_QUIET_SUCCESS_PATHS = {
|
|
"/api/inference/load-progress",
|
|
"/api/llama/update-status",
|
|
"/api/export/logs",
|
|
"/api/export/status",
|
|
"/api/hub/download-status",
|
|
"/api/hub/download-progress",
|
|
"/api/hub/gguf-download-progress",
|
|
"/api/hub/active-downloads",
|
|
"/api/hub/transport-status",
|
|
"/api/hub/datasets/download-status",
|
|
"/api/hub/datasets/download-progress",
|
|
"/api/hub/datasets/active-downloads",
|
|
"/api/hub/datasets/transport-status",
|
|
}
|
|
# The token-refresh route. Its first 2xx means the client has obtained a valid
|
|
# session, so from then on chat 401s are real failures and must stay visible.
|
|
_AUTH_REFRESH_PATH = "/api/auth/refresh"
|
|
# High-frequency chat list polls; their 2xx is covered by generation/tool-call/stats
|
|
# events. Exact paths only, so detail/message reads (/threads/{id}, .../messages,
|
|
# /projects/{id}) keep their logs. The pre-auth 401 race also fires on these polls.
|
|
_CHAT_LIST_PATHS = {
|
|
"/api/chat/threads",
|
|
"/api/chat/projects",
|
|
}
|
|
|
|
|
|
def _is_quiet_success(method: str, path: str, status_code: int, pre_auth: bool) -> bool:
|
|
"""GET-only. Suppress a 2xx poll line that carries no signal, plus a chat list
|
|
poll's transient pre-auth 401 (only in the bootstrap window before the first
|
|
successful token refresh). Mutations, real (post-refresh) auth failures, and
|
|
all other errors always log."""
|
|
if method != "GET":
|
|
return False
|
|
if 200 <= status_code < 300:
|
|
return path in _QUIET_SUCCESS_PATHS or path in _CHAT_LIST_PATHS
|
|
return pre_auth and status_code == 401 and path in _CHAT_LIST_PATHS
|
|
|
|
|
|
class LoggingMiddleware:
|
|
"""ASGI request logger that avoids BaseHTTPMiddleware streaming wrappers."""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
self.app = app
|
|
# (method, path, query, status_code) -> monotonic ts of the last EMITTED log.
|
|
self._last_log: dict[tuple[str, str, bytes, int], float] = {}
|
|
# Flips True after the first successful /api/auth/refresh; before that, chat
|
|
# list-poll 401s are the transient bootstrap race and are suppressed.
|
|
self._auth_refreshed = False
|
|
|
|
def _is_redundant_repeat(
|
|
self, method: str, path: str, query: bytes, status_code: int, now: float
|
|
) -> bool:
|
|
"""True if an identical GET/2xx log fired < window ago (query string is part
|
|
of the identity). Non-GET/non-2xx never dedup; quiet-poll paths use the longer
|
|
heartbeat. Stamps only on emit, so steady polls still log."""
|
|
if method != "GET" or not (200 <= status_code < 300):
|
|
return False
|
|
window_ms = _QUIET_POLL_DEDUP_MS if path in _QUIET_POLL_PATHS else _ACCESS_LOG_DEDUP_MS
|
|
if window_ms <= 0:
|
|
return False
|
|
key = (method, path, query, status_code)
|
|
last = self._last_log.get(key)
|
|
if last is not None and (now - last) * 1000.0 < window_ms:
|
|
return True
|
|
self._last_log[key] = now
|
|
if len(self._last_log) > _DEDUP_MAP_MAX:
|
|
cutoff = now - (max(_ACCESS_LOG_DEDUP_MS, _QUIET_POLL_DEDUP_MS) / 1000.0)
|
|
self._last_log = {k: v for k, v in self._last_log.items() if v >= cutoff}
|
|
return False
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
path = scope["path"]
|
|
excluded = (
|
|
path in _EXCLUDED_PATHS
|
|
or path.startswith("/assets/")
|
|
or path.endswith(_EXCLUDED_SUFFIXES)
|
|
)
|
|
start_time = time.perf_counter()
|
|
status_code = 500
|
|
|
|
async def send_wrapper(message: Message) -> None:
|
|
nonlocal status_code
|
|
if message["type"] == "http.response.start":
|
|
status_code = message["status"]
|
|
await send(message)
|
|
|
|
try:
|
|
await self.app(scope, receive, send_wrapper)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"request_failed",
|
|
path = path,
|
|
method = scope["method"],
|
|
status_code = status_code,
|
|
error = str(exc),
|
|
process_time_ms = round((time.perf_counter() - start_time) * 1000, 2),
|
|
exc_info = True,
|
|
)
|
|
raise
|
|
else:
|
|
end_time = time.perf_counter()
|
|
if 200 <= status_code < 300 and path == _AUTH_REFRESH_PATH:
|
|
self._auth_refreshed = True
|
|
if (
|
|
not excluded
|
|
and not _is_quiet_success(
|
|
scope["method"], path, status_code, not self._auth_refreshed
|
|
)
|
|
and not self._is_redundant_repeat(
|
|
scope["method"], path, scope.get("query_string", b""), status_code, end_time
|
|
)
|
|
):
|
|
logger.info(
|
|
"request_completed",
|
|
method = scope["method"],
|
|
path = path,
|
|
status_code = status_code,
|
|
process_time_ms = round((end_time - start_time) * 1000, 2),
|
|
)
|
|
|
|
|
|
def filter_sensitive_data(logger, method_name, event_dict):
|
|
"""Structlog processor to redact native path leases from logs."""
|
|
|
|
def filter_value(value):
|
|
if isinstance(value, str):
|
|
try:
|
|
value = redact_native_paths(value)
|
|
except Exception:
|
|
pass
|
|
value = _NATIVE_PATH_LEASE_RE.sub(r"\1<redacted native path lease>", value)
|
|
return value
|
|
elif isinstance(value, dict):
|
|
return {
|
|
k: "<redacted native path lease>"
|
|
if str(k).replace("_", "").lower() == "nativepathlease"
|
|
else filter_value(v)
|
|
for k, v in value.items()
|
|
}
|
|
elif isinstance(value, list):
|
|
return [filter_value(item) for item in value]
|
|
return value
|
|
|
|
return {
|
|
k: "<redacted native path lease>"
|
|
if str(k).replace("_", "").lower() == "nativepathlease"
|
|
else filter_value(v)
|
|
for k, v in event_dict.items()
|
|
}
|
|
|
|
|
|
def get_logger(name: str) -> structlog.BoundLogger:
|
|
"""Get a bound structured logger for a module (name is usually __name__)."""
|
|
return structlog.get_logger(name)
|