Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Copy this file to `.env` and fill in what you need, then call
# `configure(load_dotenv=True)` once at start-up. Every line here is optional --
# delete the ones you do not care about.
#
# cp .env.example .env
#
# `.env` is gitignored. This file is not: keep real credentials out of it.
# Values already set in the real environment win over this file, so a container
# or CI can override anything below without editing it.

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

# Where the dated directories go. Defaults to <project root>/log.
LOG_DIR=./log

# DEBUG / INFO / WARNING / ERROR / CRITICAL
LOG_LEVEL=INFO

# Tag on every line. Also what separates one logger from another; set it when
# more than one service writes into the same place.
# LOG_NAME=etl-worker

# Per-file size cap before rotation, in bytes. 0 disables size rotation.
# LOG_MAX_BYTES=52428800

# How many rotated copies to keep.
# LOG_BACKUP_COUNT=5

# Dated directories older than this are deleted. 0 keeps everything forever.
LOG_RETENTION_DAYS=30

# auto / always / never. `auto` colours a terminal and nothing else, so piped
# output and CI logs stay free of escape codes.
# LOG_COLOR=auto

# ---------------------------------------------------------------------------
# Slack alerts -- entirely optional
# ---------------------------------------------------------------------------
#
# Leave both credentials unset and no Slack handler is attached at all.
# Set ONE of them; setting both is a mistake and will be reported, because a
# webhook and a token name different destinations.
#
# Neither credential is on the Basic Information page. The Client Secret,
# Signing Secret and Verification Token shown there cannot post a message.

# --- Option A: incoming webhook -------------------------------------------
#
# 1. Sidebar -> Features -> Incoming Webhooks
# 2. Turn "Activate Incoming Webhooks" on
# 3. "Add New Webhook to Workspace", pick the channel, Allow
# 4. Copy the URL below
#
# The channel is baked into the URL, so there is nothing to invite.
#
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxxxxxx

# --- Option B: bot token ---------------------------------------------------
#
# 1. Sidebar -> Features -> OAuth & Permissions
# 2. Scopes -> Bot Token Scopes -> add `chat:write`
# 3. "Install to Workspace" at the top of that page, Allow
# 4. Copy the "Bot User OAuth Token" (starts with xoxb-) below
# 5. >>> In the channel itself, type: /invite @your-app-name <<<
#
# STEP 5 IS THE ONE EVERYONE FORGETS. Without it Slack answers
# `not_in_channel` and nothing is delivered, however correct the rest is.
# Repeat it for every channel you point SLACK_CHANNEL at. A private channel
# also needs the `groups:write` scope.
#
# Adding a scope only takes effect after reinstalling the app, and reinstalling
# issues a NEW token -- come back and update this value.
#
# SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN

# Required with SLACK_BOT_TOKEN, ignored with a webhook.
# Slack's UI draws the name as "# common" with a space, for looks. The name is
# `common`, so write `#common`. A channel ID (C0123456789, from the channel's
# "Copy link") also works and survives a rename.
# SLACK_CHANNEL=#alerts

# Alerts fire at this level and above. WARNING is the default.
# SLACK_LOG_LEVEL=WARNING

# Free-form deployment tag shown on each alert.
# SLACK_ENV=prod
140 changes: 128 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 webhook is configured
- 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.
Expand All @@ -28,6 +28,7 @@ so every signature and accepted value is written out below.
- [Upgrading from 1.x](#upgrading-from-1x)
- [Development](#development)
- [Deployment](#deployment)
- [Appendix: the bot must be in the channel](#appendix-the-bot-must-be-in-the-channel)

---

Expand Down Expand Up @@ -270,7 +271,14 @@ output and CI logs stay free of escape codes.

`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:
to your `pyproject.toml` instead.

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

```bash
cp .env.example .env
```

```dotenv
# .env -- keep this out of version control
Expand Down Expand Up @@ -334,17 +342,21 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxxxxxx
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](#appendix-the-bot-must-be-in-the-channel) — this is the step
people skip)

```dotenv
SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN
SLACK_CHANNEL=#alerts
```

Step 5 is easy to forget — without it Slack answers `not_in_channel` and the
alert is reported to stderr rather than delivered.
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 refused, because they mean two different
destinations and silently picking one would be worse than saying so.
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

Expand Down Expand Up @@ -383,9 +395,29 @@ traceback, coloured by severity.
- **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()` — an
`http://` webhook is rejected rather than putting the secret on the wire in
clear text.
- **`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.

---

Expand Down Expand Up @@ -500,7 +532,8 @@ act as the base for several loggers that differ in a detail.
| ------------------------------- | ------------------------------------------------------- |
| `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 a Slack credential was configured |
| `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
Expand All @@ -509,8 +542,17 @@ in a traceback.
### `ConfigError`

Raised for a setting that cannot be used — an unknown level, a non-numeric size,
an `http://` webhook. Subclasses `ValueError`, and the message names the setting
and lists the valid values.
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](#if-the-slack-settings-are-wrong).

---

Expand Down Expand Up @@ -607,6 +649,80 @@ That is the only place it is written — `pyproject.toml` declares the version
`dynamic` and reads it from there, so the built artefact and
`loggerhelper.__version__` cannot disagree.

---

## 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.

```dotenv
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
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from loggerhelper import configure, log

configure(log_dir="./log", level="DEBUG")
configure(log_dir="./log", level="DEBUG", load_dotenv=True)

log.debug("cache hit for key=42")
log.info("System started")
Expand Down
6 changes: 2 additions & 4 deletions src/loggerhelper/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"""
Implementation detail. Nothing here is part of the public API.

Consumers import from ``loggerhelper`` (or ``loggerhelper.logger``); the layout
inside this package may change in any release. Modules here may import each
other, but must never import ``loggerhelper.logger`` -- the dependency runs one
way, outward.
Consumers import from ``loggerhelper``; the layout inside may change in any
release. Modules here may import each other, never ``loggerhelper.logger``.
"""
Loading
Loading