# Query API

Everything your app has sent is readable over HTTP with SQL, and this is the API your coding agent
uses when you ask it what production did. One endpoint runs a SQL script, one takes a question in
plain words, and one takes either as a job you collect later. Four more read the errors your app
reported, grouped into issues, one reads back a mail from the analyst, one says how a pull request
went in production, and two say what Packrat receives from your apps.

Every endpoint lives at `https://query.packrat.sh`. The query endpoints take their input as the
request body and answer with newline-delimited JSON; the issue endpoints take a query string and
answer with one JSON object, and the report endpoint answers with markdown.

| Endpoint | What it does | What it reads | Max time |
| --- | --- | --- | --- |
| `POST /sql` | Runs a SQL script against one of your apps | Everything: spans, log lines, and all of it the passes worked out | 100s |
| `POST /statements` | Submits a script or a question as a job | Whichever of the two it carries | 10 min |
| `GET /statements/:id` | Collects a job's rows, waiting a while if you like | That job, in your org | waits ≤ 25s |
| `DELETE /statements/:id` | Cancels a job that has not started | That job, in your org | — |
| `POST /search` | Semantic search over your log lines | Log lines only, and only the ones logged at `WARN` or `ERROR` | 100s |
| `GET /issues` | Lists the issues your app's errors are grouped into | Issues, counted over a period | — |
| `GET /issues/:id` | One issue, with its latest event and stack trace | That issue | — |
| `GET /issues/:id/events` | An issue's events, newest first | That issue's events | — |
| `GET /issues/:id/tags/:key` | How an issue's events divide by one tag | That issue's events | — |
| `GET /reports/:id` | An analyst mail and the SQL behind it | That mail, in your org | — |
| `GET /pulls/:number` | How a pull request went in production | Its deploys, the analyst's verdict on each, and its checks | — |
| `GET /checks` | Lists what the analyst is due to verify | The app's open checks, and yours closed in the last week | — |
| `POST /checks` | Asks the analyst to verify something at a later time | Writes one check; nothing in your data | — |
| `GET /checks/:id` | Reads one check | That check, in your org, open or closed | — |
| `DELETE /checks/:id` | Closes a check before it is verified | That check, in your org | — |
| `GET /apps` | Lists the apps your key opens | Each app's name and when it last sent data | — |
| `GET /status` | Says what Packrat receives for one app | Each signal's sources, and which are kept | — |

Max query time is the longest a query may take, counted from when the request arrives, so a query
that waits its turn spends that time too.

## Which endpoint to use

**Reach for `/sql` by default.** Almost every question about a few days of one app is answered
well inside its budget, the rows stream back on the same connection, and there is nothing to poll.

**Use `/statements` when you would rather not hold a connection open while the query runs.** `/sql`
keeps one open for as long as the query takes; this hands you an id straight away and lets you come
back for the rows. That is what you want for a scan over months rather than days, a client that
cannot keep a connection open — a short proxy timeout, a serverless function, a browser — or several
queries you want to launch now and collect as each finishes. It is also the answer when `/sql` comes
back with `the query did not finish in time`.

**Use `/search` for semantic search over your log lines.** It answers "which log lines mean
something like this", and nothing else: no counting, no grouping, no spans, and only lines logged at
`WARN` or `ERROR`. Everything else is a question for `/sql`.

**Use `/issues` to ask what is broken.** It answers from errors already grouped, the way Sentry
groups them, so one failure that happened a thousand times is one row with a count and a stack
trace. Start there when someone reports an error, then take its `trace_id` to `/sql` for the rest of
the request.

Underneath they are the same queue: `/sql` submits a statement and waits for it. So the choice is
not which one is faster, or which jumps the line — it is who holds the connection while the query
runs, and how long the query is allowed to take.

## Authentication

Every request carries an API key, which belongs to one org: it reads that org's data and manages its
checks for the analyst (`POST /checks`). It cannot change your data, and it cannot send any —
that is the ingest key's job, and neither key does the other's.

There are two ways to have one:

