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 build instantly tells me which step took 40 seconds.
  • Tailing logs with timestamps that aren’t there. logwrap tail -f /var/log/app.log adds 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

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.