The machinery landed last commit; these are the events. What a routed request
now leaves behind, at info:
route id=r9116d9 cat=coding_refactor tier=2 ctx=500 src=classifier
tools=0 latency=interactive cand=8 pick=deepseek-v4-flash
est_usd=0.00016296 prof=1 ms=1868
dispatch id=r9116d9 model=deepseek-v4-flash rid=chatcmpl-... sess=c090d751
p_tok=59511 c_tok=415 kwh=4.8e-05 usd=0.000783 verdict=unverifiable
upstream_ms=3980 total_ms=5692
`rid` and `sess` are columns in energy_observations, so a journal line pivots
to its database row and back with a --grep. That is why no schema change was
needed to get traceability.
At debug it also says why, which is the half that was never recorded anywhere:
classify id=r9116d9 cat=coding_refactor tier=2 ctx=500 confidence=0.95 ms=1863
filter id=r9116d9 model=gemma-4-31b reason=tier(1<2)
filter id=r9116d9 model=glm-5.2-short reason=access_level(preview)
filter id=r9116d9 model=kimi-k3-flex reason=latency_class(flex)
rank id=r9116d9 pos=0 model=deepseek-v4-flash prof=1 est_usd=0.00016296
Those reasons come from routing.rejection_reason, which is is_eligible turned
inside out: it returns the failing filter and its numbers instead of False, and
is_eligible is now a one-line wrapper over it. One copy of the rules, so the
log cannot drift from the decision it describes.
log_decision is called by the ENDPOINTS, not by route(). chat_completions
routes twice whenever the measured conversation exceeds the classifier's
estimate -- with opencode sending ~32K of system prompt, essentially always --
and logging inside route() would double every line for no new information.
Two things the tests caught that reasoning had not:
The streaming path could not use the ContextVar. starlette resumes the
generator through a threadpool and every next() gets a fresh COPY of the
caller's context, so a trace id set inside is gone by the finally block that
logs the dispatch line. It logged id=- until the id was carried explicitly by
logs.bind() -- on the path all agent traffic takes.
$JOURNAL_STREAM is inherited. A foreground uvicorn started from a
systemd-managed session, output redirected to a file, put a literal <7> on
every line: the variable was set, but described a stream that was no longer
ours. It is now compared against the actual fd's device:inode, which is what
systemd documents.
A test asserts no conversation text reaches the log at any level, prompt or
answer. Prompts here run 60k-150k tokens and the journal is on disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
278 lines
9.4 KiB
Python
278 lines
9.4 KiB
Python
"""Structured logging for the dispatcher.
|
|
|
|
The service had no logger. It had four ``print(..., file=sys.stderr)`` calls,
|
|
all of them failure paths, so a request that WORKED said nothing at all — not
|
|
the category, the tier, the candidates, the model chosen, the cost, or the
|
|
latency. Watching a live agent session showed only uvicorn's access line, which
|
|
does not even name the model that served it.
|
|
|
|
Three things this module provides, in order of how much they matter:
|
|
|
|
**A trace id per request.** One request touches classification, filtering,
|
|
ranking, dispatch, verification and possibly a retry, and until now nothing
|
|
tied those together. The id is carried in a ContextVar so helpers read it
|
|
without every signature growing a parameter, and it is printed on every line.
|
|
|
|
**logfmt, not prose.** ``route id=r7f3a91 cat=coding_general pick=...`` reads
|
|
fine in ``journalctl`` and greps without a JSON parser. Prose reads better once
|
|
and aggregates never.
|
|
|
|
**Real journald priorities.** systemd strips a ``<N>`` prefix off a log line and
|
|
records the message at that priority (``SyslogLevelPrefix`` defaults to true),
|
|
which is what makes ``journalctl -p warning`` mean something. Without it every
|
|
line lands at PRIORITY 6 and severity cannot be filtered at all.
|
|
|
|
The prefix is emitted ONLY when ``$JOURNAL_STREAM`` is set, which systemd
|
|
exports when it owns our stderr and a terminal does not — so running uvicorn in
|
|
a foreground shell prints clean lines rather than a literal ``<6>`` on each one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import sys
|
|
from typing import Any, Optional, TextIO
|
|
|
|
LOGGER_NAME = "llm_router"
|
|
LEVEL_ENV = "LLM_ROUTER_LOG_LEVEL"
|
|
|
|
log = logging.getLogger(LOGGER_NAME)
|
|
|
|
LEVELS = {
|
|
"debug": logging.DEBUG,
|
|
"info": logging.INFO,
|
|
"warning": logging.WARNING,
|
|
"error": logging.ERROR,
|
|
}
|
|
|
|
# syslog priorities, which is what journald speaks. 5 (notice) and 1 (alert)
|
|
# are unused: nothing here is between info and warning, or above error.
|
|
PRIORITIES = {
|
|
logging.DEBUG: 7,
|
|
logging.INFO: 6,
|
|
logging.WARNING: 4,
|
|
logging.ERROR: 3,
|
|
logging.CRITICAL: 2,
|
|
}
|
|
|
|
NO_TRACE = "-"
|
|
|
|
_trace: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id", default=NO_TRACE)
|
|
|
|
|
|
# --- trace ids ------------------------------------------------------------
|
|
|
|
def new_trace() -> str:
|
|
"""Start a new request trace and return its id."""
|
|
trace_id = "r" + secrets.token_hex(3)
|
|
_trace.set(trace_id)
|
|
return trace_id
|
|
|
|
|
|
def current_trace() -> str:
|
|
return _trace.get()
|
|
|
|
|
|
def set_trace(trace_id: str) -> None:
|
|
"""Adopt an existing trace id in this context.
|
|
|
|
Needed on the streaming path. A StreamingResponse's generator is iterated
|
|
from a different context than the endpoint that built it, so the ContextVar
|
|
set in the endpoint is NOT visible inside the generator — it must capture
|
|
the id as a local and re-set it here, or every streamed request logs no id
|
|
at all. That is the path all agent traffic takes.
|
|
"""
|
|
_trace.set(trace_id)
|
|
|
|
|
|
# --- rendering ------------------------------------------------------------
|
|
|
|
def render_value(value: Any) -> str:
|
|
"""One logfmt value: unambiguous, single-line, cheap to read."""
|
|
if value is None:
|
|
return "-"
|
|
if isinstance(value, bool):
|
|
return "1" if value else "0"
|
|
if isinstance(value, float):
|
|
# Six significant figures. Enough for microdollars and kWh in
|
|
# scientific notation, without 0.0007830000000000001.
|
|
return f"{value:.6g}"
|
|
text = str(value)
|
|
if text == "":
|
|
return '""'
|
|
if any(c in text for c in ' "\n\t='):
|
|
text = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
text = text.replace("\n", " ").replace("\t", " ")
|
|
return f'"{text}"'
|
|
return text
|
|
|
|
|
|
def render(name: str, fields: dict[str, Any], trace_id: Optional[str] = None) -> str:
|
|
parts = [name, f"id={trace_id or current_trace()}"]
|
|
parts += [f"{key}={render_value(value)}" for key, value in fields.items()]
|
|
return " ".join(parts)
|
|
|
|
|
|
def _emit(
|
|
level: int, name: str, fields: dict[str, Any], trace_id: Optional[str] = None
|
|
) -> None:
|
|
"""Emit one logfmt line, if this level is enabled.
|
|
|
|
The level check comes first so DEBUG formatting costs nothing when DEBUG is
|
|
off — this runs on every request.
|
|
"""
|
|
if log.isEnabledFor(level):
|
|
log.log(level, "%s", render(name, fields, trace_id))
|
|
|
|
|
|
def event(level: int, name: str, **fields: Any) -> None:
|
|
_emit(level, name, fields)
|
|
|
|
|
|
def debug(name: str, **fields: Any) -> None:
|
|
_emit(logging.DEBUG, name, fields)
|
|
|
|
|
|
def info(name: str, **fields: Any) -> None:
|
|
_emit(logging.INFO, name, fields)
|
|
|
|
|
|
def warning(name: str, **fields: Any) -> None:
|
|
_emit(logging.WARNING, name, fields)
|
|
|
|
|
|
def error(name: str, **fields: Any) -> None:
|
|
_emit(logging.ERROR, name, fields)
|
|
|
|
|
|
class Bound:
|
|
"""An emitter pinned to one trace id, for code the ContextVar cannot reach.
|
|
|
|
A StreamingResponse's generator is resumed by starlette through a
|
|
threadpool, and every ``next()`` runs in a FRESH COPY of the caller's
|
|
context — so a trace id set on one resumption is gone by the next, and the
|
|
``finally`` block that logs the dispatch line sees nothing. Measured: the
|
|
streamed dispatch line logged ``id=-`` until this existed.
|
|
|
|
Carrying the id explicitly is the only thing that survives that, and
|
|
streaming is the path all agent traffic takes.
|
|
"""
|
|
|
|
__slots__ = ("trace_id",)
|
|
|
|
def __init__(self, trace_id: str):
|
|
self.trace_id = trace_id
|
|
|
|
def debug(self, name: str, **fields: Any) -> None:
|
|
_emit(logging.DEBUG, name, fields, self.trace_id)
|
|
|
|
def info(self, name: str, **fields: Any) -> None:
|
|
_emit(logging.INFO, name, fields, self.trace_id)
|
|
|
|
def warning(self, name: str, **fields: Any) -> None:
|
|
_emit(logging.WARNING, name, fields, self.trace_id)
|
|
|
|
def error(self, name: str, **fields: Any) -> None:
|
|
_emit(logging.ERROR, name, fields, self.trace_id)
|
|
|
|
|
|
def bind(trace_id: Optional[str] = None) -> Bound:
|
|
"""Pin an emitter to a trace id, defaulting to the current one."""
|
|
return Bound(trace_id or current_trace())
|
|
|
|
|
|
def enabled_for_debug() -> bool:
|
|
"""For callers that must do real work to produce debug fields."""
|
|
return log.isEnabledFor(logging.DEBUG)
|
|
|
|
|
|
# --- configuration --------------------------------------------------------
|
|
|
|
class PriorityFormatter(logging.Formatter):
|
|
"""Plain message, optionally prefixed with a journald priority."""
|
|
|
|
def __init__(self, prefix: bool):
|
|
super().__init__("%(message)s")
|
|
self.prefix = prefix
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
message = super().format(record)
|
|
if not self.prefix:
|
|
return message
|
|
return f"<{PRIORITIES.get(record.levelno, 6)}>{message}"
|
|
|
|
|
|
def under_journald(stream: Optional[TextIO] = None) -> bool:
|
|
"""Whether this stream really is the journal.
|
|
|
|
systemd sets $JOURNAL_STREAM to the "device:inode" of the stream it
|
|
connected, and the variable is INHERITED by every child — including one
|
|
whose stderr has since been redirected to a file or a pipe. Presence alone
|
|
is therefore a false positive, and it fired immediately: a foreground
|
|
uvicorn started from a systemd-managed session, with its output redirected
|
|
to a file, wrote a literal <7> on every line.
|
|
|
|
Comparing the value against the actual fd is what systemd documents, and it
|
|
is the only check that answers the real question.
|
|
"""
|
|
expected = os.environ.get("JOURNAL_STREAM")
|
|
if not expected:
|
|
return False
|
|
device, _, inode = expected.partition(":")
|
|
try:
|
|
stat = os.fstat((stream if stream is not None else sys.stderr).fileno())
|
|
return stat.st_dev == int(device) and stat.st_ino == int(inode)
|
|
except (AttributeError, OSError, ValueError):
|
|
# No fileno at all (a StringIO under test), or an unparseable value.
|
|
return False
|
|
|
|
|
|
def resolve_level(configured: str) -> int:
|
|
"""The env var wins, so a live service can be debugged without a repo edit.
|
|
|
|
config.yaml is the documented home of the setting -- every knob belongs
|
|
there -- but flipping it means editing a tracked file and leaving a stray
|
|
diff. LLM_ROUTER_LOG_LEVEL goes in a systemd drop-in instead.
|
|
"""
|
|
override = os.environ.get(LEVEL_ENV)
|
|
if override:
|
|
level = LEVELS.get(override.strip().lower())
|
|
if level is not None:
|
|
return level
|
|
print(
|
|
f"{LEVEL_ENV}={override!r} is not one of {sorted(LEVELS)}; "
|
|
f"falling back to logging.level={configured!r}",
|
|
file=sys.stderr,
|
|
)
|
|
return LEVELS.get((configured or "info").strip().lower(), logging.INFO)
|
|
|
|
|
|
def configure(
|
|
level: str = "info",
|
|
*,
|
|
stream: Optional[TextIO] = None,
|
|
journald: Optional[bool] = None,
|
|
) -> logging.Logger:
|
|
"""Install the one handler this logger gets. Idempotent.
|
|
|
|
``propagate`` is off: uvicorn configures its own loggers and a root handler
|
|
installed by anything else would print every line a second time.
|
|
"""
|
|
resolved = resolve_level(level)
|
|
log.setLevel(resolved)
|
|
log.propagate = False
|
|
for existing in list(log.handlers):
|
|
log.removeHandler(existing)
|
|
|
|
target = stream if stream is not None else sys.stderr
|
|
handler = logging.StreamHandler(target)
|
|
handler.setLevel(resolved)
|
|
handler.setFormatter(
|
|
PriorityFormatter(under_journald(target) if journald is None else journald)
|
|
)
|
|
log.addHandler(handler)
|
|
return log
|