run_sql
Execute a read-only QuerySQL SELECT against the observability data.
QuerySQL is standard SQL (MySQL-compatible syntax, backtick-quoted identifiers) over your own telemetry. 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') (case-insensitive substring match), 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: 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, empty buckets are zero-filled in the response (numeric columns 0, others null): interior gaps always, and out to the statement's literal timestamp bounds when it has them (to now when it has only a lower bound), so a series that stopped ends in zeros rather than on its last non-zero bucket. 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 case-insensitive substring match: contains(message, 'time') matches 'timeout'. regexp_matches(field, 'pattern') is also substring, but CASE-SENSITIVE — 'GET' will not match 'get'. Prefix the pattern with (?i) to opt in to case-insensitive matching, e.g. regexp_matches(message, '(?i)get'). matches('text') searches message, attributes, and service together.
Time bounds: a statement whose WHERE clause has no lower bound on timestamp is limited to the last 7 days. Add timestamp >= '' or timestamp >= now() - INTERVAL n DAY to look further back.
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'
Time-typed columns (timestamp, bucket(...)) come back as ISO-8601 UTC strings.
Returns rows, queryStats, and: explorerUrl: for a statement over logs only, a shareable Fixter UI link that opens this exact statement in the log explorer's read-only SQL mode over its window — attach it when citing the result as evidence. Absent for spans, metrics and joins, which the explorer cannot open. priorWindow: when compareWithPriorWindow is true, {from, to, rows} for the same statement run over the window of equal length immediately before this one, so a count or group-by is read against its own baseline in one call. The statement needs literal ISO bounds on timestamp (>= and <, or BETWEEN); otherwise priorWindow carries an error and rows are still returned. truncatedRows: set when rows were dropped from the end to fit maxChars, with a truncationHint saying how to narrow. Rows are dropped, never rewritten, so a sorted result keeps its head.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | A QuerySQL SELECT statement without trailing semicolon. | |
| maxChars | No | Character budget for the whole response; rows are dropped from the end to fit and truncatedRows says how many. Omit for the server ceiling. | |
| compareWithPriorWindow | No | Also run the statement over the equal-length window immediately before its own and return it as priorWindow. Needs literal timestamp bounds. Default false. |