rolled files land in log_dir under their basename (the namer/rotate_on_start basename them), but prune()/retier() globbed the un-basenamed name. a name like 'sub/run' matched nothing, so old .log/.gz files piled up forever (slow disk leak; the live file was fine). basename the stem at the top of both prune() and retier(). regression-verified with name='sub/run' (10 retained vs 12/dead before). bump v0.4.0 -> v0.4.1 Signed-off-by: disqualifier <dev@disqualifier.me> |
||
|---|---|---|
| src/log_setup | ||
| .gitignore | ||
| pyproject.toml | ||
| README.md | ||
log_setup
Stdlib, sync, zero-dependency logging setup an application calls once at its
entry point: a live run.log, rotation (daily / size / on-start), gzip of rolled
files, retention, console output, and a consistent time | module | level | message
format.
It configures logging (handlers, rotation, format) — which reusable libraries here
must never do. That's fine because log_setup is the application's entry-point
setup, not library-internal config. Libraries still only logging.getLogger(__name__)
and emit; their records flow into the handlers log_setup wired.
Install
log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.4.1
No dependencies — stdlib only.
Drop the @v0.4.1 suffix from the line above to install the latest unpinned.
Quick start
import logging
from log_setup import setup_logging
setup_logging(name="run", level="INFO") # daily rotation, logs/ dir, gzip (file only)
log = logging.getLogger(__name__)
log.info("started") # -> ./run.log (add console=True for stdout too)
Call it once, at the app's entry point — before the rest of the app runs. Every module
(yours and the libraries you import) then just does logging.getLogger(__name__) and
emits; the records land in the configured root.
What you get
- Live file at a stable path:
./run.log— alwaystail -f run.log, no dated name to chase. Rolled/compressed copies go intolog_dir(defaultlogs/). - Format:
2026-06-27 19:55:05 | module.name | INFO | message.%(name)sis thegetLoggername each module used, so you see which lib/module logged. - Rotation (
rotate=):"daily"(default) — rolls at midnight, dated name intolog_dir, keepsbackup_countdays."size"— rolls atmax_bytes, numbered backups inlog_dir."on_start"— on startup, moves an existingrun.logintolog_dir(run.<timestamp>.log[.gz]) and starts fresh; prunes tobackup_count.None— single file, no rotation.
- compress=True (default) gzips each rolled file (
run.log.2026-06-27.gz). - Retention =
backup_count(default 14) for every mode — unless tiered retention is enabled (below). - console=True (off by default) also logs to stdout in the same format — opt in when you want live terminal output alongside the file.
The name you pass is normalized so it produces exactly one .log: name="latest" and
name="latest.log" both yield the live file latest.log (never latest.log.log).
Tiered retention (keep_uncompressed / keep_compressed)
The default is a flat backup_count: every rolled file is gzipped on roll and the oldest
are deleted past the count. If instead you want the recent logs uncompressed (read them
without zcat) and older ones gzipped, pass the two tier knobs:
setup_logging(
name="latest",
rotate="on_start", # works for on_start, daily, and size
keep_uncompressed=3, # newest 3 rolled logs kept PLAIN
keep_compressed=7, # next 7 kept GZIPPED; total retained = 10
)
Result in log_dir (newest → oldest):
latest.log <- live "latest" (stable, tail -f)
latest.<t1>.log latest.<t2>.log latest.<t3>.log <- 3 newest: plain
latest.<t4>.log.gz ... latest.<t10>.log.gz <- next 7: gzipped
(anything past 10 deleted)
- Each restart (
on_start) or roll (daily/size) moves the live file intolog_dir, then re-tiers: newestkeep_uncompressedstay plain, the nextkeep_compressedare gzipped in place, the rest deleted. Total kept =keep_uncompressed + keep_compressed. - Opt-in by presence — pass either knob to enable tiering. Pass neither and
rotation behaves exactly as before (
backup_count+ gzip-on-roll), so existing callers are unaffected. - In tiered mode
backup_countand the gzip-on-roll behavior ofcompressare ignored — the tier counts bound retention instead. keep_uncompressed=0→ everything gzipped;keep_compressed=0→ only the plain tier. Retention is count-based (not time-based).
Output format (output=)
Two formats, two needs. Default is "text"; the live-file name is the same either way
(run.log, never auto-renamed), so a service can switch text↔json without breaking the
Promtail glob, bind-mount path, or your tail command.
output="text"(default) — human-readable2026-06-27 19:55:05 | module.name | INFO | message, local time. The single-machinetail -fpath.fmt/datefmtoverride it. Unchanged from v0.1.x.output="json"— structured one JSON object per line (JSON Lines) for the Grafana/Loki pipeline (Promtail → Loki → Grafana); Loki parses JSON fields into labels natively, no regex.
setup_logging(name="run", output="json")
logging.getLogger("bot.core").info("ready", extra={"monitor": "heartbeat"})
# -> {"time": "2026-06-28T14:03:11Z", "level": "INFO", "module": "bot.core",
# "message": "ready", "monitor": "heartbeat"}
- Fields:
time,level,module,messagealways; anyextra={...}keys land as top-level fields (stampmonitor/service/request-id for Loki labels — the lib stays domain-agnostic); error records carry the traceback inexc_info(never dropped). - Time is UTC ISO-8601 with a
Z(2026-06-28T14:03:11Z), not local. json is the aggregation path — logs from many servers/containers sort unambiguously only in UTC; Grafana converts to local for display. (Text mode stays local — that's a human on one box.) - Both file and console use the chosen format.
fmt/datefmtapply to text only (json builds fields, not a format string). An unknownoutputfalls back to text + warns, never crashes. Zero new deps — stdlibjsononly.
Signature
setup_logging(
name="run", # base -> run.log (the live file at cwd)
log_dir="logs", # rotated/compressed copies live here (created if absent)
level="INFO", # root level everything inherits (str name or logging constant)
module_levels=None, # {logger_name: level} per-logger overrides (exact name match)
rotate="daily", # "daily" | "size" | "on_start" | None
backup_count=14, # rotated files to keep (flat retention; ignored if tiered)
keep_uncompressed=None, # tiered: newest N rolled logs kept PLAIN (opt-in)
keep_compressed=None, # tiered: next M rolled logs kept GZIPPED (opt-in)
max_bytes=10_000_000, # only for rotate="size"
compress=True, # gzip rolled files
console=False, # also log to stdout (off by default; opt in)
queue=False, # route through a background QueueListener (async-friendly)
output="text", # "text" (human, local time) | "json" (structured, UTC)
fmt=None, # override the text format string (text mode only)
datefmt=None, # override the text date format (text mode only)
) -> logging.Logger # returns the configured root logger
Quieting noisy dependencies (module_levels)
level is the root default — every logger inherits it. module_levels is an
optional {logger_name: level} map of per-logger overrides applied at setup, the
standard "turn down the chatty dependency while my own code stays at INFO" case:
setup_logging(
name="run",
level="INFO", # our code logs at INFO
module_levels={
"motor": "WARNING", # quiet the driver
"pymongo": "WARNING",
"aiohttp": "WARNING", # also quiets aiohttp.client / aiohttp.access (hierarchy)
},
)
- Exact-name match — names are NOT discovered. It calls
logging.getLogger(name).setLevel(level)for exactly the name you give. There's no smart find of noisy modules; you name the loggers. A typo ("moter") silently configures a logger nothing uses — no error, no effect. Get the names right. - Hierarchy applies (the one "smart" part, and it's just stdlib): naming a parent
quiets its whole subtree.
"aiohttp"also quietsaiohttp.client,aiohttp.access, etc. — the way to catch sub-loggers without listing each. - str or int per entry (
"WARNING"orlogging.WARNING) — same normalization as the rootlevel. - Never crashes: a bad level for one entry is skipped with a warning; the other entries and the rest of setup still apply. Consistent with the never-crash-over-logging rule.
None/{}(default) → no overrides; existing callers are unaffected.
Common noisy library logger names: motor, pymongo, aiohttp (parent quiets
aiohttp.client/aiohttp.access), discord / discord.*, asyncio, urllib3. Check
a lib's actual logger name — some log under a name different from their package.
This already works without the lib (logging.getLogger("motor").setLevel(WARNING) after
setup does the same via stdlib hierarchy). The param's value is ergonomic: it keeps the
overrides in the one setup_logging call at the entry point instead of scattering
setLevel calls afterward — which is the whole point of log_setup.
Async-friendly (queue=True)
For async-heavy apps, queue=True routes records through a stdlib QueueHandler to a
background QueueListener that owns the file/console handlers, so the event loop never
blocks on file I/O. The API stays sync (log.info() as usual); the queue is internal.
The listener is stopped (and flushed) cleanly at process exit, so no records are lost.
setup_logging(name="run", queue=True)
Safety
- Idempotent: calling
setup_loggingagain clears only the handlers it added (no duplicate lines) and leaves handlers your app added itself alone. - Never crashes the app over logging: if
log_dirisn't writable, it falls back to console-only with a warning instead of raising.
Scope — what this is NOT
log_setup produces clean, rotating, compressed, retention-managed, consistently
formatted files. It does not ship logs anywhere — no Loki/ELK/syslog/network
handlers. Getting files to a backend is a separate concern (e.g. Promtail tails
run.log → Loki → Grafana panels + alerting). Keeping shipping out means the log
backend can change without touching any app, and the consistent format here is what
makes downstream parsing and alerting easy.
Structured/JSON output is in as of v0.2.0 (output="json") — text and json only.
Still deliberately out: logfmt or other formats, a format DSL, per-handler formats,
color formatting, per-logger filters, remote handlers.
Versioning
Releases are tagged vX.Y.Z. The install line above pins a release; drop the @vX.Y.Z suffix to install the latest unpinned. Pin deliberately for reproducible installs.