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 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:
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 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:

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.