Searching logs with SonQL
Searching logs with SonQL
Section titled “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.
The shape of a query
Section titled “The shape of a query”search2 index="app_logs" ERROR| stats count() as errors by service| sort by errors descA query is a source, then a pipeline of commands separated by |. Each command
takes the previous result and passes on a new one.
sorttakes aBYclause.sort by errors desc, not Splunk’ssort -errors. The grammar isSORT ((offset COMMA)? size)? BY sort_field_list, so a size limit goes before theby:sort 10 by errors desc.A
.does not break a token, by default. SearchingNullPointerExceptionfinds nothing in an index full ofjava.lang.NullPointerException— the dotted identifier is one token./and=do break, soordersfindspath=/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 bothNullPointerExceptionandjava.lang.NullPointerExceptionmatch, 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
sortdefaults to ascending.| sort by duration_msgives you the slowest first. Writeascwhen 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.
indexandrepo. Internally an index is called a repo, and you will seerepo=in older material and in some API payloads. In a search predicate the two are interchangeable —index=is aliased torepo=. The alias is search-predicate only: the field is still namedrepo, so| stats count() by indexdoes not work while| stats count() by repodoes.
Filtering
Section titled “Filtering”Bare terms are full-text matches against the raw event:
search2 index="app_logs" ERRORsearch2 index="app_logs" ERROR "connection refused"Field predicates compare a specific field:
search2 index="app_logs" status=500search2 index="app_logs" status>=500 AND service="checkout-api"AND, OR and NOT combine predicates. Wildcards use *.
Aggregation
Section titled “Aggregation”stats is the workhorse:
| stats count() as n by service, status| stats avg(duration_ms) as avg_ms, max(duration_ms) as peak by endpointAggregation 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()]Limiting results
Section titled “Limiting results”limit, not head. SonQL spells it limit:
| limit 20This is worth knowing if you come from Splunk.
headis not a SonQL command. It now errors on both engines — search2 answersunknown command [head]withdid you mean 'limit'?, search1 answersdo not support spl: head. Until August 2026 search2 silently ignored an unrecognized command, so| head 20returned unfiltered results and the query looked like it worked (fixed; search1 never had it).
Commands
Section titled “Commands”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 error — span= 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
searchorsearch2, 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 ranges
Section titled “Time ranges”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-24hare rejected. Every comparable tool accepts relative time here, and it is recorded as a usability gap.
Fields you always have
Section titled “Fields you always have”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.
_rawis 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_rawat all, so both a bare-term full-text search and therex field=_rawbelow 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 serviceLive Tail
Section titled “Live Tail”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.
Saved searches
Section titled “Saved searches”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.