Find bugs with /explore — one query, two consumers
Find bugs with /explore
Section titled “Find bugs with /explore”v2.2 added one HTTP route — POST /admin/api/projects/{p}/explore —
that powers the entire find-bug surface of the dashboard. The
Releases module is a preset call to it (dim=release). The
Issues list is another (dim=issue). The trend sparkline at the
top of Releases is a third (dim=time_bucket).
The same endpoint is what an AI agent calls. There’s no separate “agent-friendly” wrapper. The endpoint is enum-constrained on the server side (no SQL passthrough), validated cheaply on the dashboard side, and shares a URL grammar with the UI — so when an operator pastes a Sentori URL into a chat with you, you can read the same data back without guessing.
The mental model
Section titled “The mental model”Three concepts, written down once:
dim— what each row is.release/issue/time_bucket.measures— what numbers go in those rows. Pick fromevent_count/issue_count/resolved_count/unique_users/first_seen/last_seen.filters— what slice of the world to count. Time window (receivedAtGte/receivedAtLt), environment (environmentEq), release (releaseEq), event kind (kindIn), issue status (statusIn).
Every view in the find-bug lens is (dim, measures, filters) plus
an orderBy choice. That’s the whole grammar.
From the dashboard
Section titled “From the dashboard”Releases
Section titled “Releases”Open /main/<org>/<project>/releases. The page calls:
POST /admin/api/projects/<project_id>/exploreContent-Type: application/json
{ "dim": "release", "measures": ["event_count", "issue_count", "resolved_count", "unique_users", "first_seen", "last_seen"], "filters": { "receivedAtGte": "2026-05-27T00:00:00Z" }, "orderBy": "last_seen", "orderDir": "desc", "limit": 200}Pick ?window=1d|7d|30d|all from the toolbar — that’s the only
dial. Each row is one release, sortable by any measure. The
release-name cell is a link into /releases/:release, which calls
/explore again with dim=issue + releaseEq=<that-release> to
show the issues that fired in that build.
Issues
Section titled “Issues”Open /main/<org>/<project>/issues. v2.2 W3 swapped the legacy
list backend for /explore:
POST /admin/api/projects/<project_id>/explore{ "dim": "issue", "measures": ["event_count", "unique_users", "first_seen", "last_seen"], "filters": { "statusIn": ["active"], "receivedAtGte": "2026-05-27T00:00:00Z" }, "orderBy": "event_count", "orderDir": "desc", "limit": 100}Three new pickers above the rail:
- status tab (active / regressed / muted / resolved /
silenced / all) →
filters.statusIn - sort (events / users / last seen / first seen) →
orderBy - window (1d / 7d / 30d / all) →
filters.receivedAtGte
Cross-module deep-link filters (?release=, ?errorType=, ?env=)
map onto filters.releaseEq, filters.kindIn, filters.environmentEq.
If the new path misbehaves, fall back with ?legacy=1 — the old
keyset-paginated listIssuesPage endpoint is still live during W3
dogfood. The rail header tells you which path is active
(“source: /explore · 47 ms” vs “source: legacy · ?legacy=1”).
The search box (?q=) stays client-side — v2.2 /explore has no
full-text filter, and the 100-row result cap makes a client-side
match across errorType + messageSample cheap.
Sharing a slice
Section titled “Sharing a slice”URL state is the contract. Send a teammate this:
https://sentori.golia.jp/main/org/acme/proj/issues ?status=regressed&window=30d&measure=unique_users&release=myapp@1.2.3They see exactly your view. Refresh-stable, bookmark-stable.
Send the same URL to an AI agent and ask “summarise this list.”
The agent can either screen-scrape the page or pull the data
fresh by translating the URL params into an /explore call. Same
filters, same window, same numbers — by construction.
From an agent / CLI
Section titled “From an agent / CLI”curl example, using the same payload the dashboard would build:
curl -X POST \ -H "Content-Type: application/json" \ -H "Cookie: <your admin session>" \ https://sentori.golia.jp/admin/api/projects/$PROJ/explore \ --data '{ "dim": "issue", "measures": ["event_count", "unique_users"], "filters": { "statusIn": ["regressed"], "receivedAtGte": "'$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)'" }, "orderBy": "event_count", "orderDir": "desc", "limit": 25 }'Response:
{ "rows": [ { "issue_id": "01j…", "error_type": "TypeError", "message_sample": "Cannot read properties of undefined…", "last_release": "myapp@1.2.3", "status": "regressed", "event_count": 4218, "unique_users": 312 }, // …up to limit ], "totals": { "event_count": 9871, "unique_users": 514, "issue_count": 25, "row_count": 25 }, "meta": { "dim": "issue", "measures": ["event_count", "unique_users"], "rowCount": 25, "tookMs": 38, "receivedAtGte": "2026-05-27T00:00:00Z", "receivedAtLt": "2026-06-03T00:00:00Z" }}meta.receivedAtGte / receivedAtLt echo the window the server
actually used — if the caller omitted the filter, the server filled
in defaults (last 7 days) and the response says so. That’s how an
agent confirms what it asked for without re-reading its own
request.
Decision table — Issues vs Releases vs time_bucket
Section titled “Decision table — Issues vs Releases vs time_bucket”| You want to answer | Use |
|---|---|
| ”Which release caused the most new pain?” | dim=release, orderBy=event_count, 7-30 day window |
| ”Which release fixed the most?” | dim=release, orderBy=resolved_count |
| ”Which crash is biting the largest cohort right now?” | dim=issue, orderBy=unique_users, 1-7 day window, statusIn=['active','regressed'] |
| ”Where am I regressing?” | dim=issue, statusIn=['regressed'], 7 day window |
| ”Show me the event rhythm” | dim=time_bucket, measures=['event_count'] |
| ”How many users does this specific issue hit?” | dim=time_bucket, filters.issueEq=<uuid>, the requested measure (event_count / unique_users). Returns one row per bucket — same shape as the Issues list per-row sparkline. |
| ”iOS users on this route are dropping out — which?” | dim=device_os + filters.routeEq='/checkout' to see the OS breakdown; drill into dim=route + filters.osEq='ios' for the route shape. |
| ”Are P0 issues piling up?” | dim=issue_priority, measures=['issue_count','new_issue_count'], 7d window. |
| ”Which severity is climbing this week?” | dim=severity, measures=['event_count'], 7d window — compare to last week with the same query and a shifted window. |
v2.3 — sparklines + new dims/filters/measures
Section titled “v2.3 — sparklines + new dims/filters/measures”Per docs/roadmap/post-v2.2-plan.md Phase 2, the grammar gained
five filters, four dims, and four measures (one of which is
deferred):
New filters:
issueEq— single-issue. Pair withdim=time_bucketto render a sparkline of that issue’s events over time. Powers the Issues list per-row sparkline (the v2.2 W3 stub is now filled).userIdEq— single-user (payload.user.id). Phase 7 find-user lens consumes this.routeEq— single-route (payload.tags.route). Phase 8 find-slow drill key.osEq— single-OS (payload.device.os).search— server-side fuzzy match againsterror.type/error.message/message(ILIKE). Replaces the v2.2 W3 client-side stub.
New dims:
device_os— group bydevice.osvalue.issue_priority— group byissues.priority. Reads from theissuestable directly (not events).severity— group by event severity (derived fromkind+level).route— group bytags.route. Phase 8 find-slow anchor.
New measures:
new_issue_count— issues whosefirst_seen ≥ windowStart.p50_duration/p95_duration— span-table p50/p95 in ms. Returns null on dims that don’t yet join to spans; Phase 8 closes this for theroute/device_osdims.crash_free_rate— reserved. Server returns400with a clear pointer until the session schema lands (Phase 1 audit followup). Useevent_count+unique_usersas a proxy.
Per-issue sparkline example
Section titled “Per-issue sparkline example”curl -X POST \ https://sentori.golia.jp/admin/api/projects/$PROJECT/explore \ -H "Authorization: Bearer $SENTORI_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "dim": "time_bucket", "measures": ["event_count"], "filters": { "issueEq": "01900000-0000-7000-8000-000000000001", "receivedAtGte": "2026-05-27T00:00:00Z" }, "limit": 200 }'Response is one row per (auto-picked) bucket; in the dashboard this exact query backs the small chart on every Issues list row.
What /explore is NOT (in v2.3)
Section titled “What /explore is NOT (in v2.3)”- Not a SQL escape hatch. Adding a new
dimormeasureis a Rust match arm inserver/src/api/admin/explore.rs. There’s no way to query a free-form column. - Not real-time. Queries run on the production Postgres at whatever lag the events table already has (~ms during normal load). There’s no streaming subscription — agents poll on a schedule, the dashboard re-fetches on URL change.
- Not multi-project. One project per call. Superadmin / cross-org analytics is its own L2 (v2.4+).
- Not writable. Read-only endpoint. Saved views / alerting on results / scheduled exports are all out of scope here.
- Not crash-free-rate-aware yet. That measure is reserved but rejected at request validation pending session-schema work.
Related
Section titled “Related”manual-issue—captureMessagefor the signals that should open an issue in the first place.track-and-metrics— when the right pipeline istrack/recordMetric, notcaptureException.endpoint-health— synthetic probes whose failures open auto-issues; once opened, they show up in the same/exploreresults.