Skip to content

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-insensitiveselect, SELECT, and Select are identical.
  • A time window (LAST or BETWEEN … 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:

SELECT avg(cpu_user_pct) AS cpu, avg(mem_used_pct) AS mem
FROM host
BY host
LAST 1h

FROM — which table

FROM names exactly one source. You cannot join — each query reads one table.

FROM host          -- a real table
FROM spans         -- another real 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:

SELECT avg(value) FROM custom.http_request_duration
BY path EVERY 1m LAST 30m

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.

WHERE host = 'web-1'
WHERE cpu_user_pct > 80
WHERE level != 'INFO'

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:

WHERE used_pct >= 90
WHERE replicas_ready < replicas_desired

IN — match a set

WHERE level IN ('ERROR', 'FATAL')
WHERE namespace IN ('prod', 'staging')

CONTAINS does fast token-based text search, backed by the bloom-filter index on message. It matches whole tokens and is case-sensitive:

WHERE message CONTAINS 'timeout'
WHERE message CONTAINS 'OOMKilled'

For substring or pattern matching instead of whole tokens, use LIKE:

WHERE container LIKE 'nginx-%'
WHERE message LIKE '%connection refused%'

IS NULL / IS NOT NULL

WHERE completion_time IS NULL
WHERE trace_id 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:

SELECT count(*) FROM spans BY attributes.http.status_code LAST 1h

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:

BETWEEN '2026-01-01' AND '2026-01-02'
BETWEEN '2026-01-15 10:00:00' AND '2026-01-15 12:00:00'

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:

SELECT avg(cpu_user_pct) FROM host BY host EVERY 1m LAST 1h TOP 25

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:

SELECT timestamp, pod, message FROM logs
WHERE level = 'ERROR'
LAST 1h LIMIT 100

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 OR in WHERE — use IN (...) 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) / c is not supported.
  • No unbounded queries — a time window is always required.