LogMerge

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.

≈1 GB/sfrom disk on an M1 Max
0allocations in the hot path
8archive formats read in place
Three log files merged into one chronological stream

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, prefetch, merge on a min-heap, format and write
  1. 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.

  2. 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.

  3. 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.

  4. Format and write

    Timestamp, level and alias prefixes added and originals stripped as asked, the --since/--until window 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 logmerge

Every 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

FlagEffect
-i, --inInput file or directory; repeatable, added to positional args
-o, --outOutput file (default: stdout)
-t / -sPrepend a normalised timestamp / strip the original one
--write-level / --strip-levelAdd a normalised level column / remove the original token
-b / -aHeader at each file switch / alias on every line
--alias glob=nameName a file or glob; repeatable
-e pattern, --ignore-fileGitignore-style excludes; ! negates
--ignore-archivesSkip .zip .gz .tar .rar .7z .tgz .bz2 .tbz2 .xz .txz
--since / --untilKeep only lines inside the window (RFC 3339)
--dry-run, --follow-symlinksList matched files without merging; follow links while walking
--config pathYAML file as the base; flags override it