
bashlog, predictable logging with explicit redaction
7 min read
There is a point in many shell projects where printf >&2 starts accumulating policy.
The first message is straightforward. Then there are severity levels, quiet and debug modes, timestamps, terminal colors, machine-readable output, tags, and the question that tends to arrive after the first sensitive value appears in a log: how do we make sure that value does not leave the process through the logging path?
I wanted a reusable answer to that problem, but I did not want a logging library that took over the application around it. I did not want traps, shell-option changes, hidden network behavior, automatic environment discovery, generic functions named info and error, or a transport stack pretending every Bash program needed to become a logging daemon.
That became bashlog, a sourceable pure-Bash logging library with a deliberately small runtime boundary.
The project is still marked pre-release as I write this, so I am describing the current architecture and public contract rather than implying a finished 1.0 API. The boundaries are already the part I find most interesting.
What it looks like
A caller sources a built or pinned bashlog artifact and uses namespaced logging functions:
source "${project_root}/vendor/bashlog.bash"
bashlog_info "Application started."
bashlog_warning 'Configuration file %s is deprecated' "${config_file}"
bashlog_error 'Request %s failed with status %s' "${request_id}" "${status}"Routine log records go to standard error. Standard output remains available for application data, pipelines, and command substitution.
That sounds like a small decision, but it establishes an important compositional boundary. A command can produce useful data on standard output while bashlog reports diagnostics independently on standard error.
Human when interactive, logfmt when captured
The default format is auto, and auto means one specific thing:
stderr is a TTY -> human rendering
stderr is not a TTY -> deterministic logfmtAn interactive message may look like:
info: Application started.The same call with redirected or captured standard error becomes:
level=info msg="Application started."bashlog does not inspect whether it is running under systemd, Docker, Kubernetes, GitHub Actions, Jenkins, or some other environment and then guess what the caller must want. Terminal presence is the whole automatic decision.
The format can also be selected explicitly as human or logfmt. Machine-oriented logfmt output is deterministic and contains no bashlog-owned ANSI styling.
Conventional severity without taking over control flow
bashlog uses the conventional eight syslog severity levels:
emergency
alert
critical
error
warning
notice
info
debugThe default threshold is info. A caller can change it explicitly:
bashlog_level_set warning
bashlog_info 'suppressed by threshold'
bashlog_error 'emitted'Threshold suppression returns success. bashlog decides whether a record is eligible for logging; it does not decide whether the application should exit, retry, throw away state, install an ERR trap, or otherwise change control flow.
Those remain application decisions.
That is also why the public API uses bashlog_* names. Convenient generic names such as info, warn, error, and die belong to the caller. If an application wants them, it can create its own wrappers with exactly the semantics that application needs.
Optional timestamps, tags, and styling
The default presentation state is intentionally restrained:
format=auto
timestamp=off
color=autoTimestamps can be enabled in UTC or local time without invoking an external date command:
bashlog_timestamp_set utcLogging calls can also carry repeatable tags:
bashlog_warning --tag api --tag retry 'request delayed'Human rendering keeps them readable:
warning [api] [retry]: request delayedwhile logfmt keeps them structured:
level=warning tag=api tag=retry msg="request delayed"Human severity styling is configurable by symbolic color and intensity rather than accepting arbitrary ANSI sequences. Styling applies only to the severity signifier and resets immediately afterward, so color does not bleed into caller content.
None of these features changes the core logging boundary. They are presentation metadata, not an invitation for bashlog to discover application state on its own.
Logging is a data-egress boundary
The feature that pushed bashlog beyond a formatting helper is redaction.
A log record is data leaving one part of a program for another observer. That makes logging a data-egress boundary, and sensitive values deserve deliberate handling before they cross it.
I did not want bashlog to pretend it could discover secrets automatically. A library cannot reliably know whether a particular UUID, account number, token, path, customer name, or arbitrary string is sensitive in the caller’s domain.
Redaction policy therefore belongs to the developer.
A caller can register an exact secret in a named context:
bashlog_redaction_add auth fixed \
"${token}" \
'[REDACTED TOKEN]'Then explicitly select that context for a logging call:
bashlog_info --context auth \
'Request failed with token=%s' \
"${token}"A logging call without --context performs no logger-managed redaction. Merely registering a context does not cause it to become ambient global policy.
That explicitness is important. It keeps the relationship between the data and the policy visible at the call site.
Once selected, redaction becomes an obligation
Redaction is opt-in, but selected redaction is not best-effort.
Once a caller explicitly asks bashlog to use a redaction context, the library treats honoring that policy as a security obligation for that operation. The caller message and tags are transformed and checked, the completed rendered candidate is checked again immediately before emission, and a remaining match or matcher failure suppresses the protected record rather than knowingly emitting a candidate that violates the active rule.
That is the fail-closed part of the design.
bashlog supports three matcher types:
fixed
glob
erefixed is the strongest choice for a known exact secret because every character is literal. glob and ere exist for cases where pattern semantics are actually required.
Replacement text is always literal. It does not become shell code, a regular expression backreference language, glob syntax, or another layer of evaluation. The implementation deliberately rejects eval and generated-code tricks in the security-critical path.
Pure Bash means a smaller runtime boundary
At runtime, bashlog targets Bash 4.3 or newer and uses Bash language facilities and builtins only. Sourcing, formatting, timestamps, redaction, rendering, verification, and emission do not invoke external commands.
That is a scope decision rather than an argument that external commands are inherently bad. Every external runtime dependency adds availability, behavior, version, locale, process, and error-handling questions to a library that is meant to be sourceable into another program.
Development and release tooling can use whatever tools the project needs. The consumer runtime boundary stays smaller.
What bashlog does not promise
Security-related libraries can become dangerous when their claims grow faster than the mechanisms behind them, so bashlog documents several non-promises directly.
It does not claim automatic secret discovery, secure memory erasure, protection from caller-side set -x, protection from hostile code already running in the same Bash interpreter, Unicode normalization, embedded-NUL support, or direct syslog, journald, file, or network delivery.
Transport and persistence belong to the environment consuming standard error. Application policy belongs to the application. Redaction policy belongs to the developer. bashlog owns the logging behavior inside that boundary and tries to make those promises precise enough to test.
That approach is reflected throughout the project: Architecture Decision Records capture durable reasoning, a normative specification defines observable public behavior, Doxygen comments document implementation contracts beside the source, and the same behavior/security suite runs against the development, ordinary, and minified release artifacts.
bashlog is available on GitHub, along with its specification, engineering philosophy, threat model, Architecture Decision Records, tests, and build tooling.
Taken together, mktext, bash-doxygen, bashdeps, and bashlog are an experiment in a kind of tooling I keep finding useful: small components with explicit boundaries that can be composed into larger Bash projects without requiring each component to understand the entire application around it.