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 signs this machine in through the browser and keeps the key in the macOS Keychain (elsewhere in~/.config/packrat/credentials.json).packrat sql,packrat openand the other commands send it for you, so neither you nor your agent handles it. Install the command withcurl -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, whichpackratuses before any sign-in, or send it yourself:
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:
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 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.
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.
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 TABLEs 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:
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:
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:
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.
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). 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.
curl -sS "https://query.packrat.sh/issues?app=checkout&sort=freq&period=24h" \
-H "Authorization: Bearer $API_KEY"
{"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.
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.
curl -sS "https://query.packrat.sh/pulls/482?repo=acme/shop" -H "Authorization: Bearer $API_KEY"
{ "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.
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.
curl -sS https://query.packrat.sh/apps -H "Authorization: Bearer $PACKRAT_API_KEY"
{ "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.
curl -sS "https://query.packrat.sh/status?app=shop/production" -H "Authorization: Bearer $PACKRAT_API_KEY"
{ "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 thatdt. 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
sinceup to but not includinguntil, by when each row happened — a span'sstart_time, a log line'stime_unix_nano, a point'stime.since := now() - INTERVAL 1 HOURis 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 additionallykind = 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_logsis the same lines with the template in place of the body. duration_msandduration_time_unix_nanoare 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_idis 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.sincebounds the read. A query with nosincereads every day the app has sent; it is slower, and it is the usual reason one comes back"truncated":truehaving 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_spansorpackrat_logsfor 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:
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.
-- 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:
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:
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:
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:
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.