Files
10Backward/docs/architecture/logging.md
T
2026-08-04 18:57:40 +00:00

127 lines
5.1 KiB
Markdown

---
type: architecture
title: Logging & Run Summary
description: Structured JSON logging in 10Backward — JsonFormatter, secret redaction, and the startup / error / run_complete event helpers.
tags: [logging, observability, json]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
`tenbackward.logging_setup` replaces the scaffold-era `basicConfig`
text logger with a **single-line JSON formatter** that downstream
log shippers can index. It also owns the three helpers the runner
uses to record structured events: `log_startup`, `log_error`,
`log_run_summary`.
# JsonFormatter
`JsonFormatter.format(record)` returns one JSON object per log record:
| Key | Source |
|------------|---------------------------------------------------------------------|
| `ts` | UTC `datetime.fromtimestamp(record.created).isoformat()`. |
| `level` | `record.levelname` (e.g. `INFO`, `ERROR`). |
| `logger` | `record.name` (always `tenbackward`). |
| `message` | `record.getMessage()`. |
| _extras_ | Any keyword passed via `logging.info(..., extra={...})` that is not a reserved `LogRecord` attribute and whose key is **not** in `_SECRET_KEYS`. |
| `exc_type` | When `record.exc_info` is set, the exception class name. |
Reserved `LogRecord` attributes and any key starting with `_` are
dropped before serialization, so the payload contains only the values
that callers intentionally attached.
## Secret Redaction
`_SECRET_KEYS` is the canonical denylist applied in two places:
1. **At format time** for keys passed via `extra={...}`.
2. **At helper time** by `_redact_dict(...)` so values reaching
`log_startup` / `log_error` never expose known-sensitive fields
even before they hit the formatter.
Deny-list contents (case-insensitive):
```
token, access_token, mastodon_access_token, password, secret,
authorization, api_key
```
Any matching key is replaced with the string `[REDACTED]`.
# Helper Functions
| Helper | When it fires | Logger / Level | `event` field |
|---------------------------------------|-----------------------------------------------------------------------|----------------|--------------------|
| `configure_json_logging(level=INFO)` | First line of `main.main()`. Idempotent — clears existing handlers. | n/a | n/a |
| `log_startup(**fields)` | After config load + `data_dir.mkdir`, before the pipeline pass. | `INFO` | `startup` |
| `log_error(event, *, exc=None, **fields)` | When a pipeline attempt raises, or when the retry budget is exhausted. | `ERROR` | the supplied event name; `exc_type` added when `exc` is provided. |
| `log_run_summary(scanned, matched, posted, skipped, posted_ids)` | Once per successful pass. **Silently skipped** when all counters are zero and no ids were posted. | `INFO` | `run_complete` |
## Startup payload
`log_startup` is invoked with:
```
version, site_url, run_at, tz, hashtags, throwback_prefix
```
Each is added to the `startup` event payload under its own key.
## Run-summary payload
`log_run_summary` is the canonical "what did the bot do today?" record:
```json
{
"ts": "2026-08-04T09:00:00+00:00",
"level": "INFO",
"logger": "tenbackward",
"message": "run complete",
"event": "run_complete",
"scanned": 0,
"matched": 0,
"posted": 0,
"skipped": 0,
"posted_ids": []
}
```
A pass with no candidates emits **no line at all**, matching the
"silent on no matches" contract.
# Examples
## Successful run with one new post
```json
{"ts":"2026-08-04T09:00:00+00:00","level":"INFO","logger":"tenbackward","message":"startup","event":"startup","version":"0.1.0","site_url":"https://blog.example.com","run_at":"09:00","tz":"Europe/Berlin","hashtags":"#throwback,#10backward","throwback_prefix":"Heute vor 10 Jahren:"}
{"ts":"2026-08-04T09:00:01+00:00","level":"INFO","logger":"tenbackward","message":"run complete","event":"run_complete","scanned":1,"matched":1,"posted":1,"skipped":0,"posted_ids":["2025-08-04-post-slug"]}
```
## Retry budget exhausted
```json
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_error","event":"pipeline_error","attempt":1,"max_attempts":4,"exc_type":"ConnectionError"}
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_error","event":"pipeline_error","attempt":2,"max_attempts":4,"exc_type":"ConnectionError"}
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_failed","event":"pipeline_failed","attempts":4,"exc_type":"ConnectionError"}
```
The container then exits with status `1`.
## Token accidentally logged
Even if a future call wrote `extra={"access_token": "..."}`, the
formatter replaces it:
```json
{ "...": "...", "access_token": "[REDACTED]" }
```
# Related
* [System Architecture](/architecture/system-overview.md)
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Config Schema](/architecture/config-schema.md)
* [Mastodon Publishing](/architecture/mastodon-publishing.md)