Query reference: operators & syntax

How to filter — in both the Builder (structured rows) and SQL mode. The store is ClickHouse, so SQL mode is standard ClickHouse SQL. See Table & column reference for the columns.

Builder filter operators

Each + Add filter row is field · operator · value. Available operators:

OperatorMeaningExample value
= / !=Exact match / notlevel = error
IN / NOT INOne of a setnamespace IN (payments, checkout)
LIKE / NOT LIKEWildcard (% = any run, _ = one char)message LIKE %timeout%
> >= < <=Numeric / time comparisonduration_ns > 100000000

Filters combine with AND. For OR / regex / JSON-field logic, switch to SQL mode.

SQL mode — the patterns you'll actually use

Text search (substring, case-sensitive):

WHERE message LIKE '%connection refused%'

Case-insensitive: WHERE positionCaseInsensitive(message, 'refused') > 0.

Regex:

WHERE match(message, 'timeout|deadline exceeded')

Filter by a JSON attribute (labels, parsed_fields, span attributes are JSON strings):

-- spans where the HTTP status was 500
WHERE JSONExtractString(attributes, 'http.status_code') = '500'
-- logs for pods labelled team=payments
WHERE JSONExtractString(labels, 'team') = 'payments'

Time: write the range literally with ClickHouse intervals. The editor substitutes nothing into your SQL — there are no $from/$to placeholders, and using them is a syntax error:

WHERE timestamp > now() - INTERVAL 30 MINUTE      -- logs/metrics
WHERE start_time >= now() - INTERVAL 1 HOUR        -- spans use start_time

Duration (nanoseconds!):

-- spans slower than 250 ms
WHERE duration_ns > 250 * 1e6
SELECT name, duration_ns/1e6 AS ms FROM spans ORDER BY ms DESC

Aggregate:

SELECT service_name, quantile(0.99)(duration_ns)/1e6 AS p99_ms
FROM spans
WHERE start_time > now() - INTERVAL 1 HOUR
GROUP BY service_name
ORDER BY p99_ms DESC

Rules & limits

LimitValueOn hitting it
Statement typeSELECT and WITH only400. INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/OPTIMIZE/SYSTEM/SET/USE and the rest are rejected outright.
Result rows10,000Silently capped. Aggregate or narrow the range rather than paging.
SQL length1 MiB400.
Timeout30 seconds504 — narrow the window, add filters, add LIMIT.
Concurrent queries5 across the whole deployment429 "too many concurrent queries". Retry shortly.

10,000 rows is not the Logs export cap. The Log Explorer's CSV export is capped separately, at 100,000 rows. The query editor has no export at all — it is a result grid, and 10,000 is the hard ceiling on what a query returns.

Heavy queries run on a separate read-only connection pool, so they cannot stall the rest of the UI. Saving a Builder query writes to the URL — share the URL to reproduce it exactly.

Why a query gets refused

Two guards sit in front of the database, and both refuse rather than silently altering your results.

Rejected outright

  • Quoted or escaped table names. Write FROM logs, not ` FROM logs ` — a quoted or backslash-escaped identifier is refused, because a name that has to be decoded before it can be recognised cannot be reliably matched against the tables that need scoping.
  • Database-qualified names. observekit.logs is refused; use the bare logs.
  • Table functions that read outside the databaseurl, file, s3, hdfs, remote, cluster, jdbc, odbc, mysql, postgresql, mongodb, redis, iceberg, executable and others.
  • Table functions that pick tables by pattern at runtimemerge, loop, mergeTreeIndex, mergeTreeProjection. These name no table in the SQL text, so nothing can constrain what they read. (view() and viewIfPermitted() are fine — their subqueries are ordinary SQL.)
  • system.* and information_schema.*** — server internals, not your telemetry.

Silently narrowed: source scoping

If you are not an admin, your query is rewritten before it runs so it can only see the sources you are scoped to. You do not see the rewrite and you get no warning.

This is the explanation for the confusing case: you and an admin run the identical query and get different row counts, or you get zero rows from a table you know has data. Nothing is broken — you are scoped to fewer sources. A non-admin with no sources in scope gets a 403 rather than an empty result, so "no access" never masquerades as "no data".

Because the rewrite works on table names in the SQL text, a query that hides a table name from it is refused rather than run unscoped — which is why the identifier rules above are strict.

Quick recipes

-- Top error messages, last hour
SELECT message, count() AS n FROM logs
WHERE level = 'error' AND timestamp > now() - INTERVAL 1 HOUR
GROUP BY message ORDER BY n DESC LIMIT 20;

-- All logs for one request (pivot from a trace)
SELECT timestamp, level, message FROM logs
WHERE trace_id = '<trace-id>' ORDER BY timestamp;

-- Error spans by service
SELECT service_name, count() AS errors FROM spans
WHERE status_code = 'ERROR' AND start_time > now() - INTERVAL 1 HOUR
GROUP BY service_name ORDER BY errors DESC;