Skip to content

About

python logger helper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

LoggerHelper

A small logging package with no dependencies:

  • Coloured console output, plain text in files
  • One file per level under a dated directory, rotated by size and by day
  • A decorator that logs an escaping exception and names the definition it escaped from
  • Optional Slack alerts, attached only when a credential is configured

This README is the complete reference. The source carries no type annotations, so every signature and accepted value is written out below.


Contents


Install

pip install loggerhelper

Requires Python 3.9 or newer. (The code itself runs on 3.8 — logging's stacklevel= argument is the hard floor — but 3.8 is past end of life, so it is not a supported version.)


Quick start

from loggerhelper import log

log.info("System started")

That is the whole setup. Nothing is created on disk until the first line is written, so importing this costs nothing and no initialisation step is needed.

To choose where the files go:

from loggerhelper import configure

configure(log_dir="./logs")

Logging a message

Every method takes the same arguments as the standard library's, so anything you already know about logging applies.

log.debug("cache hit for key=%s", key)
log.info("System started")
log.warning("retry 2 succeeded, carrying on")
log.error("could not reach the queue")
log.critical("configuration is missing, shutting down")

Lazy formatting. Pass the arguments instead of building the string, and the formatting only happens if the record is actually emitted:

log.info("took %s ms for %s rows", elapsed, count)

Inside except. exception() is error() with the active traceback attached:

try:
    parse(row)
except ValueError:
    log.exception("bad row, skipping")

Choosing the level at runtime:

level = "WARNING" if recovered else "ERROR"
log.log(level, "upstream call finished badly")

Attaching the traceback yourself, or passing extra record fields:

log.error("upload failed", exc_info=exc)
log.info("request done", extra={"request_id": rid})

Each line records where it was called from:

[2026-08-11 09:14:02]-INFO-(service.pool.acquire:87)=> connection established
                            ^^^^^^^^^^^^^^^^^^^^^^ module.function:line

The error decorator

from loggerhelper import log

@log.catch
def handle(request):
    ...

An exception escaping handle is logged at ERROR with its traceback, and then re-raised — the caller still sees the failure. The message names the definition and the line that actually raised:

[2026-08-11 09:14:02]-ERROR-(service.pool.acquire:87)=> ConnectionRefusedError in service.pool.AdapterPool.acquire (pool.py:87): [Errno 111] refused

For a method, the name includes the class (AdapterPool.acquire). The line number is where the exception was raised, not where the decorator sits.

Options

@log.catch(level="CRITICAL")           # start-up paths, where failure ends the process
@log.catch(reraise=False)              # swallow it and return None
@log.catch(reraise=False, default=[])  # swallow it and return something else
@log.catch(exclude=(KeyError,))        # let these through unlogged
Argument Default Meaning
level "ERROR" Level to log the failure at
reraise True Re-raise after logging; False swallows and returns default
default None What to return when reraise=False
exclude () Exception types to re-raise without logging

Notes

  • async def works. The coroutine is awaited inside the guard.
  • One record, not two. The message and its traceback are a single log entry, so they cannot be interleaved with other lines and only fire one Slack alert.
  • KeyboardInterrupt and SystemExit are never caught.
  • Decorating does not build the logger, so it is safe at import time.
@log.catch
async def fetch(url):
    ...

Choosing a level

Pick by blast radius — what the failure costs — not by how important the function is. That is what makes the decorator's default correct without you deciding per function.

Level The event Example Slack
DEBUG Detail only useful while working on the code the SQL that was built, a cache hit no
INFO A normal lifecycle event service started, connection established no
WARNING Degraded, but this operation still succeeded a retry worked, a fallback was used yes
ERROR This operation failed; the service is still running an exception escaped a request handler yes
CRITICAL The process or service cannot continue required config missing, connection pool dead yes

What follows from this:

  • An unhandled exception escaping a function means that operation failed, so @log.catch defaults to ERROR.
  • Reserve level="CRITICAL" for start-up and other paths where the failure ends the process.
  • WARNING is for things that recovered. If it did not recover, it is an ERROR.
@log.catch(level="CRITICAL")
def load_config():          # if this fails there is no service
    ...

@log.catch
def handle(request):        # if this fails, this request failed
    ...

Configuring

Explicit argument wins, then the environment variable, then the default.

from loggerhelper import configure

configure(log_dir="./logs")                       # the common case
configure(log_dir="./logs", level="DEBUG")
configure(level="WARNING", retention_days=7)
configure(load_dotenv=True)                       # apply the nearest .env first

configure() accepts every setting in the table below, plus:

Argument Default Meaning
config None A ready-made LoggerConfig, instead of individual settings
env None Mapping to read instead of os.environ (useful in tests)
load_dotenv False Apply the nearest .env before reading settings

Call it before the first log line. Calling it afterwards is allowed — the handlers are rebuilt and a WARNING is written saying so, because earlier lines went somewhere else.

An argument of None means "not given" and falls through to the environment, so you can forward optional values without branching:

configure(log_dir=args.log_dir, level=args.log_level)   # None for either is fine

Environment variables

Everything is optional.

Variable Default Meaning
LOG_DIR <project root>/log Where the dated directories go
LOG_LEVEL INFO Threshold: DEBUG/INFO/WARNING/ERROR/CRITICAL
LOG_NAME unset Tag on every line; also separates one logger from another
LOG_MAX_BYTES 52428800 (50 MB) Per-file cap before rotation; 0 disables size rotation
LOG_BACKUP_COUNT 5 Rotated copies kept
LOG_RETENTION_DAYS 30 Dated directories older than this are deleted; 0 keeps everything
LOG_COLOR auto auto / always / never
NO_COLOR unset Any value disables colour (the cross-tool convention)
SLACK_WEBHOOK_URL unset Incoming webhook. Enables Slack. Must be https://
SLACK_BOT_TOKEN unset xoxb-… token. Enables Slack. The alternative to a webhook
SLACK_CHANNEL unset Which channel the bot token posts to. Required with it
SLACK_LOG_LEVEL WARNING Threshold for alerts
SLACK_ENV unset Deployment tag ("prod", "staging") shown on alerts

LOG_DIR is detected by walking up from the working directory for a .venv, pyproject.toml or .git, and putting log/ next to it — so an installed copy logs into your project, not into site-packages.

Colour is on when the stream is a terminal and off otherwise, so redirected output and CI logs stay free of escape codes.

Loading them from a .env file

export on every shell is tedious and does not survive a new terminal, a service restart or a colleague's machine. Put the values in a .env file next to your pyproject.toml instead.

Start from .env.example — it lists every variable with its default, and carries the Slack setup steps inline so none of them gets skipped:

cp .env.example .env
# .env  -- keep this out of version control
LOG_DIR=./logs
LOG_LEVEL=INFO
LOG_RETENTION_DAYS=14

SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN
SLACK_CHANNEL=#alerts
SLACK_ENV=prod

and ask for it once, at start-up:

from loggerhelper import configure

configure(load_dotenv=True)

The nearest .env walking up from the working directory is applied. Values already in the real environment win — so a container or CI that sets SLACK_ENV properly is not overridden by a stale file. Comments, blank lines, export prefixes and quoted values are all understood.

It is off unless you ask, because a library that edits os.environ while being imported surprises the application that imported it. If your project already loads a .env some other way, keep doing that and leave load_dotenv alone.

Add .env to your .gitignore. It holds a credential.


Slack alerts

Set a credential and alerts start. Set nothing and the handler is never attached — there is no cost and no code path taken.

Getting a credential

Slack has two ways in, and neither appears on the Basic Information → App Credentials page. The Client Secret, Signing Secret and Verification Token shown there are for the OAuth flow and for verifying requests Slack sends to you; none of them can post a message. Pick whichever of these fits your app:

A. Incoming webhook — fewest steps, one URL per channel.

  1. In your app, sidebar → Features → Incoming Webhooks
  2. Turn Activate Incoming Webhooks on
  3. Add New Webhook to Workspace, pick the channel, Allow
  4. Copy the URL: https://hooks.slack.com/services/T…/B…/…
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxxxxxx

B. Bot token — one credential for any number of channels.

  1. Sidebar → Features → OAuth & Permissions
  2. Under Scopes → Bot Token Scopes, add chat:write
  3. Install to Workspace (top of the same page), Allow
  4. Copy the Bot User OAuth Token: xoxb-…
  5. Invite the bot into the channel: /invite @your-app-name (appendix — this is the step people skip)
SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN
SLACK_CHANNEL=#alerts

Step 5 is easy to forget, and it is the most common reason a correct-looking setup delivers nothing: without it Slack answers not_in_channel and the alert is reported to stderr instead. Slack's UI writes the channel as # common with a space for looks — the name is common, so the value is #common.

Set one or the other. Setting both is reported as a problem, because they name two different destinations and silently picking one would be worse.

Configuring it

Anywhere the environment variables can come from — including a .env file, which is the usual answer:

SLACK_BOT_TOKEN=xoxb-...
SLACK_CHANNEL=#alerts
SLACK_LOG_LEVEL=WARNING   # optional, this is the default
SLACK_ENV=prod            # optional tag shown on the alert

Or in code:

configure(slack_bot_token="xoxb-...", slack_channel="#alerts", slack_level="ERROR")
configure(slack_webhook_url="https://hooks.slack.com/services/...")

An alert carries the level, the message, the origin, the deployment tag and the traceback, coloured by severity.

What it does for you

  • Never blocks. Sending happens on a background thread behind a bounded queue, so a slow or dead webhook cannot slow the application down. If the queue fills up, records are dropped rather than growing memory.
  • Does not flood the channel. Repeats of the same failure (same level, same origin, same exception type) within 60 seconds are collapsed, and the next alert reports how many were folded in. There is also a cap of 20 alerts per minute.
  • Never raises. A failing webhook is reported to stderr once and then ignored.
  • Notices silent rejections. chat.postMessage answers 200 OK with {"ok": false} for a bad channel or a missing scope; that is treated as the failure it is, not as a delivered message.
  • https:// only, and credentials never appear in a repr() or in an error message, so neither can leak into a log file.

If the Slack settings are wrong

Alerting is an optional extra, so a mistake in it never stops the program from logging. Slack is switched off and the reason is written once, at CRITICAL, to the console and to CRITICAL.log:

[2026-08-11 09:14:02]-CRITICAL-(...)=> Slack alerts are disabled: SLACK_BOT_TOKEN
does not look like a bot token; it should start with 'xoxb-'. The Basic
Information page has no bot token on it -- find it under OAuth & Permissions ->
Bot User OAuth Token

This applies to the credential being malformed, both credentials being set at once, a token without a channel, and a non-https webhook. It deliberately does not apply to LOG_LEVEL or LOG_DIR, where there is nothing sensible to fall back to and the error is raised.

config.slack_problem() returns that sentence, or None when Slack is usable, if you would rather check at start-up yourself.


More than one logger

from loggerhelper import Logger

audit = Logger(log_dir="./audit", name="audit")
audit.info("user 42 exported the customer table")

Logger(...) takes the same settings as configure(...). Instances are keyed by name: two Logger() objects with the same name share one set of handlers, which is what stops the same line being written twice when a module is imported more than once. Give a distinct name to a logger that should write somewhere else.

logger = Logger(log_dir="./logs", level="DEBUG", name="worker")
logger.close()   # release the files; logging again rebuilds

What ends up on disk

log/
└── 2026-08-11/
    ├── DEBUG.log
    ├── INFO.log
    ├── WARNING.log
    ├── ERROR.log
    └── CRITICAL.log
  • Each file holds only its own level — an ERROR never appears in INFO.log.
  • Only levels that were actually used get a file.
  • The date is decided when a record is written, so a process that outlives midnight rolls into the new directory.
  • A file over LOG_MAX_BYTES rotates to INFO.log.1, INFO.log.2, up to LOG_BACKUP_COUNT copies.
  • Directories older than LOG_RETENTION_DAYS are deleted at rollover. Only directories named exactly YYYY-MM-DD are touched.

Full API

Everything importable from loggerhelper:

from loggerhelper import log, Logger, configure, LoggerConfig, ConfigError

log

The shared logger, ready to use. An instance of Logger.

Logger(log_dir=None, level=None, name=None, config=None, env=None, **settings)

Method Purpose
debug(msg, *args, **kw) Development detail
info(msg, *args, **kw) Normal lifecycle event
warning(msg, *args, **kw) Degraded but recovered
error(msg, *args, **kw) This operation failed
critical(msg, *args, **kw) Cannot continue
exception(msg, ...) error with the active traceback
log(level, msg, ...) Level chosen at runtime, by name
catch(func=None, level="ERROR", reraise=True, default=None, exclude=()) The decorator
close() Release files and stop the Slack thread
reconfigure(config=None, env=None, **settings) Change settings after construction
config Property: the resolved LoggerConfig; reading it builds nothing
warn(msg, ...) Deprecated alias for warning

All message methods accept *args for lazy % formatting, and exc_info=, extra=, stack_info=, stacklevel= as in the standard library.

configure(log_dir=None, level=None, name=None, config=None, env=None, load_dotenv=False, **settings)

Applies settings to the shared log and returns it.

LoggerConfig(...)

LoggerConfig(
    log_dir=None, level="INFO", name=None,
    max_bytes=52428800, backup_count=5, retention_days=30, color="auto",
    slack_webhook_url=None, slack_bot_token=None, slack_channel=None,
    slack_level="WARNING", slack_env=None,
)

A validated settings object. Build one directly if you want to share the same settings between loggers:

from loggerhelper import Logger, LoggerConfig

settings = LoggerConfig(log_dir="./logs", level="DEBUG", retention_days=7)
worker = Logger(config=settings, name="worker")
audit = Logger(config=settings, name="audit", log_dir="./audit")

Settings passed alongside a config are applied on top of it, so one config can act as the base for several loggers that differ in a detail.

Member Purpose
LoggerConfig.from_env(env=None, **overrides) Build from the environment, overrides winning
config.replace(**changes) A copy with some settings changed; None means "leave it"
config.slack_enabled Whether Slack will actually be used
config.slack_problem() Why it will not, as a sentence, or None
LoggerConfig.SETTINGS The names of every accepted setting

repr() masks the Slack credentials, so a config is safe to log or to include in a traceback.

ConfigError

Raised for a setting that cannot be used — an unknown level, a non-numeric size, a misspelled setting name. Subclasses ValueError; the message names the setting, lists the valid values, and suggests the intended spelling for a near miss (unknown setting(s): 'load_env' (did you mean 'load_dotenv'?)).

Names are checked by Logger() and configure() immediately, not when the settings are finally resolved — settings resolve lazily, so otherwise a typo would surface at the first log call and point the traceback at a line that is perfectly correct.

Slack problems are the exception: they are reported rather than raised, as described above.


Upgrading from 1.x

1.x 2.x Note
logs = Logger() from loggerhelper import log Or keep constructing your own; both work
logs.warn(msg) log.warning(msg) warn still works, with a DeprecationWarning
@logs.error @log.catch @logs.error still works, with a DeprecationWarning
logs.error("message") log.error("message") This raised in 1.x — the name belonged to the decorator

Three behaviour changes to know about:

  • @log.catch re-raises. @logs.error swallowed the exception and returned None, which is indistinguishable from a real result. The deprecated @logs.error keeps the old behaviour; @log.catch(reraise=False) is the explicit way to ask for it.

  • The (a3f1) field is gone. It was four hex characters of uuid4, constant for the life of the process and carrying nothing a single-process program could use. It is now module.function:line of the call site.

  • Log files are created on demand. 1.x created all four files, empty, while the Logger was being constructed. A level that is never used now has no file at all. Code that asserted on an empty file needs to allow for its absence:

    assert not path.exists() or not path.read_text()   # nothing logged at this level

Development

uv sync --dev
uv run pre-commit install
uv run pytest

src/loggerhelper/logger.py is the only implementation module a consumer needs to read. Everything under src/loggerhelper/_internal/ is detail and may change between releases.


Deployment

Build the distributions into dist/:

uv build

That writes both dist/loggerhelper-<version>-py3-none-any.whl and dist/loggerhelper-<version>.tar.gz.

Note — building without uv

The build backend is hatchling, so any PEP 517 front end works:

python -m build

python -m build needs pip install build first. uv build needs nothing installed and is the same output, so prefer it unless you have a reason not to.

Clear dist/ between builds — both commands add to it rather than replacing it, so old versions linger and get uploaded by accident.

Check the wheel actually imports before releasing it, from an environment that has nothing else in it:

uv run --no-project --isolated --with dist/loggerhelper-2.0.0-py3-none-any.whl python -c "import loggerhelper; print(loggerhelper.__version__)"

Releasing

Releases are cut by pushing a tag. .github/workflows/release.yml does the rest: it checks the tag against __version__, runs the tests, builds, verifies the wheel imports from a clean environment, and publishes a GitHub Release with both distributions attached.

# 1. bump __version__ in src/loggerhelper/__init__.py, commit, merge to main
# 2. tag the commit on main and push the tag
git tag -a v2.0.0 -m "v2.0.0"
git push origin v2.0.0

The tag must be v + exactly what __version__ says, or the workflow stops before building — a tag can point at any commit, so nothing else guarantees the version in the artefact matches the version in the tag.

The release page then carries:

Asset What it is
loggerhelper-2.0.0-py3-none-any.whl The wheel — this is what you install
loggerhelper-2.0.0.tar.gz The sdist
Source code (zip) / (tar.gz) Added by GitHub automatically; a snapshot of the repo, not a built package

Only the first two are built artefacts. The two GitHub adds are archives of the repository at that tag, and installing from them means building from source.

Install a released wheel straight from GitHub:

pip install https://github.com/Marcus778899/LoggerPackage/releases/download/v2.0.0/loggerhelper-2.0.0-py3-none-any.whl

Publishing to an index

Not part of the workflow, since it needs a token:

uv publish

To consume it from another project without a package index, point at the wheel directly — this is what Database_connector_mcp does:

[tool.uv.sources]
loggerhelper = { path = "lib/loggerhelper-2.0.0-py3-none-any.whl" }

__version__ in src/loggerhelper/__init__.py is the only place the version is written — pyproject.toml declares it dynamic and reads it from there, so the built artefact and loggerhelper.__version__ cannot disagree, and the release workflow checks the tag against it.


Appendix: the bot must be in the channel

If you use SLACK_BOT_TOKEN, adding the app to the channel is a required step. Without it nothing is delivered, no matter how correct the settings are.

This is the single most common reason a working-looking setup stays silent. Everything checks out — the token is valid, chat:write is granted, the channel name is right — and Slack still refuses, because a bot may only post where it has been invited.

Do this

In the channel you want alerts in, type:

/invite @your-app-name

Use the app's name as it appears in Slack (the bot user, e.g. @loggerhelper), not the app ID. Repeat it for every channel you point SLACK_CHANNEL at.

For a private channel you must invite the bot as well, and the token needs the groups:write scope on top of chat:write.

An incoming webhook does not need any of this — the channel was chosen when the webhook was created, and access came with it. If you would rather not manage invitations, use SLACK_WEBHOOK_URL instead.

How it looks when this is the problem

The application keeps running and keeps logging normally; only the alert is lost. It is reported once on stderr:

loggerhelper: Slack notification failed, staying quiet from here:
RuntimeError: Slack rejected the message: 'not_in_channel'

not_in_channel is Slack's way of saying exactly this. Note that chat.postMessage returns HTTP 200 for it — the failure is in the response body, not the status code, which is why a hand-rolled webhook client usually reports success here and this one does not.

Telling the failures apart

Slack says What it means Fix
not_in_channel The bot is not in that channel /invite @your-app-name in the channel
channel_not_found No channel by that name Check SLACK_CHANNEL; see the note below
missing_scope The token lacks chat:write Add the scope, then reinstall the app
invalid_auth The token is wrong or revoked Copy the Bot User OAuth Token again
is_archived The channel is archived Point at a live channel

Adding a scope does not take effect until the app is reinstalled to the workspace, and reinstalling issues a new token — remember to update SLACK_BOT_TOKEN.

The # common trap

Slack's UI renders a channel as # common, with a space after the hash, purely for looks. The name is common. Copying what you see gives you a value with a space in it, and Slack answers channel_not_found because no channel can contain a space.

SLACK_CHANNEL=#common      # right
SLACK_CHANNEL=# common     # wrong: that space is Slack's typography

The channel ID (C0123456789, from Copy link on the channel) also works and avoids the question entirely. It keeps working if the channel is renamed.

License

MIT

About

python logger helper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages