Berserk Docs

Following new rows

Live tail in the terminal with bzrk search --follow, how it finds new rows, and its limits

bzrk search --follow (-f) is Live tail for the terminal. It prints the query's result, then keeps printing new rows as they arrive, until you press Ctrl-C.

bzrk search -f "default | where resource['service.name'] == 'checkout' and severity_number >= 17"
2026-10-02T10:49:15.496Z ERROR checkout payment declined: card expired
2026-10-02T10:49:16.353Z ERROR checkout payment declined: insufficient funds

How it works

Follow runs the query again and again over the same window, the way Live tail does in the UI:

  • A run starts two seconds after the previous one started, and never sooner than one second after it finished.
  • With no --since and no time bound in the query, the window is the last hour, as for any search. A relative --since such as "5m ago" moves with now on every run. An absolute --since keeps its start, so the window grows. A fixed --until keeps the window closed, so follow prints only rows that arrive late inside it.
  • Each run prints the rows the previous run did not return, oldest first by timestamp.

The first result

The first run prints the query's result as it is. A query without a limit returns its newest 100 rows (see Implicit Result Limit), so follow starts like a tail. --tail N prints at most the last N of them; --tail 0 prints only rows that arrive after you start.

Asking only for new rows

What each later run asks for depends on the query.

A query that filters and reshapes rows — made only of where, extend, project, project-away, parse, parse-where, mv-expand, search, union, sort, and serialize, with scalar functions that read only the current row — does not scan its window again. Every complete result carries the server's ingest watermark: every row ingested at or before that time is in the result. Each later run asks only for the rows ingested since the last watermark. Rows ingested after the watermark can come back in the next run too; follow recognizes them and does not print them twice.

Any other query re-runs over its whole window. That includes:

  • aggregates (summarize, count, make-series) and limits (take, top, distinct)
  • join and lookup
  • a second table, read through in (...), toscalar(...), materialize(...), or a tabular let
  • functions that read other rows, such as prev(), next(), and row_number()
  • your own functions (let f = ...), and stored functions

An aggregate prints each row whose value changed: with | summarize n=count() by bin(timestamp, 1m), a bin prints again whenever its count grows.

Output

RowPrinted as
A log row (it has a body, as a column or inside a raw row)time, severity, service, body
Any other rowthe time, then name=value for each column that is not null
--jsonone JSON object per row

--csv and --tsv are not supported with --follow.

Severity comes from severity_text, or from severity_number (17 and up is ERROR, 13 WARN, 9 INFO, 1 DEBUG). The service is service.name, from the column or from the resource.

When the output is piped, it has no colors, and follow exits with status 0 when the reader closes the pipe (| head -20). An error from the server ends the follow with status 1.

Limitations

Row order is the order of arrival

Rows are sorted oldest first within one run, not across runs. A row that is ingested late prints when it arrives, so it can land below newer lines. A result without a timestamp column prints in the order the server returns it.

  • Busy streams. Follow shows whether your logs are arriving; it is not an export. A run prints at most the newest 2,000 new rows. When more arrive between two runs, the older ones are left out and a marker takes their place:

    ··· more than 2000 rows arrived since the last poll; older ones are not shown ···

    With --json, the marker is a row of its own: {"$error": "more than 2000 rows arrived since the last poll; older ones are not shown"}. Filter the stream further (a service, a severity, a where on the body) to see every row.

  • Full-window runs of a filter. A filter that re-runs over its whole window (for example, one that uses in (...) on a second table) returns its newest 100 rows on every run, so a stream faster than about 100 rows per run loses rows. End such a query with | top 1000 by timestamp desc to raise that bound, and keep its window small, because every run scans all of it.

  • Rows are matched by value, ignoring ingest_time. Two identical rows print twice. A row that leaves a sliding window prints nothing. When an aggregate's value changes back, its row prints again.

  • Log lines show four fields. A log row prints its time, severity, service, and body; other columns you project or extend are not shown. Use --json, or leave body out of the projection to see them as name=value pairs. A row whose cells are all null prints as an empty line.

  • Incomplete results. When a run cannot read part of the data, follow prints a warning; rows that run missed print when a later run returns them.

  • Not with early stopping or CSV. --follow cannot be combined with --stop-when, --stop-cmd, --no-stream, --csv, or --tsv.

Examples

# Errors from one service, as they happen
bzrk search -f "default | where resource['service.name'] == 'checkout' and severity_number >= 17"

# Only what arrives from now on, as JSON for jq
bzrk search -f --tail 0 --json --since "1m ago" \
  "default | where body has 'timeout'" | jq -c '{timestamp, body}'

# Request counts per minute, printed as each bin changes
bzrk search -f --since "15m ago" \
  "default | where isnotnull(body) | summarize n=count() by bin(timestamp, 1m)"

On this page