Run SQL Query
run_sqlExecute a read-only QuerySQL SELECT against the observability data.
QuerySQL is Fixter's own SQL dialect over your telemetry: standard clauses and backtick-quoted identifiers, with its own function set (listed under Functions below). Clauses and operators: 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'). Searching for an error message or log tag? Call list_issues with that text as its query first: Fixter groups recurring errors into issues with their count, first and last seen and a traceId, which often answers the search or narrows it.
A SELECT alias can be used in WHERE and JOIN ON (SELECT lower(service) AS svc ... WHERE svc = 'a'). A name that is a column, or that the SELECT list itself reads as a field (upper(region) AS region), stays that field. An alias of an aggregate cannot be filtered in WHERE: use HAVING. To read an attribute that shares an alias's name, qualify it with the source: logs.n.
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, bytes spans: timestamp, service, name, kind, status_code, status_message, trace_id, span_id, parent_span_id, source_instance_id, duration_ms, bytes metrics: metric_name, service, source_instance_id, timestamp, value, bytes
bytes is the row's stored size in bytes, non-zero on every row: measure or rank data volume with SUM(bytes). raw (and payload on spans) returns the whole stored row and can only be selected, not filtered, grouped, sorted or aggregated.
Functions: Aggregates: count(*), count(DISTINCT field), countIf(condition), countIf(DISTINCT field, condition), sum, avg, min, max, p50, p95, p99, quantile(field, 0.9), error_rate() (percentage, 0-100), request_count(), error_burn_rate(budget), latency_burn_rate(field, threshold, budget). Time: bucket(timestamp, '1h') per minute, hour or day; CAST(timestamp AS DATE) for the date; now(), now() - INTERVAL 1 DAY; epoch_second(timestamp). Text: substring(field, start, length), length, lower, upper, trim, concat, replace, CAST(x AS STRING), truncate_chars(field, n, '...'), contains(field, 'text') (case-insensitive substring match), regexp_extract(field, 'pattern' [, group]), regexp_replace, regexp_matches. Other: coalesce, if, nullif, greatest, least, abs, round, ceil, floor, 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 columns (the column names), rows (one array of values per row, in column order), queryStats, and:
explorerUrl: for a statement over logs only, a shareable Fixter UI link that opens this
statement in the log explorer's read-only SQL mode over its window —
send the user there when they ask to see or browse the rows rather than
reproducing them, and attach it when citing the result as evidence. A row listing that leaves
message out opens with message added, so the reader sees what was logged.
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.
cut cells: a value longer than 4000 characters shows a marker instead: {"truncated": true,
chars (its full length), shown, keys (its structure, when JSON), head (the first shown
characters: 4000, or fewer when maxChars leaves less room), more (the get_trace or
get_log call that reads the rest, where find jumps to text inside it)}.
moreRows: true when the result has more rows than this page holds; truncationHint says why.
With a top-level ORDER BY, nextOffset is the offset that reads the next rows
(same sql, offset=nextOffset). Without ORDER BY there is no nextOffset: an
unordered result can come back in a different order on each call.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | A QuerySQL SELECT statement without trailing semicolon. | |
| offset | No | Rows of the result to skip, to read on from a previous nextOffset. Default 0. | |
| maxChars | No | Character budget for the whole response; rows past it are left for the next page (moreRows, nextOffset). 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. |