- **`packrat login`**, for a person. The [packrat command](https://github.com/packratlabs/packrat-cli)
  signs this machine in through the browser and keeps the key in the macOS Keychain (elsewhere in
  `~/.config/packrat/credentials.json`). `packrat sql`, `packrat open` and the other commands send it
  for you, so neither you nor your agent handles it. Install the command with
  `curl -fsSL https://packrat.sh/cli | sh`.
- **A key from the keys page**, for a machine with no person, such as CI. Set it as
  `PACKRAT_API_KEY`, which `packrat` uses before any sign-in, or send it yourself:

```sh
curl -sS https://query.packrat.sh/sql \
  -H "Authorization: Bearer $PACKRAT_API_KEY" \
  --data-binary 'select count(*) from packrat_traces(since := current_date)'
```

`Authorization: <key>` is accepted as well as `Authorization: Bearer <key>`. A missing or revoked
key is a 401 with no body, and revoking takes effect on the next request.

The examples further down write `$API_KEY` for the key, so that each one shows the request rather
than the plumbing. Keep a key out of URLs, out of your shell history, and out of a conversation with
an agent.

**The same key opens Packrat's pages.** A `GET` of any page under `https://packrat.sh/orgs/…` with
`Authorization: Bearer <key>` answers with the page, as a signed-in person sees it, so an agent can
read a page someone pasted: an Analyst page, an issue, the deploys. It opens only the key's org, it
never prints a key (the setup screen hides both), and it changes nothing: making or
revoking a key, and every form on the site, need a person signed in.

`packrat login` makes a key of this kind for one person on one machine, and the keys page lists it
with that person. It works only while the person is a member of the org.

## Which app you are asking about

A key opens your **org**, and an org holds one app per service you run. Every endpoint takes `app`,
on the query string, naming the service:

```sh
curl -sS "https://query.packrat.sh/sql?app=checkout" \
  -H "Authorization: Bearer $API_KEY" \
  --data-binary 'select count(*) from packrat_traces(since := current_date)'
```

`app` is required only when the org has more than one app. If it does and the request has no `app`
query parameter, the response is a 400 that lists the available apps.

**You name the service, not the environment.** Packrat picks the environment for you: production if
there is one, and otherwise the only one there is — so an app that reports solely as `staging` or
`development` is still reached by its service name alone. Name one explicitly as
`app=checkout/staging` when you want the unusual one.

An agent that was given a pull request can say `repo=owner/name` in place of `app`: the app is the one
whose analyst is connected to that repository on GitHub.

Hosts that report through the [Datadog Agent](https://packrat.sh/docs/datadog) are asked for the same
way, as the app `infrastructure`: `app=infrastructure/production`. It is never the app a request with no
`app` falls back to, and no app can take its name.

`packrat_apps()` lists what your key opens, and marks the one the query is reading. An org whose
first data has not been written yet gets no rows; Packrat writes what arrives once a minute.

```sh
curl -sS https://query.packrat.sh/sql -H "Authorization: Bearer $API_KEY" \
  --data-binary 'select service, env, current from packrat_apps()'
```

**The answer says which app it came from.** The `end` line carries `app`, so a result can always be
traced back to what produced it without anyone having to remember what they asked for.

Each app is stored separately and shares nothing with the others — not tables, not log templates,
not incidents. One consequence is worth knowing: a `template_id` means something only within the app
it came from, so an id from one app's search does not identify the same message in another.

## Responses

Rows stream as they are found, one JSON object per line:

```
{"type":"schema","columns":[{"name":"unit","type":"VARCHAR"},{"name":"runs","type":"BIGINT"}]}
{"type":"row","values":["GET /orders",128]}
{"type":"end","rows":1,"truncated":false,"seconds":0.42}
```

`schema` comes once, then a `row` per result row, then `end` — which carries how many rows were
sent, whether they were cut off, and how long the query took. A blank line is a heartbeat, sent
every few seconds so the connection stays open while an aggregation is still thinking; ignore it.

**An error can arrive inside a 200.** Because the response streams, the status line is committed
before the query has finished, so a query that fails after that cannot become a 4xx. It ends with a
line instead, and that line is the only place the failure is reported:

```
{"type":"error","message":"the query could not be run"}
```

So read the last line rather than trusting the status alone. Two ceilings end a query early and both
show up there: 50,000 rows, which is `"truncated":true`, and 100 seconds of wall clock, which
includes any time spent waiting in the queue.

## Status codes

| Code | When |
| --- | --- |
| 200 | Rows, as NDJSON — including a query that failed, on its last line |
| 202 | A statement that has not finished, with a JSON summary and a `Location` |
| 400 | An empty body, an unknown `kind`, or a question that cannot be asked |
| 401 | No key, or one that has been revoked |
| 404 | A statement id or an issue id that is not in your org, or an app that is not there |
| 409 | A cancel arrived after the query had already started |
| 410 | A statement finished but its rows have been cleared |
| 413 | A body over 256 KB, or a question over 1 KB |
| 429 | Your org already has 20 queries waiting or running; `Retry-After: 5` |

## POST /sql

The body is the script itself, not a JSON envelope — the thing you are sending is already a
language.

```sh
curl -sS https://query.packrat.sh/sql \
  -H "Authorization: Bearer $API_KEY" \
  --data-binary 'select unit, count(*) as runs from packrat_traces(since := current_date) group by 1'
```

**Max query time is 100 seconds**, covering the wait for a turn, the query itself, and streaming the
rows. Past it the response ends with `the query waited in the queue past its deadline` if it never
started, or `the query did not finish in time` if it was still running.

A script may be several statements. They all run, and the rows you get back are the last one's, so a
preamble of `CREATE TEMP TABLE`s costs you nothing in the response.

Two queries run at once per org and the rest wait their turn, which is why a 429 means genuinely too
many at once rather than too many per second.

## POST /statements

Submits a query and answers immediately with an id, leaving the query to run without you: it keeps
going after your request has ended, and you collect the rows later with `GET /statements/:id`. Which
of the two you are submitting is the `kind` parameter, and the body is that query — a script or a
question, never a JSON envelope:

| `kind` | Body | Parameters |
| --- | --- | --- |
| `sql` — the default, so it can be left off | A SQL script, up to 256 KB | None |
| `search` | A question in plain words, up to 1 KB | `limit`, `since` and `until`, exactly as `/search` takes them |

A script, which is what you get without saying `kind` at all:

```sh
curl -sS https://query.packrat.sh/statements \
  -H "Authorization: Bearer $API_KEY" \
  --data-binary 'select * from packrat_incidents(current_date - 7)'
```

The same request as a question, with `kind=search` in the query string and the question as the body:

```sh
curl -sS "https://query.packrat.sh/statements?kind=search&limit=10&since=2026-09-01" \
  -H "Authorization: Bearer $API_KEY" \
  --data-binary 'ran out of database connections'
```

Anything else in `kind` is a 400, and so is a question that cannot be asked — both are found out
before the statement is queued, so nothing is recorded.

Either way you get 202, a `Location` to come back to, a `Packrat-Statement` header, and a summary
whose `kind` says which of the two it recorded:

```
{"type":"statement","id":"stm_…","kind":"sql","state":"queued","ahead":2,
 "created_at":"2026-09-15T18:20:11.004Z","started_at":null,"finished_at":null}
```

`state` is one of `queued`, `running`, `succeeded`, `failed`, `canceled` or `expired`, and `ahead`
is how many of your own queries are in front of it.

**Max query time is ten minutes**, from being submitted to having rows — six times what the same
query gets on `/sql`, which is the point of submitting it this way. One still waiting when
its deadline passes is `expired`. One that was running and has said nothing two minutes past it is
`failed`, on the assumption that the worker holding it died.

## GET /statements/:id

Collects the result. `?wait=` holds the request open for up to 25 seconds waiting for the query to
finish, which saves you polling:

```sh
curl -sS "https://query.packrat.sh/statements/stm_…?wait=25" \
  -H "Authorization: Bearer $API_KEY"
```

A finished statement answers 200 with the same NDJSON `/sql` streams. An unfinished one answers 202
with the summary above, so you can come back. Ids are looked up inside your org, so someone else's
id is a 404, and results are kept for a day.

## DELETE /statements/:id

Cancels a statement that has not started, which is a 200 and the summary. One that is already
running is a 409 — it will run to its end or hit its deadline.

## POST /search

The body is a question in plain words, up to 1 KB.

```sh
curl -sS "https://query.packrat.sh/search?limit=10&since=2026-09-01" \
  -H "Authorization: Bearer $API_KEY" \
  --data-binary 'ran out of database connections'
```

| Parameter | Value |
| --- | --- |
| `limit` | 1 to 100, default 20 |
| `since`, `until` | `YYYY-MM-DD`, both optional |

This is semantic search, and it is what the endpoint is for: matching words would fail on the thing
people actually ask, because "ran out of connections" and "PG::ConnectionBad: could not obtain a
connection" share almost no words. Instead your question is embedded with `BAAI/bge-small-en-v1.5`
into a 384-dimension vector — the same model that placed your log templates — and ranked against
every template vector by cosine similarity. The comparison is exact rather than approximate: a
template catalogue is small enough to scan whole, so there is no index to warm and no recall to
trade away. What comes back is the nearest templates with how often each one actually happened:
`similarity` (1.0 is identical), `template_id`, `template`, `occurrences`, `errors`, `first_dt`,
`last_dt`.

**`/search` only reaches log lines**, and only those logged at `WARN` or `ERROR`: those are the
templates Packrat places in meaning space, so an `INFO` line is never a candidate however well it
matches your words. The counts beside a hit do cover every line of that template, whatever its
severity. For anything else — spans, units of work, minutes, incidents — write the question as SQL
and send it to `/sql`.

A question that cannot be asked — empty, over 1 KB, or with a date that is not `YYYY-MM-DD` — is a
real 400 with an error object, because nothing has been written yet when that is found out.

## GET /issues

Packrat groups the exceptions your app records through OpenTelemetry into issues as they arrive: an
exception that escapes a request or a job, and one reported through `Rails.error` (see the
[Rails page](https://packrat.sh/docs/ruby-on-rails)). A 404 or a 422 that Rails rescued is left
out. The four endpoints read those issues the way Sentry's MCP server reads Sentry's:
`search_issues`, `get_issue_details`, `search_issue_events` and `get_issue_tag_values`.

```sh
curl -sS "https://query.packrat.sh/issues?app=checkout&sort=freq&period=24h" \
  -H "Authorization: Bearer $API_KEY"
```

```json
{"app":"checkout","period":"24h","issues":[
  {"id":"CHECKOUT-12","title":"ActiveRecord::RangeError: PG::NumericValueOutOfRange","culprit":"OrdersController#create",
   "type":"ActiveRecord::RangeError","status":"unresolved","substatus":"regressed","level":"error","platform":"ruby",
   "events":41,"users":9,"times_seen":212,"first_seen":"2026-09-02T14:03:11Z","last_seen":"2026-09-24T08:57:40Z"}]}
```

| Parameter | Value |
| --- | --- |
| `app` | The service, as for `/sql`. Required only when the org has issues in more than one |
| `status` | `unresolved` (default), `resolved`, `ignored` or `all` |
| `query` | Text to find in the title or the culprit |
| `environment` | Only events from this environment. Default: every environment |
| `sort` | `date` (default, last seen first), `freq` (most events), `new` (first seen), `user` (most users) |
| `period` | How far back `events` and `users` count, as a number and `h`, `d` or `w`: `24h`, `14d`, `2w`. Default `14d`, at most `90d` |
| `limit` | 1 to 100, default 25 |

An issue with no event inside the period is not listed. `times_seen`, `first_seen` and `last_seen`
cover the issue's whole life, whatever the period. `substatus` is `new`, `ongoing` or `regressed`:
regressed means it came back after it was resolved.

Unlike `/sql`, an issue is one service's across every environment, so `app` names the service alone
and `environment` narrows the events. A `period` that cannot be read is the default, and one longer
than 90 days is 90 days; the reply's `period` says which was used.

### GET /issues/:id

One issue by its `id`, with the same fields as the list, plus `first_release`, `last_release`, the
tags its events carry with the most common value of each, and its latest `event` — or the one named
by `?event=<event_id>`. The event holds the exception chain, oldest cause first, each with its
frames (`filename`, `abs_path`, `function`, `lineno`, `in_app`), and the `trace_id` that takes you
to the request in `/sql`.

### GET /issues/:id/events

The issue's events inside the period, newest first: `event_id`, `occurred_at`, `environment`,
`release`, `transaction`, `server_name`, `trace_id`, `user` and `handled`. Takes `period`,
`environment` and `limit` as the list does.

### GET /issues/:id/tags/:key

How the issue's events inside the period divide by one tag — `environment`, `release`, `handled`,
`runtime.name` or any key the issue page lists — most common first, each value with its `count`,
`first_seen` and `last_seen`.

## GET /reports/:id

Every mail from the analyst ends with a command that reads it back here, so you can hand it to your
coding agent and carry on from what the analyst found. The answer is markdown: the mail, the deploys
the analyst was shown, and each statement it ran — its purpose, the SQL, and the first 2,000
characters of what came back.

```sh
curl -sS "https://query.packrat.sh/reports/rep_5k2m9x0qv7ha" -H "Authorization: Bearer $API_KEY"
```

The statements ran against the app the mail is about, as it was then. Run one again through
`/sql` with the same `app` to see how it looks now. A daily summary sent before 2026-09-30 kept
none of its statements, and says so.

## GET /pulls/:number

How a pull request went in production: each deploy that shipped it, newest first, with the analyst's
verdict on it, and the checks asked for it. Hand it the repository the pull request is in, and the
app is found for you.

```sh
curl -sS "https://query.packrat.sh/pulls/482?repo=acme/shop" -H "Authorization: Bearer $API_KEY"
```

```json
{ "app": "shop/production", "pull": 482, "analysed": true, "repository": "acme/shop",
  "deploys": [ { "id": "dep_3k9x0qv7ha2m", "version": "9f2c41e…", "deployed_at": "2026-10-14T16:02:11Z",
                 "pull": 482, "title": "Cache the cart total", "verdict": "partly", "judged_at": "2026-10-14T16:14:40Z",
                 "intent": "…", "evidence": "…", "next_step": "…", "follow_up": "…",
                 "report": "rep_5k2m9x0qv7ha", "look": "look_2m9x0qv7ha5k" } ],
  "checks": [ { "id": "chk_7ha5k2m9x0qv", "what": "…", "due_at": "2026-10-20T21:00:00Z", "pull": 482,
                 "asked": true, "opened_at": "…", "closed_at": null, "outcome": null } ] }
```

`verdict` is `confirmed`, `partly` or `refuted` once the analyst has judged the deploy; `too_early`
and `not_observable` when it could not yet, and `null` before its first look. `follow_up` is the
"Follow-up" section of the pull request, which says what would prove it worked. `report` is the mail
that gave the verdict: read it with `GET /reports/:id` for the SQL behind it. A deploy that shipped
several pull requests is listed for each of them.

No deploys means none has shipped the pull request yet, or the app's repository is not connected,
which `repository` shows. A verdict needs the analyst on the app, which `analysed` shows.

## POST /checks

A check is the one thing an API key writes. It is a sentence the analyst reads: what must be true at a
later time, and the SQL that shows it. The body is that text, at most 4,000 characters. `due` is the
first time it can be verified, in UTC and at most 30 days ahead. The analyst runs the SQL in its
first look after that time and mails the team what the data showed, whether it held or not. Give
`report` the id of an analyst mail and the answer arrives in that mail's thread. Give `pull` a pull
request's number and the check belongs to it: `GET /pulls/:number` lists it, and the answer arrives in
the thread of the mail that gave that pull request's verdict.

```sh
curl -sS -X POST "https://query.packrat.sh/checks?app=checkout&due=2026-10-20T21:00Z&report=rep_5k2m9x0qv7ha" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: text/plain" --data-binary @- <<'TEXT'
The daily new-user send at 20:30 UTC sent nothing on each of the last 14 days. If so, say that the
scheduler entry can be deleted.

select time, body from packrat_logs(since := now() - interval 14 day)
where body like '%Sent roundup email%' and extract(hour from time) = 20
TEXT
```

Send the `Content-Type: text/plain` header: without it curl posts a form, and the `%` of a `LIKE` is
a `400`. The answer is the check as JSON, with its `id`. A `422` says in `message` what was wrong: the
analyst does not look at that app, `due` is missing or out of range, or 20 asked checks are open
already.

### GET /checks

The app's open checks, the analyst's own and the ones asked for (`asked`), soonest first, and the
asked ones closed in the last 7 days with their `outcome`. `pull=482` keeps the ones asked for that
pull request.

### GET /checks/:id

One check, open or closed, whoever asked for it. Once it is closed, `outcome` says what the data showed.

### DELETE /checks/:id

Closes an open check before it is verified. To change a check, close it and post another.

## GET /apps

Lists the apps your key opens: one for each service name and environment that has sent data.

```sh
curl -sS https://query.packrat.sh/apps -H "Authorization: Bearer $PACKRAT_API_KEY"
```

```json
{ "now": "2026-10-13T15:02:11Z",
  "apps": [ { "app": "shop/production", "service": "shop", "env": "production",
              "first_seen_at": "2026-09-30T18:20:04Z", "last_data_at": "2026-10-13T15:01:58Z" } ] }
```

An app you do not recognise is usually your own under a different name: a service name that was not
set, or an environment you did not expect.

## GET /status

Says what Packrat receives for one app, which is the check at the end of setup. `app` is required.
It answers from the moment a request arrives, before the first minute is written to storage.

```sh
curl -sS "https://query.packrat.sh/status?app=shop/production" -H "Authorization: Bearer $PACKRAT_API_KEY"
```

```json
{ "app": "shop/production", "service": "shop", "env": "production",
  "first_seen_at": "2026-09-30T18:20:04Z", "last_data_at": "2026-10-13T15:01:58Z", "now": "2026-10-13T15:02:11Z",
  "signals": [
    { "signal": "traces", "quiet": false, "sources": [
      { "source": "sentry", "name": "Sentry", "kept": true, "last_seen_at": "2026-10-13T15:01:58Z" },
      { "source": "datadog", "name": "Datadog", "kept": false, "last_seen_at": "2026-10-13T15:01:40Z" } ] } ],
  "warnings": [ "Datadog also sends traces. Packrat does not keep them, because this app's traces come from Sentry. To use them in place of Sentry's, ask us to switch the source." ] }
```

`signals` always has five entries, in the order `exceptions`, `traces`, `logs`, `metrics`,
`replays`. A signal with no `sources` has not arrived. A source is `otel`, `sentry`, `datadog`,
`sentry_browser` or `datadog_browser`, and `last_seen_at` is exact to the minute. `name` is the
source's display name; an `otel` source sent by the `packrat-rails` gem is named `packrat-rails`.

Until an app sends its first exception, an app whose traces come from `otel` lists that source under
`exceptions` with `kept: true` and `last_seen_at: null`, because the same spans carry its exceptions.

`kept` says whether Packrat keeps what that source sends. An app keeps its exceptions and its traces
from the first server source that sent them, so a second one has `kept: false`. Logs, metrics and
everything from a browser are kept from every source. `quiet` is true when the kept source has sent
nothing for a day and another source sends that signal now.

An app that is not there is a 404 whose `message` names the apps that are.

## Schema

Your app's data is exposed as macros rather than tables, so each one is called like a function. The
ones taking `since` and `until` take them as named arguments with dates; leaving them out reads
everything the app has sent, which is slower and rarely what you want.

`packrat_spans`, `packrat_logs`, `packrat_metrics`, `packrat_traces`, `packrat_templated_logs`,
`packrat_exceptions`, `packrat_log_counts`, `packrat_replays` and `packrat_replay_events`, and the metric
macros built on `packrat_metrics`, take a timestamp as well. The two mean different things:

- **A date is a day**, both ends included: `since := DATE '2026-09-27', until := DATE '2026-09-27'` is
  every row filed under that `dt`. A row is filed under the day it arrived, which for a job that runs
  past midnight is the day after it began.
- **A timestamp is an instant**, and the window runs from `since` up to but not including `until`,
  by when each row happened — a span's `start_time`, a log line's `time_unix_nano`, a point's `time`.
  `since := now() - INTERVAL 1 HOUR` is the last hour. On spans, logs and metrics it also chooses the
  files: a window of one hour opens the two or three hour files that hold rows from it, where a date
  opens all twenty-four.

The time columns are UTC with no zone, and `now()` carries one, so `since` and `until` take `now()` as it
is but a comparison in SQL wants `now() at time zone 'UTC'`: `where occurred_at >= now() - interval 1
hour` is a binder error.

Every table carries the partition columns `dt` (`DATE`), `org`, `service` and `env` (`VARCHAR`), and
the two signal tables also carry `signal`. Filter on `dt` wherever you can: it is how the files are
laid out, so it is the cheapest filter there is. For less than a day, pass the window as timestamps
rather than a date and a `WHERE` on the time, which reads the whole day to keep an hour of it.
`service` and `env` are constant within one app, so filtering on them narrows nothing — the app is
chosen by the request, not in the SQL.

### packrat_apps

`packrat_apps()` — the apps this key opens. Takes no arguments and reads no storage, so it is free
to ask and it answers before any app has been compacted.

| Column | Type | Description |
| --- | --- | --- |
| `service` | `VARCHAR` | The name the app reports under, and what you pass as `app` |
| `env` | `VARCHAR` | Its environment |
| `base` | `VARCHAR` | Where its data is kept |
| `current` | `BOOLEAN` | Whether this query is reading it |

### Facts about the data

Five things about the shape of this data. Each one, got wrong, gives you a plausible number rather
than an error, so they are worth reading once before writing anything.

- **A root span is `parent_span_id IS NULL`**, never `= ''`. The wrong form matches nothing and
  returns no rows, which reads as "nothing was recorded". A root that is also a unit of work — an
  inbound request rather than an outbound call — is additionally `kind = 2`.
- **Durations nest.** A span's time already contains its children's, so summing durations across a
  trace counts the same milliseconds several times and can put a template's render time above the
  whole request. Self time — a span's duration minus the sum of its children — is the measure that
  adds up.
- **Group log lines by `template_id`, not by their text.** A line carries its variables inside the
  string, so exact matching splits one message into thousands. `packrat_templated_logs` is the same
  lines with the template in place of the body.
- **`duration_ms` and `duration_time_unix_nano` are different columns.** One is milliseconds and one
  is nanoseconds. Mixing them reports microseconds as milliseconds, which reads as a suspiciously
  fast page rather than as a mistake.
- **`template_id` is local to one app.** The same message in two apps has two different ids, so an
  id carried from one app's search does not find it in another.
- **`since` bounds the read.** A query with no `since` reads every day the app has sent; it is slower,
  and it is the usual reason one comes back `"truncated":true` having answered nothing useful. A
  question about an hour asks for that hour as timestamps, and reads only the files that hold it.
- **Ask a summary before the raw signal.** Exceptions, log counts and processes each have a small
  table of their own, written as the hours close, so "is this new", "what rose against last week"
  and "was there a deploy" read a few hundred rows rather than a week of spans. Go to
  `packrat_spans` or `packrat_logs` for the rows of one trace, or for the last few minutes.

### packrat_spans

`packrat_spans(since :=, until :=)` — one span, as your SDK sent it. Everything OpenTelemetry's OTLP
reader produces, so the names are the spec's rather than ours.

| Column | Type | Description |
| --- | --- | --- |
| `trace_id`, `span_id`, `parent_span_id` | `VARCHAR` | Hex ids; `parent_span_id` is `NULL` on a root, never empty |
| `trace_state` | `VARCHAR` | W3C tracestate, if the caller sent one |
| `name` | `VARCHAR` | The span's name — a route, a job class, a query |
| `kind` | `INTEGER` | OTel span kind: 2 server, 3 client, 5 consumer |
| `kind_name` | `VARCHAR` | The same thing in words, added by the macro |
| `start_time_unix_nano` | `TIMESTAMP_NS` | When it started |
| `start_time` | `TIMESTAMP_NS` | The same column under a shorter name, added by the macro |
| `duration_time_unix_nano` | `BIGINT` | How long it took, in nanoseconds |
| `duration_ms` | `DOUBLE` | The same in milliseconds, added by the macro |
| `status_code` | `INTEGER` | 0 unset, 1 ok, 2 error |
| `status_status_message` | `VARCHAR` | The status message; the doubled word is the reader's column name |
| `service_name`, `service_namespace`, `service_instance_id` | `VARCHAR` | Which app, and which process of it |
| `span_attributes` | `VARCHAR` | JSON — read with `json_extract_string(span_attributes, '$."http.route"')` |
| `resource_attributes`, `scope_attributes` | `VARCHAR` | JSON, the same way |
| `scope_name`, `scope_version` | `VARCHAR` | The instrumentation that produced the span |
| `events_json`, `links_json` | `VARCHAR` | JSON arrays of span events and links |
| `dropped_attributes_count`, `dropped_events_count`, `dropped_links_count` | `INTEGER` | What the SDK dropped before sending |
| `flags` | `INTEGER` | W3C trace flags |

Spans from a page, sent by Sentry's browser SDK, share the app's spans. Their `scope_name` starts
`sentry.javascript.`, they have no `service_instance_id`, and their kind is never 2 or 5, so they are
in no other table. `sentry.op` in `span_attributes` says what each one is (`pageload`, `navigation`,
`ui.action`, `http.client`, `resource.script`), a page load carries its Web Vitals as
`browser.web_vital.<name>.value` (`lcp`, `fcp`, `ttfb`, `cls`, `inp`, in milliseconds but `cls`),
and spans the app records itself carry its own attribute names.

Spans from a Sentry server SDK have a `scope_name` such as `sentry.ruby`. A request's first span is
kind 2 and a job's is kind 5, so they are units in `packrat_traces` as OpenTelemetry's are, named as
Sentry names the transaction (`ColorsController#all`, or the job's class). `service_instance_id` is
the host. `sentry.op`, `sentry.description` and `sentry.origin` hold what Sentry called each span.

### packrat_logs

`packrat_logs(since :=, until :=)` — one log line, as it arrived, body and all.

| Column | Type | Description |
| --- | --- | --- |
| `time_unix_nano` | `TIMESTAMP_NS` | When the line was written |
| `time` | `TIMESTAMP_NS` | The same column under a shorter name, added by the macro |
| `observed_time_unix_nano` | `TIMESTAMP_NS` | When the SDK saw it |
| `body` | `VARCHAR` | The line itself |
| `severity_text` | `VARCHAR` | `INFO`, `WARN`, `ERROR` — as your logger wrote it |
| `severity_number` | `INTEGER` | The same severity on OTel's 1–24 scale |
| `trace_id`, `span_id` | `VARCHAR` | The request this line belongs to, when the logger knew it |
| `service_name`, `service_namespace`, `service_instance_id` | `VARCHAR` | Which app, and which process of it |
| `event_name` | `VARCHAR` | Set only by SDKs that name their log events |
| `log_attributes`, `resource_attributes`, `scope_attributes` | `VARCHAR` | JSON |
| `scope_name`, `scope_version` | `VARCHAR` | The instrumentation that produced the line |
| `dropped_attributes_count` | `INTEGER` | What the SDK dropped |
| `flags` | `INTEGER` | W3C trace flags |

### packrat_traces

`packrat_traces(since :=, until :=)` — one unit of work — a request or a job, not a span. This is
the table most questions want.

| Column | Type | Description |
| --- | --- | --- |
| `trace_id` | `VARCHAR` | The trace this unit is the root of |
| `kind` | `VARCHAR` | `web` for an inbound request, `job` for a message off a queue |
| `unit` | `VARCHAR` | What was asked for: the route, or the job class |
| `code_namespace`, `code_function` | `VARCHAR` | The class and method behind it, when the instrumentation said |
| `messaging_destination` | `VARCHAR` | The queue a job came off |
| `start_time` | `TIMESTAMP_NS` | When it started |
| `duration_ms` | `DOUBLE` | How long it took |
| `queue_ms` | `DOUBLE` | How long it waited before your app saw it; treat under 500 as zero |
| `gc_ms` | `DOUBLE` | Garbage collection inside it, from the Rails log line |
| `error` | `BOOLEAN` | Failed from the caller's side: span status error, or a 5xx |
| `http_status_code` | `INTEGER` | The status it returned |
| `user_id` | `VARCHAR` | From the `user.id` attribute, if you set one |
| `service_instance_id` | `VARCHAR` | Which process ran it |
| `spans`, `distinct_spans` | `BIGINT` | How much work it did, and how many kinds |
| `shape` | `VARCHAR` | A hash of the unit and its span mix, so two runs of the same shape compare |

### packrat_weather

`packrat_weather(from, to)` — one minute of one process. Both bounds are timestamps, not dates.

The rows stop at the end of the newest compacted hour, which is a few minutes after that hour
ends. A minute with a row of zeros was quiet. A minute with no row has not been read yet, so
count the newest minutes from `packrat_spans` instead.

| Column | Type | Description |
| --- | --- | --- |
| `minute` | `TIMESTAMP` | The minute this row covers |
| `service_instance_id` | `VARCHAR` | Which process |
| `unit_count`, `consumer_run_count` | `BIGINT` | Units of work, and how many were jobs |
| `error_count` | `BIGINT` | Units that failed |
| `over_30s_count`, `queued_over_1s_count` | `BIGINT` | Requests over 30s, and units that waited over a second |
| `in_flight_count` | `BIGINT` | Units still running during the minute |
| `span_count`, `db_call_count` | `BIGINT` | Spans, and how many were database calls |
| `sum_duration_ms`, `db_busy_ms` | `DOUBLE` | Time spent, in total and in the database |
| `p50_ms`, `p95_ms` | `DOUBLE` | Unit duration percentiles |
| `queue_p95_ms`, `db_p99_ms` | `DOUBLE` | Queue wait and database call tails |

### packrat_incidents

`packrat_incidents(since)` — one incident: a run of bad minutes Packrat joined up. `since` is a
timestamp, and rows come back oldest first.

| Column | Type | Description |
| --- | --- | --- |
| `started_at`, `ended_at` | `TIMESTAMP` | When it ran from and to |
| `class` | `VARCHAR` | What it looked like: `capacity`, `database`, `dependency`, `errors`, `change`, `restart`, `unknown` |
| `rules` | `VARCHAR[]` | Which alarms fired: `breadth`, `queue`, `errors` |
| `window_count` | `BIGINT` | Ten-minute windows it spans |
| `instances` | `VARCHAR[]` | The processes involved |
| `busiest_instance` | `VARCHAR` | The one worst affected |
| `peak_breadth`, `peak_busy`, `peak_queue_p95_ms` | `DOUBLE` | How wide, how loaded, how long the queue got |
| `affected_unit_count` | `BIGINT` | Units of work caught in it |
| `error_count`, `over_30s_count` | `DOUBLE` | Failures and very slow requests inside it |
| `versions_seen` | `VARCHAR[]` | Versions running during it |
| `version_changed`, `restarted` | `BOOLEAN` | Whether a deploy or a restart happened inside it |
| `dependency_peer` | `VARCHAR` | The outside host implicated, if one was |
| `note`, `noted_at` | `VARCHAR`, `TIMESTAMP` | A note someone wrote about it |

### packrat_templates

`packrat_templates()` — the log line catalogue: one row per distinct message structure.

| Column | Type | Description |
| --- | --- | --- |
| `template_id` | `BIGINT` | Its id, joined to from the tables below |
| `template` | `VARCHAR` | The line with its values replaced by wildcards |
| `tokens` | `INTEGER` | How many words it has |

### packrat_templated_logs

`packrat_templated_logs(since :=, until :=)` — the same lines as `packrat_logs`, with the body
dropped and the template it matched in its place. Ask this one about kinds of line; ask
`packrat_logs` about the words in them.

| Column | Type | Description |
| --- | --- | --- |
| `time_unix_nano` | `TIMESTAMP_NS` | When the line was written |
| `template_id` | `BIGINT` | Which template it matched |
| `severity_text` | `VARCHAR` | As your logger wrote it |
| `trace_id`, `span_id` | `VARCHAR` | The request it belongs to |
| `service_instance_id` | `VARCHAR` | Which process wrote it |

### packrat_log_counts

`packrat_log_counts(since :=, until :=)` — templated lines counted by minute, template, severity and
process. The same lines as `packrat_templated_logs`, a quarter as many rows, so it is the table for
a baseline: which messages rose against the week before, and which are new.

| Column | Type | Description |
| --- | --- | --- |
| `minute` | `TIMESTAMP` | The minute the lines were written in |
| `template_id` | `BIGINT` | Which template they matched; join `packrat_templates()` for the text |
| `severity_text` | `VARCHAR` | As your logger wrote it |
| `service_instance_id` | `VARCHAR` | Which process wrote them |
| `lines` | `BIGINT` | How many |

It is written by a pass that runs after the templates, so it reaches the last hour that closed, not
the present minute. For the last few minutes, count `packrat_templated_logs` or `packrat_logs`.

### packrat_exceptions

`packrat_exceptions(since :=, until :=)` — one row for each exception a span recorded, with the span
it happened in, up to the present minute. These are the `exception` events inside
`packrat_spans.events_json`, read out once as each hour closes — the hour still arriving is read out
as you ask — so asking about them does not parse a day of spans.

| Column | Type | Description |
| --- | --- | --- |
| `occurred_at` | `TIMESTAMP_NS` | When it was raised |
| `type` | `VARCHAR` | `exception.type` — the class |
| `message` | `VARCHAR` | `exception.message`, whole |
| `fingerprint` | `VARCHAR` | The message's first 200 characters with ids and numbers replaced, so one failure groups however many records it named |
| `stacktrace` | `VARCHAR` | `exception.stacktrace`, whole |
| `escaped` | `BOOLEAN` | `exception.escaped`: whether it left the span unhandled, where the SDK says |
| `trace_id`, `span_id` | `VARCHAR` | The span it was recorded on |
| `span_name` | `VARCHAR` | That span's name |
| `span_kind` | `INTEGER` | That span's kind, 2 for server |
| `span_failed` | `BOOLEAN` | Whether that span ended in status ERROR — false for an exception the code rescued |
| `service_instance_id` | `VARCHAR` | Which process raised it |
| `version` | `VARCHAR` | `service.version` of that process |

Group by `fingerprint` rather than `type` when the question is whether a failure is new: a class
renamed in a refactor is the same failure, and the message says so where the class does not.
`/issues` groups the same exceptions the way Sentry does and keeps their history; this table is for
joining them to spans, traces and versions in SQL.

### packrat_replays

`packrat_replays(since :=, until :=)` — one row for each browser session a Sentry replay or
Datadog's RUM SDK recorded, with how much went wrong in it. An app has these when its pages run
Sentry's browser SDK with replays on, or Datadog's browser SDK, and send Packrat a copy. They reach
to about a minute ago.

| Column | Type | Description |
| --- | --- | --- |
| `replay_id` | `VARCHAR` | Sentry's id for the replay, or Datadog's session id without its dashes |
| `link` | `VARCHAR` | The replay's page in Packrat, where a person reads these rows beside the recording. Give this when someone asks for a link; do not build one, and do not point at Sentry or Datadog, which may not have kept the replay |
| `started_at`, `ended_at` | `TIMESTAMP_NS` | The first and last thing recorded in the window asked for |
| `user_id` | `VARCHAR` | The user the page named to the SDK, when it did |
| `admin` | `BOOLEAN` | True when the page marked that user as staff (`Sentry.setUser({ id, admin: true })`), so their sessions can be left out |
| `url` | `VARCHAR` | The page the replay began on |
| `version` | `VARCHAR` | The release the page was served from |
| `errors` | `BIGINT` | JavaScript errors thrown while it recorded |
| `rage_clicks` | `BIGINT` | Elements pressed five times or more with no change to the page |
| `dead_clicks` | `BIGINT` | Clicks the page did not answer within seven seconds |
| `failed_requests` | `BIGINT` | Requests the page made that failed or answered 400 or above |
| `clicks`, `events` | `BIGINT` | How much the person did, and how many rows `packrat_replay_events` holds for it |
| `trace_ids` | `VARCHAR[]` | The traces the page was in, which the server's spans share when the page propagates them |

### packrat_replay_events

`packrat_replay_events(since :=, until :=)` — what the person did in those sessions, one row for
each thing, so a replay is read in order rather than watched:

```sql
select occurred_at, kind, detail from packrat_replay_events(since := now() - interval 1 day)
where replay_id = '543bfd9640e94925bafb03da58aab720' order by occurred_at
```

| Column | Type | Description |
| --- | --- | --- |
| `occurred_at` | `TIMESTAMP_NS` | When it happened in the browser |
| `replay_id` | `VARCHAR` | The replay it belongs to |
| `kind` | `VARCHAR` | `page`, `load`, `navigate`, `click`, `key`, `request`, `console`, `vital`, `error`, `dead click` or `rage click`; from Datadog also `error click`, `action` (a custom action) and `report` |
| `detail` | `VARCHAR` | The element, the request and its status, the error and its Sentry event id — whatever the kind is about |
| `level` | `VARCHAR` | `ERROR` for an error, and for a console error from Datadog; `WARN` for a dead, rage or error click, a failed request and a console error from Sentry; `INFO` otherwise |
| `trace_id` | `VARCHAR` | The trace it happened in, when the replay names one |
| `user_id`, `admin`, `url`, `version` | | As on `packrat_replays` |

An element is named by its tag, its id and the attributes that identify it; one with no id is
followed by the nearest ancestor that has one (`a.block in div#color_89debef0`). The SDK masks the
page's text unless the app turns that off, so read what a click did from the request after it. A
`dead click` on its own is weak evidence — the SDK reports some clicks the page did answer — and a
`rage click` is strong. These rows are also log lines of the app, under the scope `sentry.replay`
or `datadog.rum` in `packrat_logs`. A Datadog session names an element by its selector and the name
the SDK gave it (`#save "Save palette"`).

### packrat_processes

`packrat_processes(since :=, until :=)` — every process the app has run: one row per instance, process
id and version, with when it was first and last seen. A new version on an instance is a deploy; a new
process id on the same version is a restart. It reaches the present minute: the weather pass writes
each hour as it closes, and the hour still arriving is read as you ask, so a deploy ten minutes ago is
already here.

Without bounds it reads every day the app has run, a file a day. With timestamps it keeps the
processes seen inside the window, and a process that started before the window has the window's
first day as its `first_seen` — so ask for when a version first appeared with a `since` early
enough to hold it.

| Column | Type | Description |
| --- | --- | --- |
| `service_instance_id` | `VARCHAR` | The instance, such as `web.1` |
| `process_pid` | `VARCHAR` | The process id it reported |
| `service_version` | `VARCHAR` | `service.version` |
| `first_seen`, `last_seen` | `TIMESTAMP` | The first and last span it sent |
| `span_count` | `BIGINT` | How many spans it sent |

`packrat_events(from, to)` reads the same table as a list of what happened between two timestamps:
one row per `appeared`, `deploy`, `restart`, `resumed` or `gone`, with `time`, `service_instance_id`,
`service_version` and `process_pid`. Each process is compared with the one before it on its instance,
read from up to seven days before `from`; an instance silent for longer than that comes back as
`appeared` rather than as a deploy.

### packrat_metrics

`packrat_metrics(since :=, until :=)` — one point: a metric your app's OpenTelemetry SDK exported, or
one a host's Datadog Agent sent, which is read from the app `infrastructure/<env>`. A series is a
metric and one set of tags, and the host is a column rather than an app, since hosts come and go.

| Column | Type | Description |
| --- | --- | --- |
| `time` | `TIMESTAMP` | When the point was taken, UTC |
| `host` | `VARCHAR` | The hostname the Agent reports, or the resource's `host.name` |
| `metric` | `VARCHAR` | `http.server.request.duration`, or Datadog's names: `system.cpu.user`, `container.memory.rss` |
| `type` | `VARCHAR` | OpenTelemetry's `gauge`, `sum`, `histogram` or `exponential_histogram`; Datadog's `gauge`, `count` or `rate` |
| `value` | `DOUBLE` | A gauge's or a sum's value, in the metric's own unit; `system.mem.*` and `system.swap.*` are MiB |
| `interval` | `INTEGER` | Seconds a Datadog point covers, usually 15 |
| `tags` | `VARCHAR[]` | `key:value` strings: Datadog's tags, `device:vda` among them, or the point's attributes, sorted |
| `unit` | `VARCHAR` | When the sender names one |
| `source` | `VARCHAR` | `otlp` or `datadog` |
| `start_time` | `TIMESTAMP` | Where a cumulative point starts counting from, which is when its process started |
| `temporality` | `VARCHAR` | `cumulative` — everything since `start_time` — or `delta`, the point's own share |
| `monotonic` | `BOOLEAN` | Whether a sum only goes up: a counter, not an up-down counter |
| `count`, `sum`, `min`, `max` | | A histogram's observations, their total and their extremes |
| `bounds`, `buckets` | `DOUBLE[]`, `BIGINT[]` | A histogram's bucket bounds and the count in each; `buckets` has one more entry |
| `scale`, `zero_count`, `zero_threshold`, `positive_offset`, `positive_buckets`, `negative_offset`, `negative_buckets` | | An exponential histogram's buckets, as OpenTelemetry sends them |
| `resource` | `VARCHAR` | The resource's attributes as JSON, which tell two processes on one host apart |
| `scope` | `VARCHAR` | The instrumentation scope that recorded it |

A metric's value depends on how it was sent, so ask the macros below before you sum or average
`value` yourself. A cumulative counter holds a running total, and the Ruby SDK sends cumulative
unless `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` is `delta`.

`packrat_tag(tags, key)` reads one tag: `packrat_tag(tags, 'device')` is `vda` for a point tagged
`device:vda`, and `NULL` for one without it.

`packrat_deltas(name, since :=, until :=)` — each point of one metric with what it adds to its series.
`kind` is `level` (a gauge, a rate, an up-down counter), `amount` (a count or a counter) or
`distribution` (a histogram). An amount adds `delta`; a distribution adds `delta_count` observations
that total `delta_sum`. A cumulative point adds its rise over the point before, and counts from zero
after its process restarts. Given a timestamp `since`, the hour before it is read as well, so the
window's first point is measured against the one before it, and only the window's rows come back.
Given a date, the first point of a series in the range adds nothing, because nothing comes before it —
so pass a window of less than a day as timestamps. A `NULL` name reads every metric.

`packrat_rollup(name, seconds, since :=, until :=, by_tag :=)` — one metric in time buckets of `seconds`,
per host and tag set, rolled up the way Datadog rolls up a graph: the mean of a level, the sum of an
amount, and the mean observation of a histogram. Columns `bucket`, `host`, `tags`, `value`.

`by_tag :=` folds the series, the way Datadog's "by" does. A tag key gives one series for each value of
that tag across every host, with `host` empty and `tags` holding only `key:value`. An empty string `''`
gives one series of everything. A folded level is the mean of its series, and a folded amount is their
sum. Without it, each host and tag set stays apart. Folding a disk's devices or a CPU's cores yourself is
still yours to do, because devices add up and cores do not.

`packrat_quantile(name, q, seconds, since :=, until :=, by_tag :=)` — the `q` quantile of one histogram metric
(`0.95` for p95) in time buckets of `seconds`, per host and tag set, found by linear interpolation within
the bucket that holds it. It is only as precise as the buckets: an exponential histogram is accurate to a
few percent, but an explicit one only to its bounds. Each bucket is drawn in to its point's `min` and
`max`, so the open top bucket of an explicit histogram ends at the largest value observed. Columns `bucket`, `host`, `tags`, `value`.

`packrat_buckets(name, seconds, since :=, until :=, by_tag :=)` — the observations each histogram bucket
gained, per time bucket, host and tag set: `bucket`, `host`, `tags`, `lower`, `upper`, `n`.
`packrat_quantile` reads this. Both take `by_tag :=` as `packrat_rollup` does, and they sum the buckets
before they find the quantile. So `by_tag := ''` gives the p95 across every host.

`packrat_metric_names(since :=, until :=)` — what was sent, one row per metric: `metric`, `type`,
`hosts`, `series`, `points` and `last_point`.

```sql
-- Host CPU in use, a minute at a time, over the last hour.
SELECT bucket, host, 100 - avg(value) AS cpu_percent
FROM packrat_rollup('system.cpu.idle', 60, since := current_date)
WHERE bucket > now() - INTERVAL 1 HOUR
GROUP BY ALL ORDER BY bucket;

-- p95 request time per route across every host, five minutes at a time, today.
SELECT bucket, packrat_tag(tags, 'http.route') AS route, value AS p95
FROM packrat_quantile('http.server.request.duration', 0.95, 300, since := current_date, by_tag := 'http.route')
ORDER BY bucket, route;
```

### Worked examples

The slowest ten kinds of request today:

```sql
select unit, count(*) as runs, round(quantile_cont(duration_ms, 0.95)) as p95_ms
from packrat_traces(since := current_date)
where kind = 'web'
group by 1
order by p95_ms desc
limit 10;
```

Errors by the minute for the last three hours:

```sql
select minute, sum(error_count) as errors, sum(unit_count) as units
from packrat_weather(now() - interval 3 hour, now())
group by 1
order by 1;
```

Exceptions in the last hour, and whether each has happened in the week before:

```sql
with k as (
  select fingerprint, any_value(type) as type,
         count(*) filter (where occurred_at >= (now() at time zone 'UTC') - interval 1 hour) as last_hour,
         count(*) filter (where occurred_at < (now() at time zone 'UTC') - interval 1 hour) as week_before,
         count(distinct cast(occurred_at as date)) filter (where occurred_at < (now() at time zone 'UTC') - interval 1 hour) as days_seen
  from packrat_exceptions(since := now() - interval 8 day)
  group by 1)
select * from k where last_hour > 0 order by week_before = 0 desc, last_hour desc limit 15;
```

What Packrat called an incident this week:

```sql
select started_at, ended_at, class, rules, error_count, versions_seen
from packrat_incidents(current_date - 7);
```

## Before the first hour is compacted

`packrat_spans` and `packrat_logs` answer from the first minute an app reports, with the columns
listed above: they read the hour being collected as well as the compacted ones.

The other macros read what compaction and the passes wrote. An hour closes five minutes after it
ends and is compacted within ten minutes of that; ask one of them sooner and you get a line in words
rather than a storage error, with the clock time when that changes. Before anything has been written
at all, the line says either that data has arrived and becomes queryable within a minute, or that
none has arrived and what to check.

## What this API does not do

It does not write, it cannot reach another org's data, and it knows nothing about your code — only
the telemetry your app sent.

---

## Agent instructions

This page is part of the Packrat documentation, published as plain markdown for coding agents: add `.md` to any docs address, or read the index at https://packrat.sh/docs/llms.txt. This page: https://packrat.sh/docs/api.md.

Before wiring Packrat into an app, ask the developer for the URL of their setup page at https://packrat.sh/orgs, and read it with `packrat open <URL>`. It has the steps and the org's ingest key.
