Skip to content

Searching logs with SonQL

The query language for Sondar log search, and what it actually does.

Where a command is documented here, it was run against a live instance — not carried over from a document, because the earlier documentation marked commands unsupported that in fact work.


search2 index="app_logs" ERROR
| stats count() as errors by service
| sort by errors desc

A query is a source, then a pipeline of commands separated by |. Each command takes the previous result and passes on a new one.

sort takes a BY clause. sort by errors desc, not Splunk’s sort -errors. The grammar is SORT ((offset COMMA)? size)? BY sort_field_list, so a size limit goes before the by: sort 10 by errors desc.

A . does not break a token, by default. Searching NullPointerException finds nothing in an index full of java.lang.NullPointerException — the dotted identifier is one token. / and = do break, so orders finds path=/api/v1/orders. Splunk treats . as a minor breaker and the shipped default here does not, so it is a live migration trap.

It is configurable per index. The word-breaker list defaults to ,'";=()[]{}?@&<>/: — add . when you create the index and both NullPointerException and java.lang.NullPointerException match, with nothing lost. It applies at write time, so it affects new data only. For data already written, use a wildcard: *NullPointerException, java.lang.*, or | where _raw like "%NullPointerException%".

And it sorts descending by default — the opposite of Splunk, where sort defaults to ascending. | sort by duration_ms gives you the slowest first. Write asc when you want the other direction. Verified on both engines against fields with real variation; the handbook states the same (search1_command_manual: “is sorted by desc by default”). This one is silent when it surprises you — a migrated query returns the reverse order, not an error.

search2 is the log search command. index= selects which index to read.

index and repo. Internally an index is called a repo, and you will see repo= in older material and in some API payloads. In a search predicate the two are interchangeable — index= is aliased to repo=. The alias is search-predicate only: the field is still named repo, so | stats count() by index does not work while | stats count() by repo does.

Bare terms are full-text matches against the raw event:

search2 index="app_logs" ERROR
search2 index="app_logs" ERROR "connection refused"

Field predicates compare a specific field:

search2 index="app_logs" status=500
search2 index="app_logs" status>=500 AND service="checkout-api"

AND, OR and NOT combine predicates. Wildcards use *.

stats is the workhorse:

| stats count() as n by service, status
| stats avg(duration_ms) as avg_ms, max(duration_ms) as peak by endpoint

Aggregation functions are validated: a name the engine does not know is an error, not a silent empty column —

| stats countt() as c
→ Error: no match function [countt()]

limit, not head. SonQL spells it limit:

| limit 20

This is worth knowing if you come from Splunk. head is not a SonQL command. It now errors on both engines — search2 answers unknown command [head] with did you mean 'limit'?, search1 answers do not support spl: head. Until August 2026 search2 silently ignored an unrecognized command, so | head 20 returned unfiltered results and the query looked like it worked (fixed; search1 never had it).

Split by how strongly each is established, because the distinction is the whole point of this page.

Run against the live engine and confirmed working. Earlier documentation marked all five of these unsupported:

