I have a recurring frustration: I run a script in CI, it produces output, something fails, and I have no idea when any of it happened. Did the failure come 200ms after start or two minutes in? Was it stdout or stderr? It’s the difference between “took a minute to figure out” and “took half an hour.”
The fix is to prefix every line with a timestamp. Easy in theory, annoying in practice — especially if you want the prefix to differ between stdout and stderr, or if you want UTC, or colors, or to drop the prefix in production. So I wrote logwrap: a small Go binary you put in front of any command and it adds a configurable prefix to every line, in real time, without buffering.
The minimal example
logwrap echo "Hello World"
# [2024-01-15 10:30:45] [INFO] [user:1234] Hello World
That’s the default template. stdout becomes [INFO], stderr becomes [ERROR], both get a timestamp and the user/PID of the wrapper:
logwrap sh -c "echo 'Success'; echo 'ERROR: Failed' >&2"
# [2024-01-15 10:30:45] [INFO] [user:1234] Success
# [2024-01-15 10:30:45] [ERROR] [user:1234] ERROR: Failed
Custom templates
The prefix is a Go text/template string. The four variables are Timestamp, Level, User, PID:
# Just timestamps, please
logwrap -template "[{{.Timestamp}}] " ls -la
# Level first, then timestamp
logwrap -template "{{.Level}}: {{.Timestamp}} - " echo "Hello"
# UTC and colors on
logwrap -colors -utc make test
Timestamp formatting uses strftime (the Linux date style) rather than Go’s reference-time idioms. So %Y-%m-%d %H:%M:%S actually works the way your fingers expect:
prefix:
timestamp:
format: "%Y-%m-%dT%H:%M:%S%z" # 2024-01-15T14:30:45-0700
utc: false
That alone was worth writing.
Configuration files
For anything beyond a one-off, drop a YAML file at ./logwrap.yaml, ~/.config/logwrap/config.yaml, or pass -config path/to/file.yaml:
prefix:
template: "[{{.Timestamp}}] [{{.Level}}] [{{.User}}:{{.PID}}] "
timestamp:
format: "%Y-%m-%d %H:%M:%S"
utc: false
colors:
enabled: false
info: "green"
error: "red"
timestamp: "blue"
user:
enabled: true
format: "username" # username, uid, or full
pid:
enabled: true
format: "decimal" # decimal or hex
log_level:
default_stdout: "INFO"
default_stderr: "ERROR"
detection:
enabled: true
keywords:
error: ["ERROR", "FATAL", "PANIC"]
warn: ["WARN", "WARNING"]
debug: ["DEBUG", "TRACE"]
info: ["INFO"]
The level-detection block is the bit that earns its keep over a naive awk-prefix-pipe: lines containing WARN get the WARN level even if they came in via stdout. It’s not magic — it’s a substring match — but it lines up surprisingly often with how real applications log.
Where it actually shines
A few patterns I keep coming back to:
- Long-running builds.
logwrap make buildinstantly tells me which step took 40 seconds. - Tailing logs with timestamps that aren’t there.
logwrap tail -f /var/log/app.logadds them. - Wrapping legacy scripts in CI. A 300-line bash script that someone wrote in 2014 and nobody wants to touch — wrap it in logwrap, get structured output, ship it.
Privacy in shared environments
The default template includes the username and PID. If you’re piping logs to a shared dashboard or a public CI feed, that’s information you probably don’t want to leak. The repo ships a public-safe.yaml example with user and PID disabled, and you can do the same on the CLI:
logwrap -template '[{{.Timestamp}}] {{.Level}}: ' -- some-command
The README has a fuller security section worth reading if you’re going to use this in production logs. (TL;DR: it’s a wrapper, not a sandbox. It doesn’t sanitize what the wrapped command does.)
Install
go install github.com/sgaunet/logwrap/cmd/logwrap@latest
Or grab a release binary.
Where to find it
- Source: github.com/sgaunet/logwrap
- License: MIT
It’s the kind of tool you don’t realize you need until you’ve spent an afternoon staring at undated CI logs. Then you install it everywhere.