logging-standard
Skill2 starsChanged yesterdayMITView on GitHub
Use before adding or changing any logging call in src/: log levels, exception handling, the event_name key=value message grammar tests/test_logging_standard.py enforces, and what pvl-core's root-logger setup (standard-library logging rendered as Rich or JSON) does with a call that follows it or does not.
- Reads credentials
We split this skill into the rules it gives an agent
3 rules. 1 is common, 0 are distinctive, and 2 are very specific to this project.
1
Common
Most of the 3 edited versions we read keep these.
0
Distinctive
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 90
All exceptions must be caught and handled.
- Very specific: line 128
Nothing else: no prose before, between or after the fields.
- Common: line 164
Never use f-strings in log calls (defeats lazy evaluation, and hides the fields from the parser).
“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
<!-- ===== TEMPLATE-OWNED — re-rendered on template updates. ===== -->Logging Standard
Logging Standard
Scope
This standard governs first-party code only — src/scholar_mcp/
and tests/. Two categories of log output are explicitly out of scope;
do not try to make them conform, silence them, or reformat them:
- pvl-core's request-logging middleware: emits conforming
event key=valuerecords for every MCP message (request_started/request_completed/request_failedkeyed bymethod=, and thetool_call_*vocabulary carryingtool=<name>). Governed byfastmcp-pvl-core; no first-party code needed. - Third-party loggers —
uvicorn.access,uvicorn.error,mcp.server.lowlevel.server,httpx,httpcore,docket.worker:configure_logging_from_envgoverns them for the whole family, and a second rule on the same logger in this repository would only conflict with it. What it does:mcp.server.lowlevel.server,httpxandhttpcoresit atWARNING(or the configured level, if stricter) at every level aboveDEBUG, and atNOTSETunderDEBUGso they reappear.uvicorn.accessis filtered, not levelled: it stays atNOTSET, only failed requests (status 400 and above) pass belowDEBUG, every status passes atDEBUG, and at every level the query string is stripped and a/transfer/<token>segment is redacted — a credential in a request line is not a preference.uvicorn.erroris never demoted (it carries bind failures).docket.workeris capped atINFOunderDEBUGso its poll loop does not flood the stream.
Framework
- Standard library
loggingthroughout. Every module:logger = logging.getLogger(__name__). - No
print()for operational output. No third-party logging libraries; there is no structlog here, and the JSON output below is pvl-core's formatter over ordinaryLogRecords. - pvl-core's middleware handles tool invocation, timing and error logging automatically.
- pvl-core owns the root logger.
cli._rootcallsconfigure_logging_from_env("SCHOLAR_MCP", verbose=...), and so doesmake_server()unless its caller passesconfigure_logging=False, asservedoes to keep-v; it installs the one console handler chain at root, switches FastMCP's own handlers off sofastmcp.*propagates into that chain, and is idempotent (its own handlers are replaced on every call, any other root handler — OTLP, file, syslog — is left alone). Never attach a handler yourself: a second console handler renders every line twice. Everything it writes goes to stderr; stdout is the protocol channel under stdio transport.
Operator surface
| Variable | Effect |
SCHOLAR_MCP_LOG_LEVEL | The level for every logger in the process. Default INFO; -v on the command line forces DEBUG. The unprefixed FASTMCP_LOG_LEVEL still works for one major and logs log_level_env_deprecated once. |
SCHOLAR_MCP_LOG_FORMAT | rich (one coloured event key=value line per record) or json (one JSON object per record). Unset is auto: rich when stderr is a terminal, json everywhere else. An unrecognised value falls back to auto, the same way an unknown level falls back to INFO. |
SCHOLAR_MCP_SHUTDOWN_GRACE_S | The SIGTERM drain window run_http gives uvicorn; default 3. Not a logging knob, listed because cli.serve used to hard-code it. |
The container image, the packaged systemd unit and the compose file set
neither logging variable: a container and journald are not terminals, so
auto already means JSON there, and .env or /etc/scholar-mcp/env
keeps the choice.
Under pytest the server logs JSON, because a test runner's stderr is
not a terminal either. A test that asserts on Rich-shaped stderr sets
SCHOLAR_MCP_LOG_FORMAT=rich with monkeypatch before the code under
test calls configure_logging_from_env, or — better — asserts on
caplog.records (record.msg and record.args), which no renderer touches.
Log Levels
| Level | Use for |
DEBUG | Detailed internals: cache hits, parameter values, config resolution |
INFO | Significant operations: service startup, configuration decisions (tool calls logged by middleware) |
WARNING | Degraded but continuing: API errors with fallback, missing optional config, unexpected data |
ERROR | Failures affecting the primary result. Use logger.error(..., exc_info=True) when traceback is needed |
CRITICAL | The process cannot continue to do its job. The level parser accepts it for SCHOLAR_MCP_LOG_LEVEL too; at that level even the deprecation notice above is raised to CRITICAL so it stays visible |
Exception Handling
- All exceptions must be caught and handled. No bare
except:. Always specify the exception type. - Optional enrichment failures: catch, log at
DEBUGwithexc_info=True, continue. This holds inside a tool too: a failed optional extra is not an outcome of the call. - Inside an MCP tool, follow the
designing-tool-outcomesskill instead of the two bullets below. A tool never returns an error string or an error-shaped value. It raisesToolError: at INFO when only the model has to act (not found, invalid input, a stale version), and at WARNING or ERROR for a server fault. The tool's boundary logs an unexpected exception once, with the traceback. - Outside a tool (startup, CLI, background jobs):
- Expected errors (HTTP 4xx, missing data): catch, log, return user-facing error string.
- Primary result errors: catch, log at
WARNINGorERROR, return error string.
ErrorHandlingMiddlewareis a safety net. If it catches something, that's a bug to fix.
Message Format
Pseudo-structured: logger.info("event_name key=%s", value). Standard
library logging keeps the template (record.msg) and the values
(record.args) apart on the record, so a consumer that parses the
template recovers typed fields from every call, but only when the
template follows the grammar below. fastmcp-pvl-core owns that grammar
and ships the check; tests/test_logging_standard.py runs it over src/
and fails the build on any call that breaks it.
Why it is a rule and not a preference. In JSON mode a conforming record
renders as {"ts": …, "level": …, "logger": …, "event": "<name>",
"<field>": <value>, …} — one key per field, ready for a collector's facets.
A non-conforming call does not error: it renders as an opaque
"message": "<the formatted text>" and every field is lost. In Rich mode
both shapes look alike, which is why the difference is invisible at a
terminal and matters in production.
The grammar. A template is an event name, then zero or more space-separated fields:
- Event name: the first token,
snake_case([a-z][a-z0-9_]*). Nothing before it. - Field:
name=value, wherenameissnake_caseandvalueis either one%-conversion (%s,%d,%i,%f,%.1f,%r,%x,%e,%gand their upper-case forms, with optional flags, width and precision) or a fixed token with no space,%or=in it (status=configured). - Arguments: exactly one positional argument per conversion, in order.
No
*args; the count must be readable from the source. - Nothing else: no prose before, between or after the fields; no
colons, dashes, parentheses or units outside a field; no
%%.
Cases the grammar settles. Each left column is a real call from the family, each right column is the conforming form:
| Do not write | Write |
"Service started" | "service_started" |
"Auth enabled: mode=%s" | "auth_enabled mode=%s" |
"OllamaProvider initialised: host=%s" | "ollama_provider_initialised host=%s" |
"s2_rate_limited attempt=%d/%d" | "s2_rate_limited attempt=%d max_attempts=%d" |
"s2_rate_limited waiting=%.1fs" | "s2_rate_limited waiting_s=%.1f" |
"cache_hit ratio=%d%%" | "cache_hit ratio_pct=%d" |
"scaffold inactive (no app_domain)" | "apps_scaffold_inactive reason=no_app_domain" |
f"fetch_failed url={url}" | "fetch_failed url=%s", url |
A compound value becomes two fields; a unit or a percent sign moves into
the field name; a reason that used to be prose becomes a fixed-token
value; a traceback goes through exc_info=True, never into the message.
What the renderer does with a conforming call.
- Four field names are reserved by the JSON envelope:
ts,level,loggerandevent. A field with one of those names is emitted asfield_<name>(level=%sbecomes"field_level") so it cannot overwrite the record's real severity. Pick another name before an aggregator's severity facet surprises you. - Values need no pre-quoting. A value with whitespace or a
"is quoted and escaped in Rich output and is an ordinary JSON string in JSON mode. - A malformed call does not raise.
logger.info("e a=%d", "x")renders<unrenderable log record: <logger name>>— safe, but the message is gone, so the shape of that line is worth recognising in a log.
Rules the check relies on.
- Never use f-strings in log calls (defeats lazy evaluation, and hides the fields from the parser).
- The logger is the module-level
logger = logging.getLogger(__name__)and the call is one ofdebug/info/warning/error/exception/criticalon it. The check judges only that shape: a logger reached asself.loggeror imported from another module, andlogger.log(level, ...), are invisible to it. That is a limit of the check, not permission to use those forms to route around it; following the mandated receiver is also what keeps your calls checkable. find_nonconforming_log_callsis pvl-core's own checker and gates pvl-core's build too (pvliesdonk/fastmcp-pvl-core#328). Runuv run pytest tests/test_logging_standard.py -qto list every non-conforming call with its file, line and reason.
Other versions3 projects use a version of this.
Word for word · 0
None found.
Edited · 2
- pvliesdonk/markdown-vault-mcp★ 3797% same
- pvliesdonk/paperless-mcp★ 198% same
Wrote their own version · 1
- quocld/hephaestus-agent-skills★ 8same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository16 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .claude/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- applying-template-updatesSkill
- authoring-issues-prsSkill
- config-contractSkill
- designing-tool-outcomesSkill
- releasingSkill
- repository-protectionSkill
- researching-referencesSkill
- roadmappingSkill
- self-reviewingSkill
- tool-registrationSkill
- writing-documentationSkill
- writing-model-facing-textSkill
- writing-release-notesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

