log is an interactive TUI for mixed web/server logs:
- JSON lines most of the time
- plain text lines sometimes
- prefixed JSON like
POD | {"level":"info","message":"..."}as well
It accepts files, stdin, a streaming URL, or a spawned command.
- Left pane virtualized log list with follow mode
- Right pane JSON tree or raw text detail view
- Tabs for multiple file sources
- Merged view across all sources
- Ring buffer with configurable max size
- Batched ingest updates for fast streams
- Reverse ordering toggle
- Advanced filter mode with nested-field expressions and boolean logic
- Query mode with boolean expressions inspired by
hl - Handlebars main-line templates with fblog-style helpers
- Detail-pane search and copy actions inspired by
jless - Basic ANSI color preservation for text detail
- Help modal
- Summary JSON / text mode for automation and smoke testing
bun install
bun run src/cli.ts examples/mixed.logWhen passing CLI flags to bun run src/cli.ts, prefer inserting -- before
the tool arguments:
bun run src/cli.ts -- examples/mixed.log --merge --filter 'level = "error"'For day-to-day use, the wrapper binary is the least surprising:
./bin/log examples/mixed.log --mergeOpen a local file:
bun run src/cli.ts examples/mixed.logTail command output:
./bin/log --cmd "docker logs -f my-container 2>&1"Read a URL stream:
./bin/log --url https://example.com/logsSummarize mixed input as JSON:
bun run src/cli.ts examples/mixed.log --summary-jsonSummarize piped stdin as text:
cat examples/mixed.log | bun run src/cli.ts --summary-textStress-test command ingestion:
bun run build
bun run stress:cmdFiles:
bun run src/cli.ts server.log access.logFiles in merged view from startup:
./bin/log server.log access.log --mergeIf --merge is used with only one source, the session falls back to normal mode
and reports that merge was ignored in both the startup status line and the live
footer state.
Files in merged view with explicit reverse and no-follow startup:
./bin/log server.log access.log --merge --reverse --no-followFiles in merged view with startup filter and query:
./bin/log server.log access.log --merge --filter 'request.method = "POST"' --query 'level = "error"'Files in merged view with startup filter/query and zero-follow reverse mode:
./bin/log server.log access.log --merge --reverse --no-follow --filter message:error --query 'level = "error"'Files in merged view with the full startup control set:
./bin/log server.log access.log --merge --reverse --no-follow --filter message:line --query 'level = "unknown"'Those startup flags are reflected back into the live TUI session state:
- header summary includes merged/filter/query/reverse/nofollow context
- footer state includes merged session markers like
srcs:<n>,mflt:..., andmqry:...
stdin:
cat examples/mixed.log | bun run src/cli.tsURL stream:
bun run src/cli.ts -- --url https://example.com/logsCommand stream:
bun run src/cli.ts -- --cmd "docker logs -f my-container 2>&1"Installed binary:
./bin/log --cmd "docker logs -f my-container 2>&1"
./bin/log --url https://example.com/logsJSON summary:
bun run src/cli.ts examples/mixed.log --summary-jsonText summary:
bun run src/cli.ts examples/mixed.log --summary-text↑/↓orj/k: move selectionPgUp/PgDn: move by pageHome/End,g/G: jump top/bottomEnter: toggle focus into detail paneEsc: back to list / close modalSpace: fold/unfold JSON node in detail paneR: reverse orderingF: filter mode1..6: toggle quick level filters for trace/debug/info/warn/error/fatalQ: query editor mode/: detail search moden/N: next/previous detail search matchTab: next source tabShift+Tab: previous source tabM: toggle merged viewm: toggle detail mode (tree/raw)yy,yp,yk: copy current value, path, or key in JSON detail mode?: helpq: quit
F opens the filter bar. Type a filter expression, press Enter to apply it,
or Esc to cancel.
Supported operators:
- equality:
field = value,field != value - numeric comparisons:
duration_ms >= 100,size < 4096 - substring:
message ~= "timeout",message !~= "health" - wildcard like:
service like "api*" - regex:
message ~~= "timeout|retry" - boolean composition:
and,or,not, parentheses - existence:
exists(.user.id),not exists(.trace_id) - set membership:
level in ("warn","error"),service not in (db,cache) - optional path modifier:
.trace_id? = "missing-ok" - nested paths:
request.method = "GET" - array wildcards:
span.[].name = "db.query" - array indexes:
span.[1].name = "cache.hit"
Notes:
- Filters apply to structured JSON fields when a row is JSON.
- Text rows still support virtual fields like
message,level,prefix, andraw. - Legacy shorthand like
level:errorand bare substring filters still work.
Examples:
level = "error" and request.method = "POST"
exists(.user.id) and duration_ms >= 250
span.[].name like "db*"
message !~= "health" and level in ("warn","error")
.trace_id? = "missing-ok"Current query support includes:
- equality:
level = "error" - substring:
message like "timeout" - regex:
message =~ /health/ - existence:
exists(user.id) - membership:
level in ("warn","error") - boolean composition:
and,or,not
Examples:
level = "error" and service like "db"
exists(user.id) and level in ("warn","error")
not message =~ /health/log looks for config in this order:
--config /path/to/file.jsonc./.log.jsonc$HOME/.config/log/config.jsonc
Example config:
mainLineTemplate uses Handlebars and receives these variables:
timestamplevelmessageprefixjsonraw
Helpers:
boldredyellowgreencyanbluepurpleuppercasefixed_sizemin_sizelevel_style
Examples:
If NO_COLOR is set, color helpers emit plain text instead of ANSI escapes.
If message contains placeholders and config enables:
placeholderFormat: "#{key}"contextPath: "extra_data"
then a message like User #{user} logged in is substituted from
json.extra_data.user.
bun run typecheck
bun test
bun run test:e2e
bun run buildFor a quick high-volume ingest check:
bun run stress:cmdSee examples/mixed.log.
{ "maxEntries": 50000, "batchMs": 50, "mainLineTemplate": "{{level_style (uppercase level)}} {{message}} {{cyan prefix}}", "placeholderFormat": "#{key}", "contextPath": "extra_data", "levelMap": { "10": "trace", "20": "debug", "30": "info", "40": "warn", "50": "error" } }