epok

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.

The exemption worth knowing. Queries with a 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-insensitivelevel:ERROR and level:error return the same logs. A bare * matches everything and is the usual starting point for a pipe.

Search
bash
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 MISSING

field:* 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.

Search
bash
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 values

Mixing 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.

Search
bash
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 fields

RE2 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.

Search
bash
_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 range

Absolute 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.

Search
bash
_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 label

Inside 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.

Search
bash
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 right

collapse_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.

Search
bash
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 level

Running 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.

Search over the API
bash
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 a 400; 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 take s, m, h, d only — -1w is not recognised by this field even though _time:1w is valid inside a query. Write -7d.
  • end — defaults to now. If both ends are absolute and start is not before end, you get a 400.
  • limit — defaults to 1000, allowed range 1 to 10,000.
  • before — an ISO timestamp cursor. Returns results older than that instant; this is how you page.

Response

200 OK
json
{
  "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 in logs. Not a total for the range.
  • elapsed_ms — end-to-end time for the request. query_ms reports server-side query time alone.
  • has_more — there are older results to fetch.
  • oldest_time — the _time of the last entry in logs. Feed it straight back as before to page.
  • agg_truncated — an aggregation hit the row limit. You cannot page an aggregation, so this is a signal to narrow the query or raise limit, 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.

The next page
json
{
  "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 countadaptive_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.

200 OK — narrowed and clamped
json
{
  "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 start gets clamped to.
  • API reference — the full endpoint list, with requests you can run in the browser.