> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apsio.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Explore, SQL and dashboards

> Ask your own questions in read-only SQL over a project's telemetry, and keep the answers on dashboards.

When the console's views and the [read API](/read-api) do not answer a question, write it in
SQL. **Explore** in the console, `POST /v1/projects/{projectId}/query` and the MCP tool
`run_query` run one read-only ClickHouse `SELECT` over the project's own telemetry.
**Dashboards** keep queries and preset views as panels over one time range.

## What a query reads

Six tables, with only the project's own rows:

| Table | What it holds |
| - | - |
| `apsio.logs` | Logs, breadcrumbs, crashes, errors, hangs and other events, with their attributes |
| `apsio.spans` | Spans: app start, screen loads, network requests and your own |
| `apsio.metric_histograms` | Histogram metrics, MetricKit's among them |
| `apsio.metric_values` | Sums and gauges, frame counts among them |
| `apsio.session_summaries` | One row per session: its start, last sighting, outcome, release and device |
| `apsio.issue_occurrences` | Every crash, error, hang, ANR and abnormal exit, symbolicated and grouped |

Each table's columns and their ClickHouse types are in Explore's table list and at
`GET /v1/projects/{projectId}/query/schema`. Reads stop at your plan's
[retention](/privacy-and-security#retention): detail for logs, spans and metrics, crashes and
release health for sessions and occurrences.

The examples Explore starts from:

```sql theme={null}
SELECT event_name, count() AS records
FROM apsio.logs
WHERE ts > now() - INTERVAL 1 DAY
GROUP BY event_name
ORDER BY records DESC
LIMIT 20
```

```sql theme={null}
SELECT attributes['app.screen.name'] AS screen,
  count() AS loads,
  quantile(0.9)(duration_ns / 1e6) AS ttid_p90_ms
FROM apsio.spans
WHERE span_name = 'app.screen.load' AND ts > now() - INTERVAL 7 DAY
GROUP BY screen
ORDER BY ttid_p90_ms DESC
LIMIT 20
```

```sql theme={null}
SELECT http_method, http_template, count() AS failed
FROM apsio.spans
WHERE http_template != '' AND http_status >= 500 AND ts > now() - INTERVAL 7 DAY
GROUP BY http_method, http_template
ORDER BY failed DESC
LIMIT 20
```

Filter on `ts` whenever you can: a query that reads less runs faster and stays within its limits.

## What a query cannot do

The database decides what a query may read, not a parser of your SQL. The query runs as a
ClickHouse user of the project whose grants reach the six tables' own columns and nothing else,
whose row policies keep it to the project and your plan's retention, and whose limits it cannot
change:

* **One `SELECT`.** A write, `SET`, `SETTINGS`, `FORMAT`, `INTO OUTFILE` or a second statement is
  refused.
* **No other table.** No other database or system table, no dictionary, and no virtual column
  such as `_part` or `_part_offset`, which would count other tenants' rows. `merge()` is refused
  too.
* **No outside source.** Table functions that read elsewhere (`url`, `s3`, `remote`, `file`,
  `mysql` and the like) are refused. Generators that read nothing (`numbers`, `values`,
  `generateRandom`, `view`) work, within the read limit.

An error says what kind it is, never ClickHouse's own message:

| Code | Status | Meaning |
| - | - | - |
| `query_syntax` | 400 | A syntax error, with its position in your SQL |
| `query_not_allowed` | 400 | A table, database or function the query may not use, or a write, `SET` or `SETTINGS`. A table that does not exist gets the same answer |
| `query_parameter` | 400 | A placeholder the API does not fill, or a value that does not fit its type |
| `query_limit` | 422 | A limit was reached: time, rows or bytes read, memory, or result size |
| `query_busy` | 429 | Two of the project's queries are running, or the server is busy |
| `rate_limited` | 429 | Over the plan's queries a minute, or the project's share of the server |
| `plan_required` | 403 | The plan does not include SQL queries |
| `forbidden` | 403 | The credentials cannot read the project, or the request comes from the sandbox |
| `query_failed` | 400 | Anything else, with ClickHouse's error class in `clickhouse_error` (such as `UNKNOWN_IDENTIFIER`): check the columns, functions and types the query uses |

## Limits

| | Limit |
| - | - |
| SQL | 10,000 characters |
| Time | 10 seconds a query |
| Read | 50 million rows or 5 GB a query |
| Memory | 512 MB a query, 1 GB for the project's queries together |
| Result | 10,000 rows or 12 MB; past it the result is cut and `truncated` is set |
| At once | 2 queries of the project |
| Per minute | Set by the plan: Trial 10, Pro 30, Enterprise 60. Solo has no SQL queries, and the [public sandbox](/read-api#the-public-sandbox) none either |

The project also has a share of the server per minute and per hour, so one project's queries
cannot slow down another's.

The rows and bytes a query read come back rounded to two significant digits, and 64-bit integers
as strings.

## Audit

Every query is written to your organization's audit log before it runs, as `query.run`: the
project, who ran it (a user or a token), how (the console, a token, OAuth or MCP, with the
tool), and the SHA-256 and length of its SQL. Never its text, and never a row of its result.

## A time range

Send a `range` (`{"from": "...", "to": "..."}`, RFC 3339, at most 90 days) and three
placeholders take its values, as ClickHouse query parameters, never as text in your SQL:

| Placeholder | Value |
| - | - |
| `{from:DateTime}` | The start of the range |
| `{to:DateTime}` | The end of the range, exclusive |
| `{bucket:UInt32}` | A step in seconds that cuts the range into at most 120 points |

```sql theme={null}
SELECT toStartOfInterval(ts, toIntervalSecond({bucket:UInt32})) AS t, count() AS logs
FROM apsio.logs
WHERE ts >= {from:DateTime} AND ts < {to:DateTime}
GROUP BY t ORDER BY t
```

The range is widened to whole buckets, and the answer's `range` says what the placeholders were.
Dashboards fill them with the board's time range.

## Dashboards

A dashboard is a board of panels over one time range: 1 hour, 24 hours, 7 days or 30 days. A
project has at most 20 dashboards, and a dashboard at most 24 panels. Each panel is drawn as a
line, bars, one number or a table, with the columns to draw, a unit and an optional threshold
line.

| Panel | What it runs | Plans |
| - | - | - |
| SQL | A query with the placeholders above, run like any query: the same limits, rate and audit | Trial, Pro, Enterprise |
| Preset | One of the read API's views, with its parameters, unmetered | Every plan, Solo included |

The presets:

| Preset | The view |
| - | - |
| `crash_free` | Sessions, crashes and crash-free rates per day (`release-health/daily`) |
| `release_health` | Two releases of one app compared (`release-health/compare`) |
| `performance_series` | One [performance](/concepts/performance) metric per day or per release (`performance/series`) |
| `network_endpoints` | The endpoints the apps call, with their error rates and latency (`network/endpoints`) |
| `vitals` | App start, screens, hangs and frames at a glance (`vitals`) |

A panel runs when the board opens, when the range changes and on **Refresh**, two at a time; a
board can refresh itself every 5 minutes. A result is kept in the API for a minute, so a board
reopened within it costs no query. No result is stored: retention and erasure apply to
dashboards as to everything else.

**Who edits.** Every member creates boards and edits boards and panels; owners and admins delete
them. A project's changes are limited to 20 a minute per person, and each one is in the audit
log. For now, boards are edited only from the console: agents and tokens read them with
`list_dashboards`, `get_dashboard` and `run_dashboard_panel`, and a write from them is refused
with `grant_required`.

Board names, panel titles and SQL can be written by anyone in the project. The console shows
them as text, and MCP returns them as untrusted data.

## From an agent or a script

```sh theme={null}
curl -X POST -H "Authorization: Bearer $APSIO_TOKEN" -H "Content-Type: application/json" \
  -d '{"sql": "SELECT count() FROM apsio.session_summaries WHERE start_ts > now() - INTERVAL 1 DAY"}' \
  "https://api.apsio.io/v1/projects/$PROJECT_ID/query"
```

The body must be JSON. Agents use the MCP tools `get_query_schema` and `run_query`; see
[Agents](/agents).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.