command what it does engine notes
top most frequent values of a field search2 requires a count: top 5 service. search1 accepts bare top service. The percent column is “of total data volume” — all search-result events — on both engines since 14 Aug 2026. Caveat: when a by clause is used, the percent is relative to that group’s events that have the field (search2 does not support top … by at all). A mixed-version cluster needs a full restart for top/rare (the result wire format changed in 14 Aug builds)
rare least frequent values same — rare 5 service on search2
dedup drop duplicate rows identical on both
transaction group related events into one works on both engines on live indexed data (15 Aug 2026); search2 carries the joined member fields — each event field becomes a multivalue column holding the union of the members’ values, so `transaction client_ip
chart aggregate into a chart-shaped result works on both (closed on search2 14 Aug 2026); the over <r> by <c> two-level form is stats by <r>,<c> on search2, one row per combination, not the columnar pivot search1 builds. chart options are rejected on search2 with a hard errorspan= and limit= (and any other option, on the row or column split) are parsed but not honoured there, so they fail loudly rather than silently changing the result

The two engines are not quite the same language. Of 22 commands run against both, 15 agree and 7 do not. Where a saved search, dashboard panel or alert does not begin with search or search2, the engine is chosen by the global Default Search Engine setting — so changing that setting can change what those objects return. Name the engine explicitly in anything you save.

Also exercised directly: stats, where, eval, rex, sort, limit, and the rex → where → stats → sort pipeline end to end.

Present in the grammar, not individually exercised here. These are real lexer tokens, so they parse, but this page does not claim runtime behavior for them: eventstats, timechart, bucket, fields, join, movingavg, lookup, fit / apply.

streamstats parses and requires a by clause — the engine answers “expected one of ‘by’”.

mvcombine — works on both engines. Corrected 14 Aug 2026 (was “genuinely absent”, then “rejected on search2 with unsupported ast node: MvCombine”), and the grouping was fixed on search2 15 Aug 2026: mvcombine <field> groups by the searched repo’s real columns (excluding the per-event identity fields _time, _raw, _indexTime, linenum and map-typed fields) and collects the target’s values into a multivalue field. Events that differ only in the identity fields — same status, latency, host, and so on, at different timestamps — merge into one row, matching Splunk’s documented semantics (“all field values identical except the specified field”) and its own guidance that mvcombine is “most useful after you reduce the set of available fields”. Before the 15 Aug fix, grouping by every field made no two events merge and search2 returned empty on real data. Use it freely on either engine.

mvexpand — works on both engines (corrected 14 Aug 2026). On both engines it expands a multi-value field into one row per value. The earlier “returns the input unchanged on search2” observation was not reproducible against the current code — a regression test (SchemaTests. testMvexpandOnValuesOutput) proves stats values(f) as vs | mvexpand vs expands. mvexpand limit=<n> keeps only the first <n> values of each row and works on both engines; both spellings are accepted since 15 Aug 2026 — Splunk’s field-first mvexpand <field> limit=<n> and the options-first mvexpand limit=<n> <field>, on search1 and search2 alike.

lookup is in the grammar but could not be exercised without a lookup table, so it is listed above rather than among the confirmed five.

Time is a parameter of the search, not part of the query text. In the UI it is the range picker; over the API it is startTime and endTime in epoch milliseconds.

The CLI takes absolute times only — --start "2026-08-05 09:00:00". Relative forms like -24h are rejected. Every comparable tool accepts relative time here, and it is recorded as a usability gap.

Every event carries these regardless of parsing:

field meaning
_raw the original line, verbatim
_time event timestamp
_indexTime when it became searchable
host reporting host
origin source path or collector
repo the index it lives in
sourcetype its declared format

Anything beyond these has to be parsed out. Sondar parses in the collection agent, as a transformers stage in the collection config — not at write time on the server. So events sent straight to the ingest API arrive with the fields above and nothing else, and JSON keys do not become queryable on their own.

_raw is only as reliable as the ingest path that wrote it. Events that arrive through a collection agent carry the original line in _raw; events POSTed straight to the ingest API as JSON are parsed into fields and may retain no _raw at all, so both a bare-term full-text search and the rex field=_raw below can silently match nothing on such data.

To get at structure without a collection pipeline, extract at search time:

search2 index="app_logs"
| rex field=_raw "duration=(?<duration_ms>\d+)"
| stats avg(duration_ms) as avg_ms by service

Live Tail follows an index in near real time. It polls once a second over a rolling window held slightly behind the present, because an event takes a moment to become searchable after it arrives — the delay control is that offset. Lower it to see events sooner; raise it if the stream looks like it is missing events under load.

A query you expect to run again belongs in a saved search (a macro), which can then be called by name from other queries and from alert conditions.