Command-line tool · Go · MIT
Every log file of the job, in one timeline.
Driver logs, executor logs, sidecars, rotated files and the .gz archives — LogMerge reads them all at once and writes every line in timestamp order, still traceable to its file, stack traces still attached to the line that raised them. About 1 GB/s from disk.
A distributed job leaves its story scattered across a dozen files. Reading them one at a time means reconstructing the timeline in your head. LogMerge writes the timeline.
Point it at anything
Files, directories, globs. .gz, .bz2, .xz, .zip and .tar.* are read in place — no extraction step, and an entry inside an archive is addressable by name.
Timestamps in many shapes
ISO 8601 with T or a space, ctime as syslog and the JVM write it, date-time-millis. All normalised to one width on the way out.
Orphan lines stay put
A stack trace or a wrapped message has no timestamp of its own, so it follows the line before it instead of drifting to the top or the bottom.
Know where a line came from
A per-line alias with -a, or a --- file --- header at each switch with -b; glob-to-name aliases keep long paths short.
Fast enough to forget
A min-heap merge with parallel prefetch, vectorised newline scanning, a branch-predictable parser and profile-guided builds — nothing allocates per line.
Flags or YAML
Everything is a flag; a YAML file is the base for setups you run often, and flags override it.
How a merge runs
Four stages, and the heap is the whole trick.
- Discover
Every path is walked, exclude patterns and aliases applied, archives opened in place. An entry inside an archive is a virtual file,
archive.tar.gz!/entry/name.log. - Prefetch, in parallel
A goroutine per file parses the first timestamp so the merge starts with a full heap. Files with no parseable timestamp are reported and skipped; no shared lock anywhere.
- Merge on a min-heap
One entry per open file, keyed by its next line's timestamp, stored beside the file pointer. Pop the earliest, stream it until its next line would overtake the heap, push back — O(log k) per switch.
- Format and write
Timestamp, level and alias prefixes added and originals stripped as asked, the
--since/--untilwindow applied, buffered writes to stdout or a file.
Install
Pre-built binaries for Linux, macOS and Windows on amd64 and arm64, or Go 1.21+.
Binary
# Linux amd64
curl -L -o logmerge https://github.com/mmdemirbas/logmerge/releases/latest/download/logmerge-linux-amd64
chmod +x logmerge
# macOS Apple Silicon
curl -L -o logmerge https://github.com/mmdemirbas/logmerge/releases/latest/download/logmerge-darwin-arm64
chmod +x logmergeEvery release is on GitHub Releases.
From source, then a first run
go install github.com/mmdemirbas/logmerge/cmd/logmerge@latest
logmerge ./logs -o merged.log # everything under ./logs
logmerge -t -b /var/log/app1 /var/log/app2 | less
logmerge --dry-run /var/log/myapp # list what would be merged-t normalises timestamps, -b marks each switch between files.
What comes out
Interleaved files, a header per block, and the stack trace kept under the line that produced it.
--- auth-service.log ---
2026-03-13 01:21:22.000000000 [INFO] User "admin" logged in
2026-03-13 01:21:22.105000000 [DEBUG] Initializing session cache...
--- database.log ---
2026-03-13 01:21:22.500000000 [INFO] Query: SELECT * FROM users WHERE id = 1
2026-03-13 01:21:22.505000000 [ERROR] Connection timeout:
at db.connector.connect (connection.go:45)
at main.main (main.go:12)
caused by: context deadline exceeded
--- auth-service.log ---
2026-03-13 01:21:23.000000000 [INFO] Session expired for user "guest"
Flags
| Flag | Effect |
|---|---|
-i, --in | Input file or directory; repeatable, added to positional args |
-o, --out | Output file (default: stdout) |
-t / -s | Prepend a normalised timestamp / strip the original one |
--write-level / --strip-level | Add a normalised level column / remove the original token |
-b / -a | Header at each file switch / alias on every line |
--alias glob=name | Name a file or glob; repeatable |
-e pattern, --ignore-file | Gitignore-style excludes; ! negates |
--ignore-archives | Skip .zip .gz .tar .rar .7z .tgz .bz2 .tbz2 .xz .txz |
--since / --until | Keep only lines inside the window (RFC 3339) |
--dry-run, --follow-symlinks | List matched files without merging; follow links while walking |
--config path | YAML file as the base; flags override it |