OQL Syntax
This page is the clause-by-clause reference for OQL. For the functions used inside
SELECT, see Functions; for the tables and columns you can name, see
Tables.
Grammar
query = SELECT select_list
FROM source
[WHERE conditions]
[BY columns]
[EVERY duration]
[LAST duration | BETWEEN literal AND literal]
[TOP number]
[LIMIT number]
select_list = expression (',' expression)*
expression = (aggregation | column) [AS alias] | '*'
aggregation = func '(' column ')'
func = avg | sum | min | max | count | p50 | p90 | p95 | p99 | any
source = table_name -- host, disk, logs, spans, ...
| 'custom.' metric_name -- custom.http_request_duration
| database | cache | mq -- external-metric virtual tables
conditions = condition (AND condition)*
condition = column operator value
| column CONTAINS string -- full-text (bloom filter)
| column LIKE pattern -- SQL LIKE
| column IN '(' value_list ')'
| column IS NULL
| column IS NOT NULL
operator = '=' | '!=' | '>' | '<' | '>=' | '<='
duration = number unit -- unit: s | m | h | d | w
Two ground rules:
- Keywords are case-insensitive —
select,SELECT, andSelectare identical. - A time window (
LASTorBETWEEN … AND) is mandatory on every query.
SELECT — what to return
SELECT lists what comes back: raw columns, aggregates, or *.
-- Raw columns (no aggregate → a table)
SELECT timestamp, host, message FROM logs LAST 15m
-- Aggregates
SELECT avg(cpu_user_pct) FROM host LAST 5m
SELECT count(*) FROM logs LAST 1h
-- Everything
SELECT * FROM host LAST 5m
Aggregate functions are avg, sum, min, max, count, any, and the percentiles
p50, p90, p95, p99. See Functions for what each does.
Aliases with AS
Rename any column or aggregate for cleaner output:
FROM — which table
FROM names exactly one source. You cannot join — each query reads one table.
The full list of tables and their columns is in Tables. Three sources have special syntax:
custom.<metric> — custom application metrics
The custom table stores arbitrary OTLP/Prometheus metrics in a name/value/labels
model. The custom.<metric_name> shorthand auto-filters to that one metric:
This is the same as FROM custom WHERE name = 'http_request_duration'. On the custom
table, any column that isn't a known real column (host, namespace, job,
host_id, cluster_id, value, timestamp) is read from the labels map — so
WHERE method = 'GET' means labels['method'] = 'GET'.
database / cache / mq — external-metric virtual tables
database, cache, and mq are virtual tables over metrics collected from
infrastructure (PostgreSQL, Redis, Kafka, …) via OpenTelemetry receivers. Their
columns are the native metric names of those receivers, written with a dot:
SELECT postgresql.backends, postgresql.commits
FROM database
WHERE engine = 'postgresql' AND db_name = 'orders'
EVERY 1m LAST 1h
See Tables → External-metric tables for how each metric column reduces over a bucket and how to scope by engine/instance.
WHERE — filtering
WHERE filters rows with one or more conditions joined by AND.
AND only — no OR
AND is the only logical connector. There is no OR: keeping queries flat keeps
them predictable. When you'd reach for OR, use IN (...) or run two queries.
Comparison operators
=, !=, >, <, >=, <= — the usual set:
IN — match a set
CONTAINS — full-text search
CONTAINS does fast token-based text search, backed by the bloom-filter index on
message. It matches whole tokens and is case-sensitive:
For substring or pattern matching instead of whole tokens, use LIKE:
IS NULL / IS NOT NULL
Map attributes — the dot accessor
Some columns are schemaless key→value maps: OTel attributes and
resource_attributes, the agent's labels, and the external-metric attrs. You read a
single key out of a map with a dot — the map column name, then the key. Because OTel
keys themselves contain dots, the key part can too:
SELECT count(*) FROM spans WHERE attributes.http.method = 'POST'
SELECT count(*) FROM spans BY attributes.http.status_code LAST 1h
SELECT count(*) FROM spans WHERE resource_attributes.deployment.environment = 'prod'
SELECT count(*) FROM logs WHERE attributes.request_id = 'abc123'
SELECT avg(value) FROM custom WHERE labels.env = 'prod' BY labels.instance
How it resolves: when a dotted name's first segment is a known map column for that
table (attributes, resource_attributes, labels, attrs), the rest is the key.
Otherwise the dotted name keeps its other meaning — a custom.<metric> /
database.<metric> name, or a plain column. The dot accessor works in SELECT,
WHERE, and BY.
Keys with dashes or slashes
The dot accessor only reaches keys made of identifier characters. Keys containing
- or / are not expressible via the dot yet.
BY — faceting
BY splits the result into one group per distinct value of the listed column(s). With no
EVERY, this produces a bar chart / ranked table; with EVERY, one series per group.
-- One bar per mountpoint
SELECT avg(used_pct) FROM filesystem BY mountpoint LAST 5m
-- One line per interface, over time
SELECT sum(rx_bytes) FROM network BY interface EVERY 5m LAST 6h
You can facet by a map attribute, too:
EVERY — time bucketing
EVERY buckets rows into fixed time intervals, turning the result into a time series.
EVERY 10s
EVERY 1m
EVERY 5m
EVERY 1h
EVERY 1d
EVERY AUTO -- Hexcovery picks a sensible bucket for the range
EVERY AUTO chooses a bucket from the time window so you get a readable number of points:
| Time range | Bucket |
|---|---|
| ≤ 30m | 10s |
| ≤ 2h | 1m |
| ≤ 12h | 5m |
| ≤ 48h | 15m |
| ≤ 7d | 1h |
| ≤ 30d | 6h |
| > 30d | 1d |
LAST / BETWEEN — the time window
Every query needs a time window. Pick one.
LAST — relative
LAST 5m -- last 5 minutes
LAST 1h -- last hour
LAST 24h -- last day
LAST 7d -- last week
LAST 4w -- last 4 weeks
Units: s seconds, m minutes, h hours, d days, w weeks.
BETWEEN … AND — absolute
ISO 8601 timestamps, interpreted as UTC:
TOP — limiting to the most interesting groups
TOP N keeps only the top N groups, ranked by the first aggregate in SELECT
(descending). It pairs with BY, and is the right way to chart "the busiest 25 hosts"
rather than an arbitrary slice:
With EVERY + BY + TOP, Hexcovery first finds the top N groups across the whole
window, then returns the full time series for exactly those groups — so you get 25
complete lines, not 25 scattered points.
LIMIT — capping rows
LIMIT N caps the number of rows returned — most useful for raw-column (table) queries
like log browsing:
Output type
You never choose a visualization — the query shape decides it, and the result carries an
output_type hint:
| Query shape | Output type | Rendered as |
|---|---|---|
EVERY present (with or without BY) |
timeseries |
Line / area chart |
BY without EVERY |
bar |
Bar chart / ranked table |
Aggregate, no BY, no EVERY |
scalar |
One big number |
| No aggregate (raw columns) | table |
Paginated table |
What OQL deliberately does not do
These are intentional limits that keep queries simple and safe:
- No
ORinWHERE— useIN (...)or run two queries. - No
JOIN— every query targets one table. - No subqueries — the language is flat.
- No arithmetic in
SELECT— e.g.SELECT (a - b) / cis not supported. - No unbounded queries — a time window is always required.