Search syntax
Updated Jul 28, 2026 · 1d ago
You don't need to be a query wizard — detection finds most of what matters without anyone typing a query, and the natural-language translator writes these for you. This page is for when you want exact control. The same search syntax powers Explore, Live Tail, saved searches, threshold rules, and the /search API endpoint, so a query you write in one place works in all of them. The in-app help panel beside the query box calls this the Search reference.
First: what a wide time range actually scans
Two things happen to your time range before the query runs. Neither is a bug, both change your results, and the API tells you when they happen — but if you're reading a number off the screen and don't know about them, you will draw the wrong conclusion.
1. Ranges longer than 6 hours are narrowed. For a range over 6 hours, the search first finds which hour buckets actually contain matching logs, then reads only the 3 newest non-empty buckets. A 7-day search can therefore return roughly 3 hours of data. It is fast rather than complete on purpose: you almost always want the most recent matches. The response says so in an adaptive_window object, and the UI renders a banner from it, so you are never shown a narrowed result presented as a full one. To scan the whole range instead, page backwards with before — or use an aggregation pipe, which is exempt (see below).
2. Your start time is clamped to your retention. Ask for 30 days on a plan that keeps 7 and the search silently becomes a 7-day search — there is nothing older to read. The response reports this as retention: {clamped, cap_days, requested_days}, so "no results" and "no results in the window we were allowed to look at" stay distinguishable. Caps per plan are on Limits.
stats, top, uniq, sort, or collapse_nums pipe stage are never narrowed — splitting an aggregate across buckets would produce a wrong answer, so they scan the full range. If you want a true 7-day count, ask for a count: level:error | stats by (service) count() over 7d counts the whole range, while the same query without the pipe returns the newest slice. The counts are over everything; the number of rows is still capped by limit, and agg_truncated in the response tells you when rows were cut.Basic filtering
A query is a filter, optionally followed by pipes. Unquoted bare words search across all fields; a quoted string matches inside _msg, the log message body. field:value matches one field. Field-value matching is case-insensitive — level:ERROR and level:error return the same logs. A bare * matches everything and is the usual starting point for a pipe.
level:error # logs whose level field equals "error"
"connection timeout" # substring match anywhere in the message body
_msg:"pool exhausted" # phrase match, message body only
service:api # field equals a value
status_code:500 # numeric field equals a value
duration_ms:>1000 # numeric comparison — requests slower than 1s
service:* # the service field EXISTS (any value)
NOT trace_id:* # the trace_id field is MISSINGfield:* is a presence test, not a wildcard value match — it returns logs where the field is set at all. Combined with NOT it finds the gaps in your instrumentation: level:error NOT trace_id:* is every error you cannot trace back to a request.
Logical operators
Combine conditions with AND, OR, and NOT, and group with parentheses. A space between two terms is an implicit AND, which is why level:error service:api returns only logs matching both — not the union people sometimes expect.
level:error AND service:api # both must match
level:error OR level:fatal # either matches
NOT service:nginx # everything except nginx
(level:error OR level:fatal) AND service:api # parentheses group
level:error service:api # a space IS an AND
NOT level:info NOT level:debug # stack NOT to exclude several valuesMixing AND and OR without parentheses is the classic way to get a result set you can't explain. Write the parentheses even when you think you don't need them.
Wildcards and regex
A trailing * is a prefix match. For anything more expressive use ~"…" with an RE2 regular expression — scoped to a field (host:~"…") or, with no field, across all of them. Field-scoped regex is the form to reach for: it's narrower, faster, and far easier to read six months later.
service:api* # prefix: matches api, api-v2, api-gateway
_msg:err* # prefix in the message: error, err, errand
status_code:~"5.." # regex on one field: every 5xx
host:~"web-0[1-3]" # regex character class: web-01, web-02, web-03
_msg:~"\d{3}\s+error" # regex with escapes (RE2 syntax)
~"timeout|refused" # regex across all fieldsRE2 has no backtracking, so there are no catastrophic-backtrack blowups — but it also means no backreferences and no lookahead. If your regex from another tool rejects, that's usually why.
Time filters
_time: takes either a relative duration measured back from now, or an absolute inclusive range in brackets. Units are s, m, h, d, w. In the UI the time picker sets this for you; typing _time: in the query box is for saved searches and rules, where the window has to travel with the query.
_time:5m # last 5 minutes
_time:1h # last hour
_time:7d # last 7 days
_time:[2026-03-01, 2026-03-02] # absolute range, inclusive
_time:[2026-03-01T10:00:00Z, 2026-03-01T11:00:00Z] # precise ISO8601 rangeAbsolute timestamps are UTC. A bare date like 2026-03-01 means midnight, so _time:[2026-03-01, 2026-03-02] is that one full day. Over the API the same window is expressed with the start and end body fields instead.
Stream filters
A stream is a unique log source identified by a set of labels — typically service plus host, pod, or container. _stream:{…} filters hit an index, which makes them substantially faster than the equivalent field filter over a large range. When a label is available as both, prefer the stream form.
_stream:{service="api"} # indexed label lookup — fast
_stream:{service="api",host="web-01"} # several labels, all must match
_stream:{service=~"api.*"} # regex on a stream labelInside the braces, = is exact and =~ is regex — note that this differs from the field:~"…" form used outside the braces. Which labels exist depends on how your shipper is configured; use * | field_names to find out rather than guessing.
Pipes
Append | after the filter to transform or aggregate the matches. Pipes chain left to right, each one consuming the previous output. A filter returns log lines; a stats or top pipe returns rows of aggregate instead, which is why aggregation results have no _time field.
level:error | stats by (service) count() # one row per service, with a count
* | stats count() as total by (service) # name the output column
* | stats avg(duration_ms), max(duration_ms) by (service) # several aggregates at once
* | top 10 (_msg) # the 10 most frequent messages
* | sort by (duration_ms) desc # slowest first
* | uniq by (_msg) # one row per distinct message
level:error | collapse_nums prettify # normalize numbers, IPs, hex
* | field_names # which fields exist here
* | field_values service # which values that field takes
* | stats count() as total by (service) | sort by (total) desc # pipes chain left to rightcollapse_nums prettify rewrites numbers, IPs, and hex strings into stable templates, so timeout after 30s and timeout after 45s collapse into one row. It is the same normalization the New Error and Pattern Clustering detectors use to decide whether an error is genuinely new, and it is the fastest way to turn 40,000 error lines into the dozen distinct problems behind them.
A query may contain at most 5 pipe operators. Beyond that the request is rejected with 400 Too many pipe operators (max 5). In practice three is plenty: filter, normalize, rank.
The cap counts | characters, not pipe stages — and regex alternation uses the same character. A seven-branch alternation carries six bars, so _msg:~"timeout|refused|reset|closed|broken|denied|aborted" is rejected for "too many pipe operators" despite containing no pipe at all. Split it into OR terms, or use a character class, when an alternation runs long.
Common patterns
The queries that actually get typed during an incident. Each one answers a question you'd ask out loud.
level:error _time:1h | stats by (service) count() # which service is erroring, last hour
level:error | collapse_nums prettify | top 20 (_msg) # the 20 distinct error shapes
service:api status_code:500 _time:15m # the actual recent 500s from api
duration_ms:>5000 service:api # api requests slower than 5s
_stream:{service="api"} level:error | stats by (_msg) count() # error breakdown for one stream
trace_id:"abc-123-def" # every log line for one request
level:error NOT service:nginx _time:30m # errors, minus the noisy proxy
* | stats by (level) count() | sort by (count) desc # volume by levelRunning a search over the API
Everything above works outside the UI. POST the query to the search endpoint with an API key that has read scope (app.getepok.dev → Settings → API Keys). The tenant ID in the path must be the tenant your key belongs to — a mismatch returns 403 API key does not match tenant, not an empty result.
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/search \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "level:error AND service:api",
"start": "-1h",
"end": "now",
"limit": 100
}'Authorization: Bearer is shown here; X-API-Key: YOUR_API_KEY is accepted equally.
Request fields
There are exactly five, and no others are read:
query— required. Any query from this page. Empty or whitespace-only is a400; the ceiling is 10,000 characters.start— defaults to-1h. A relative duration (-15m,-24h,-7d) or an ISO timestamp. Clamped to your retention as described above. Relative durations here takes,m,h,donly —-1wis not recognised by this field even though_time:1wis valid inside a query. Write-7d.end— defaults tonow. If both ends are absolute andstartis not beforeend, you get a400.limit— defaults to1000, allowed range 1 to 10,000.before— an ISO timestamp cursor. Returns results older than that instant; this is how you page.
Response
{
"logs": [ { "_time": "2026-03-01T10:42:11Z", "_msg": "...", "level": "error" } ],
"count": 100,
"elapsed_ms": 340,
"query_ms": 290,
"has_more": true,
"oldest_time": "2026-03-01T10:31:02Z",
"agg_truncated": false,
"adaptive_window": null,
"retention": null
}logs— the matching entries. A plain filter does not promise time order; see the caveat under paging.count— how many entries are inlogs. Not a total for the range.elapsed_ms— end-to-end time for the request.query_msreports server-side query time alone.has_more— there are older results to fetch.oldest_time— the_timeof the last entry inlogs. Feed it straight back asbeforeto page.agg_truncated— an aggregation hit the row limit. You cannot page an aggregation, so this is a signal to narrow the query or raiselimit, not to fetch another page.
To walk a long range, loop: send the query, then resend it with before set to the previous oldest_time, until has_more is false. Setting before also switches off the 6-hour narrowing, so a cursor walk reads across the whole window rather than the newest slice of it.
The caveat that bites here. A plain filter carries no ordering guarantee, and oldest_time is simply the _time of the last entry in the array — which is the oldest match only if the results happened to come back sorted. If the walk has to be exact, make the order explicit with | sort by (_time) desc. Know the trade before you do: a sort pipe makes the query count as an aggregation, which forces has_more to false and reports a cut result set through agg_truncated instead. You then drive the loop off your own returned row count and the before cursor rather than off has_more.
{
"query": "level:error AND service:api",
"start": "-1h",
"end": "now",
"limit": 100,
"before": "2026-03-01T10:31:02Z"
}The two disclosure fields
When the range was narrowed or the start clamped, the response carries the proof. If you are building anything on this endpoint, read these before you trust count — adaptive_window.total_hits_in_range is the real number of matches in the range you asked for, which is often far larger than what was returned.
{
"logs": [ ... ],
"adaptive_window": {
"searched_hours": 3,
"nonempty_hours": 41,
"total_hits_in_range": 918234,
"newest_searched": "2026-03-07T09:00:00Z",
"oldest_searched": "2026-03-07T07:00:00Z",
"requested_start": "2026-02-28T09:52:00Z"
},
"retention": { "clamped": true, "cap_days": 7, "requested_days": 30.0 }
}Both keys are always present and are null when nothing was narrowed or clamped — test for null, not for a missing key. Note that adaptive_window.requested_start is the start actually searched after the retention clamp, not the value you sent, which is why it arrives as an absolute timestamp rather than the -30d in the request above. By default responses also enrich error entries with first-occurrence context; append ?enrich=false to the URL to skip that work. Queries are rate limited per key and per tenant by plan — over budget returns 429.
Common mistakes
A time window narrower than the data.The single most common cause of "the log isn't there." The default window is one hour, and an incident you're investigating at 4pm may have started at 11am. Widen the range before you doubt the query — and then read the adaptive_window banner, because widening past 6 hours changes what gets scanned rather than simply scanning more.
Over-broad wildcards. _msg:e* or a bare ~".*" matches nearly everything, which is slow and tells you nothing. Anchor the prefix (service:api*, not service:a*) and scope regex to a field (host:~"web-0[1-3]", not a bare pattern across all of them). If you want the shape of your errors rather than a broad match, the right tool is | collapse_nums prettify | top 20 (_msg).
Assuming case matters — or that it doesn't. Field values are case-insensitive, so level:ERROR and level:error are the same query and writing level:error OR level:ERROR buys you nothing. Regex is a different story: RE2 patterns are case-sensitive, so _msg:~"Timeout" will miss timeout.
Expecting a filter to give you a total. count is the number of rows returned, capped by limit and possibly narrowed to the newest buckets. If the question is "how many," ask with | stats by (…) count() — aggregations are exempt from narrowing, so the counts cover the whole range. Add | sort by (count) desc if you care which rows survive: the row list is capped by limit, and without a sort the rows you get back are an arbitrary slice rather than the biggest ones.
Piping past five. Long chains rejected with 400 usually mean the work belongs in two steps: aggregate first, then investigate the one row that stood out.
Next
- Quickstart — get logs flowing, then come back and query them.
- Custom rules — turn a query you keep re-running into an alert that runs itself.
- Detectors — what gets found without anyone writing a query at all.
- Limits — retention caps per plan, which is what
startgets clamped to. - API reference — the full endpoint list, with requests you can run in the browser.