Execute a read-only QuerySQL SELECT against the observability data.
QuerySQL is standard SQL (MySQL-compatible syntax, backtick-quoted identifiers)
with automatic tenant isolation. Write normal SQL — most standard features work:
WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, DISTINCT, CASE WHEN, LIKE, ILIKE,
BETWEEN, IN, !=, <>, IS NULL, IS NOT NULL, NOT, OR, AND, subqueries, derived tables,
JOINs, aliases, COALESCE, IF. Also =~ 'pattern' (case-insensitive match, * wildcard);
= / != with a *-wildcard string value behave as ILIKE / NOT ILIKE.
Free-text search: matches('text') in WHERE searches the message, all attributes,
and service case-insensitively (substring match; trace/span ids by exact match), e.g.
SELECT * FROM logs WHERE matches('connection refused').
Call describe_schema first to discover available fields and dynamic attributes
for your data.
Sources: logs, spans, metrics. Dynamic attributes are queryable directly by
name, dots included: http.request.method. Resource attributes need the
resource. prefix: resource.service.name (logs and spans only; metrics does not
expose resource attributes). Missing attributes read as NULL.
Common fields per source:
logs: timestamp, service, level, message, trace_id, span_id,
parent_span_id, source_instance_id, log_id
spans: timestamp, service, name, kind, status_code, status_message,
trace_id, span_id, parent_span_id, source_instance_id, duration_ms
metrics: metric_name, service, source_instance_id, timestamp, value
Custom functions:
count(), count(DISTINCT field), countIf(condition),
countIf(DISTINCT field, condition), sum(field), avg(field),
min(field), max(field), p50(field), p95(field), p99(field),
contains(field, 'text'), error_rate() (percentage, 0-100), request_count(), error_burn_rate(budget),
latency_burn_rate(field, threshold, budget),
bucket(field, 'interval'), now(), regexp_extract(field, 'pattern' [, group]),
lag(field) OVER (PARTITION BY ... ORDER BY ...).
bucket(timestamp, '5m') groups by time. Intervals: <number><unit> with unit
m, h, or d (e.g. 1m, 5m, 30m, 1h, 6h, 1d).
For a query that selects a single aliased bucket, groups by it alone, orders by it, and has no LIMIT,
interior gaps between the first and last returned bucket are zero-filled in the response
(numeric columns 0, others null). Buckets outside the data range are not invented; other
query shapes still return only non-empty buckets.
DISTINCT is a modifier on the counting aggregates: count(DISTINCT field) counts
distinct values, countIf(DISTINCT field, condition) counts the distinct values of
the rows matching the condition. DISTINCT inside any other aggregate (sum, avg,
p95, ...) is rejected with an error rather than ignored.
regexp_extract returns the first regex match (or capture group if specified).
Returns null on no match. Example: regexp_extract(message, 'status=(\d+)', 1).
Burn-rate rules (declared SLO): error_burn_rate(budget) is the error share divided by
your budget (0.001 = 99.9% SLO); latency_burn_rate(duration_ms, 500, 0.03) is the share
of requests over 500ms divided by a 3% budget. Alert when the result exceeds a burn
multiple (e.g. GT 6 over a 60-minute window).
Metrics aggregation: a metric row carries one reading in its value column, so aggregate it
with the ordinary functions — avg(value) for a gauge, sum(value) only where each row is
already a delta. There is no rate() or value() function: a cumulative counter's rate cannot
be written as one aggregate, because an aggregate cannot wrap the window function the
per-point delta needs. Spell it as a subquery instead:
SELECT sum(delta) / 300 AS value FROM (SELECT value - lag(value) OVER (PARTITION BY service, source_instance_id, metric_name ORDER BY timestamp) AS delta FROM metrics WHERE metric_name = 'http.server.request.count') AS deltas WHERE delta >= 0
Replace 300 with your own window in seconds and the metric name with yours. The derived table has to be aliased (AS deltas) or the outer select has no source to resolve delta against. delta >= 0 drops counter restarts. The shape is correct only where the metric carries one series per service, source_instance_id and metric_name: when attributes split it into several series, lag() steps between interleaved series and the summed rate is silently wrong. That case needs the attribute set in the PARTITION BY, which run_sql cannot express today, so pin the query to a single series in its WHERE, or use a metric alert rule, which partitions per series.
This reads the metrics table directly, which does not expose temporality, so it assumes the
metric is cumulative; for a delta-temporality metric sum(value) over the window is already
the answer. list_metrics reports which is which.
Limitations:
- Read-only SELECT only (no INSERT/UPDATE/DELETE/UNION).
- No CROSS JOIN (use explicit JOIN ... ON).
- No SYMMETRIC BETWEEN (order the bounds and use plain BETWEEN).
- JOINs require qualified field references (e.g. l.service, s.name).
- contains(field, 'text') is a whole-token match, case-insensitive:
contains(message, 'time') does not match 'timeout'. A term containing separators
(e.g. 'user-service', 'order_id') requires each of its tokens. For substring or
partial-word matching use matches('…') or a =~ '*glob*' predicate instead.
Prefer purpose-built tools when they fit: use correlate when you have a trace id
(returns spans, logs, and metric exemplars in one call), get_trace for the span tree
alone, and aggregate_spans to find where errors or latency are concentrated before
drilling in. Use run_sql for ad-hoc analysis that the other tools don't cover.
Examples:
SELECT service, count(*) FROM logs WHERE level = 'ERROR' GROUP BY service
SELECT service, p95(duration_ms) FROM spans GROUP BY service
SELECT bucket(timestamp, '5m') AS t, count(*) FROM logs GROUP BY t ORDER BY t
SELECT http_method, count(*) FROM logs GROUP BY http_method
SELECT http.response.status_code, count(*) FROM logs GROUP BY http.response.status_code
SELECT s.name, l.message FROM spans s JOIN logs l ON s.trace_id = l.trace_id
SELECT service FROM logs WHERE service IN (SELECT DISTINCT service FROM spans)
SELECT error_burn_rate(0.001) AS value FROM spans WHERE service = 'my-svc'
Each successful logs-only query also returns an explorerUrl opening the same
query in the Fixter logs explorer (grid view; trace_id/span_id cells link to
the trace waterfall). Attach it when citing rows as evidence to the user. The
link's time window is derived from the returned rows' timestamps (or defaults
to the last 30 days). explorerUrl is absent when the query errored, referenced
spans or metrics anywhere (the logs page renders only logs), or contained
double quotes (use single quotes for string literals), or used a query shape
the explorer cannot reproduce.