Files
awesome-copilot/extensions/sentry-triage/sentry.mjs
T
Liz Tom b6da04c241 Add Sentry Triage canvas extension (#2802)
An on-call error-triage canvas for GitHub Copilot: scans live Sentry
issues, groups them by urgency, and hands issues off for tracking or a
fix PR. Registers a reusable canvas extension under extensions/ with a
matching plugins/sentry-triage manifest.

The extension runs a loopback HTTP/SSE server per canvas instance
(host-allow-listed, per-instance capability token never broadcast over
SSE), renders the triage board server- and client-side, and drives
model turns over a single shared Copilot session serialized so scan and
work turns can't interleave. Work state is prototype-pollution-guarded
against agent-controlled tracking keys, and sendAndWait timeouts are
handled without duplicating external side effects.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-26 10:04:22 +10:00

473 lines
20 KiB
JavaScript

// Deterministic Sentry data layer for the triage canvas.
//
// Everything that decides WHICH issues exist, their counts, and how they are
// categorized lives here in code — driven straight off the Sentry CLI SDK. The
// model is never asked "what are the issues"; it is only (optionally, and
// elsewhere) asked to rephrase a title into plain English. That is what stops the
// canvas from ever showing remembered / stale data.
//
// The SDK returns parsed JSON (typed IssueListResult / project / org objects), so
// there is no markdown to parse: we adapt those JSON shapes into the canvas's
// internal issue model. The adapters + categorizer are pure functions; only
// listOrgs / listProjects / findProject / scanIssues touch the network (via
// ./sentryClient.mjs), and they surface SentryError as a user-facing message.
import {
orgList,
projectView,
issueList,
projectListRaw,
runSerial,
SentryError,
} from './sentryClient.mjs'
// Surface only genuinely high-impact ongoing issues in "escalating" when Sentry
// itself has not tagged them is:escalating. Kept as named constants so the
// selection rule is explicit rather than a magic number buried in a condition.
const HIGH_IMPACT_USERS = 40
const HIGH_IMPACT_EVENTS = 100
// "New" = first seen within roughly the on-call window.
const NEW_MAX_AGE_HOURS = 24
const NEW_MIN_USERS = 2
// Human label for the selected window, e.g. "24h" -> "last 24 hours".
export function windowLabel(period) {
const m = String(period || '').match(/^(\d+)\s*([hdw])$/i)
if (!m) return 'selected window'
const n = parseInt(m[1], 10)
const u = m[2].toLowerCase()
const unit = u === 'h' ? 'hour' : u === 'd' ? 'day' : 'week'
return `last ${n} ${unit}${n === 1 ? '' : 's'}`
}
function toInt(value) {
if (value == null) return 0
const n = parseInt(String(value).replace(/[,\s]/g, ''), 10)
return Number.isFinite(n) ? n : 0
}
// Age in hours from an ISO 8601 timestamp. The SDK returns real first/last-seen
// timestamps (unlike the old MCP markdown, which clamped first-seen to the
// window edge), so this is a true age. Missing / unparsable -> Infinity so the
// issue never counts as "new".
export function ageHoursFromIso(iso, now = Date.now()) {
if (!iso) return Infinity
const t = Date.parse(iso)
if (!Number.isFinite(t)) return Infinity
return (now - t) / 3_600_000
}
// Render an ISO timestamp as a short relative phrase ("just now", "20 hours ago",
// "5 days ago") for the human-facing reason text. Empty / unparsable -> ''.
export function humanizeSince(iso, now = Date.now()) {
if (!iso) return ''
const t = Date.parse(iso)
if (!Number.isFinite(t)) return ''
const mins = Math.max(0, Math.round((now - t) / 60_000))
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.round(hours / 24)
if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
const weeks = Math.round(days / 7)
if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 's'} ago`
const months = Math.round(days / 30)
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
const years = Math.round(days / 365)
return `${years} year${years === 1 ? '' : 's'} ago`
}
// Adapt one raw SDK IssueListResult into the canvas's internal issue shape.
// `now` is injectable for deterministic tests. firstSeen/lastSeen are humanized
// for display; ageHours is the true numeric age used by the categorizer.
export function mapIssue(raw, now = Date.now()) {
if (!raw || !raw.shortId) return null
const firstSeenIso = raw.firstSeen || ''
return {
key: String(raw.shortId),
url: raw.permalink || '',
title: raw.title ? String(raw.title).replace(/\s+/g, ' ').trim() : String(raw.shortId),
users: toInt(raw.userCount),
events: toInt(raw.count),
firstSeen: humanizeSince(firstSeenIso, now),
lastSeen: humanizeSince(raw.lastSeen || '', now),
firstSeenIso,
ageHours: ageHoursFromIso(firstSeenIso, now),
}
}
// Adapt a list of raw SDK issues, dropping anything without a short id.
export function mapIssues(list, now = Date.now()) {
const out = []
for (const raw of Array.isArray(list) ? list : []) {
const issue = mapIssue(raw, now)
if (issue) out.push(issue)
}
return out
}
// Adapt raw SDK org objects into slugs (deduped, order preserved).
export function mapOrgs(list) {
const out = []
const seen = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const slug = String(raw?.slug || '').trim()
if (slug && !seen.has(slug)) {
seen.add(slug)
out.push(slug)
}
}
return out
}
// Adapt raw SDK project objects into slugs (deduped, order preserved).
export function mapProjects(list) {
const out = []
const seen = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const slug = String(raw?.slug || '').trim()
if (slug && !seen.has(slug)) {
seen.add(slug)
out.push(slug)
}
}
return out
}
// List the org slugs this Sentry connection can access. Fast and reliable, so
// it's safe to call on canvas open. Never throws — returns [] on any error
// (including "not authenticated") so the setup form still renders and the auth
// gate is what tells the user to sign in.
export async function listOrgs() {
try {
return mapOrgs(await orgList())
} catch (err) {
console.error('[sentry-triage] listOrgs failed:', err instanceof Error ? err.message : err)
return []
}
}
function reasonFor(id, issue, ctx) {
if (id === 'regressions') {
return `Sentry flagged this as regressed`
}
if (id === 'escalating') {
if (issue.sentryEscalating) return `Sentry flagged this as escalating`
return `Active within the ${ctx.windowLabel}`
}
// new-critical: the SDK gives a true first-seen timestamp, so we can report it
// honestly whenever we have one.
if (Number.isFinite(issue.ageHours) && issue.firstSeen) {
return `First seen ${issue.firstSeen}`
}
return `Active within the ${ctx.windowLabel}`
}
// Pure categorizer. Takes the parsed main issue list plus the sets of issue keys
// Sentry itself classifies as regressed / escalating, and returns the canvas
// category structure (plainEnglish is filled in later by the caller). `period`
// is the selected board window, used to phrase window-relative reasons honestly.
export function categorize({ issues = [], regressed = new Set(), escalating = new Set(), period = '24h' } = {}) {
const byUrgency = (a, b) => b.users - a.users || b.events - a.events
const regressions = []
const escalatingOut = []
const newCritical = []
for (const issue of issues) {
if (regressed.has(issue.key)) {
regressions.push(issue)
} else if (
escalating.has(issue.key) ||
(issue.ageHours >= NEW_MAX_AGE_HOURS &&
(issue.users >= HIGH_IMPACT_USERS || issue.events >= HIGH_IMPACT_EVENTS))
) {
escalatingOut.push({ ...issue, sentryEscalating: escalating.has(issue.key) })
} else if (issue.ageHours < NEW_MAX_AGE_HOURS && issue.users >= NEW_MIN_USERS) {
// New Critical is strictly `<` the boundary; the escalation branch above
// owns exactly-at-boundary issues via `>=`. Together they leave no gap: at
// a 24h scan Sentry clamps an older issue's first-seen to exactly 24h, and
// that boundary case belongs with old/ongoing (escalating if high-impact),
// never "New Critical". A boundary issue that isn't high-impact simply
// isn't urgent enough for any bucket.
newCritical.push(issue)
}
}
regressions.sort(byUrgency)
escalatingOut.sort(byUrgency)
newCritical.sort(byUrgency)
const ctx = { windowLabel: windowLabel(period) }
const make = (id, name, arr) => ({
id,
name,
issues: arr.map((issue) => ({
key: issue.key,
summary: issue.title,
plainEnglish: '',
reason: reasonFor(id, issue, ctx),
events: issue.events,
users: issue.users,
url: issue.url,
})),
})
const categories = []
if (regressions.length) categories.push(make('regressions', '🔄 Regressions', regressions))
if (escalatingOut.length) categories.push(make('escalating', '📈 Escalating', escalatingOut))
if (newCritical.length) categories.push(make('new-critical', '🆕 New Critical', newCritical))
return categories
}
// All project slugs in an org. Pages through the list (the API caps each page at
// 100) so orgs with many projects are fully represented in the dropdown. The
// loop is bounded and stops as soon as a page adds no new slugs, so it stays
// safe even if the underlying cursor doesn't advance. Throws SentryError on an
// auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
// Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
// the symbolic "next" cursor through global per-command state, so pages must not
// interleave with each other or with any other SDK call (a concurrent issue
// scan, another instance's discovery, a second traversal of this same org).
// runSerial holds the module-wide queue for the whole loop, which guarantees
// that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
// the queue we already hold.
return runSerial(() => listProjectsPaged(org, onPage))
}
async function listProjectsPaged(org, onPage) {
const seen = new Set()
const out = []
const PAGE = 100
const MAX_PAGES = 20
// Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
// take minutes to fully page through, leaving the autocomplete spinning. Stop
// once we've spent this long and return what we have — the client filters the
// collected slugs, and a few hundred is plenty to type against. With streaming
// (onPage) the first page is usable almost immediately regardless.
const BUDGET_MS = 8000
const started = Date.now()
let cursor
for (let page = 0; page < MAX_PAGES; page++) {
let raw
try {
raw = await projectListRaw(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
// not discard the projects we already collected. Surface the error only
// when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
if (out.length) break
throw err
}
let added = 0
for (const slug of mapProjects(raw)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
added++
}
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
if (raw.length < PAGE || added === 0) break
if (Date.now() - started > BUDGET_MS) break
cursor = 'next'
}
return out
}
// Verify a specific project slug exists / is accessible. The org's project list
// is capped, so a valid slug may not appear in it; project view resolves any
// slug directly. Returns the canonical slug, or '' if it doesn't exist / isn't
// accessible.
export async function findProject(org, slug) {
const wanted = String(slug || '').trim()
if (!wanted) return ''
try {
const project = await projectView(org, wanted)
const resolved = String(project?.slug || '').trim()
return resolved || wanted
} catch (err) {
if (err instanceof SentryError) return ''
throw err
}
}
// Per-query issue search. `limit` bounds a single call; the SDK auto-pages up to
// the SDK max (1000) to satisfy it. The primary board search and the targeted
// regression/escalation searches all pass an explicit bounded cap so an org with
// far more than 100 open/priority issues doesn't silently lose the remainder.
async function searchIssueList(orgProject, query, period, limit = 100) {
return mapIssues(await issueList({ orgProject, query, sort: 'date', limit, period }))
}
// Bounded safety cap for the primary `is:unresolved` board search. 100 (one API
// page) let a high-impact issue outside the 100 most-recently-seen fall off the
// board entirely; we page up to the SDK max so the "thousands of unresolved
// errors" the canvas promises are actually considered, while still bounding work.
const PRIMARY_LIMIT = 1000
// Upper bound for the targeted priority searches (regressed / escalating). These
// are the buckets we can't afford to truncate, so we ask for the SDK's max.
const PRIORITY_LIMIT = 1000
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Run a targeted priority search with one retry. A bare `catch {}` here would
// turn a transient/rate-limited failure into a silent "no regressions", so a
// real regression could be dropped or misbucketed while the scan still reports
// success. A permanent "unsupported filter" (400) means Sentry rejected this
// priority query, which is indistinguishable from a legitimately empty bucket —
// but these are the exact high-priority buckets the canvas must not silently
// drop, so record a warning (don't retry) rather than reporting a clean empty.
// On a persistent transport failure, log and record a warning too so the
// incompleteness isn't swallowed.
async function searchPriority(orgProject, query, period, warnings, label) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
return await searchIssueList(orgProject, query, period, PRIORITY_LIMIT)
} catch (err) {
const info = sentryErrorInfo(err)
if (info && info.code === 400) {
warnings.push(label)
console.error(`[sentry-triage] priority search rejected (400) (${label}):`, err instanceof Error ? err.message : err)
return []
}
if (attempt === 0) {
await delay(250)
continue
}
warnings.push(label)
console.error(`[sentry-triage] priority search failed (${label}):`, err instanceof Error ? err.message : err)
return []
}
}
return []
}
// Merge several issue lists into one, de-duplicating by key and keeping the
// first record seen (the primary list wins). Used so priority issues fetched by
// targeted searches still appear even when they fall outside the main list.
function mergeIssues(...lists) {
const seen = new Set()
const out = []
for (const list of lists) {
for (const issue of list) {
if (!issue || !issue.key || seen.has(issue.key)) continue
seen.add(issue.key)
out.push(issue)
}
}
return out
}
// Extract an HTTP-ish status code from a thrown SentryError so a permission/API
// failure isn't silently rendered as "0 issues" (an empty, all-clear board). The
// SDK carries the API message in `.message` / `.stderr`; a scoped read failure
// shows up as "(403)" / "403 Forbidden". Returns { code } or null.
export function sentryErrorInfo(err) {
if (!err) return null
const t = `${err.message || ''}\n${err.stderr || ''}`
const m = t.match(/\b(?:API error|HTTP)?\s*\(?(40\d|41\d|42\d|50\d)\)?\b/i)
if (m) return { code: Number(m[1]) }
if (/permission|forbidden|not authorized|unauthorized/i.test(t)) return { code: 403 }
return null
}
// Turn a detected Sentry error into a short, plain-English message for the board.
// A scoped 403 is the common case: the account can search the org but not that
// specific project.
export function describeScanError(info, project) {
const where = project ? `project "${project}"` : 'this organization'
if (info && info.code === 403) {
return project
? `Your Sentry account doesn't have access to project "${project}" (403). Try a project you have access to, or check your Sentry login.`
: `Your Sentry account doesn't have permission to read issues here (403). Check your Sentry login.`
}
if (info && info.code) return `Sentry returned an error (${info.code}) while reading ${where}. Try again shortly.`
return `Sentry returned an error while reading ${where}. Try again shortly.`
}
// Fetch + categorize live from Sentry over the requested time window (default
// 24h). The user can widen the window from the issues list, so we honor the
// chosen period exactly rather than silently auto-widening. Sentry's own
// is:regressed / is:escalating sets drive those buckets so they reflect real
// Sentry state rather than a heuristic guess.
//
// The primary `is:unresolved` search is paged up to a bounded cap (PRIMARY_LIMIT)
// and sorted by last-seen; a plain 100-item page could drop a high-impact issue
// that hasn't been seen recently. We additionally fetch the regressed /
// escalating sets and merge them in before categorizing — those two priority
// buckets are exactly the ones we can't afford to miss.
//
// Returns { categories, error, scanned, capped, older, olderCapped, warnings }:
// error is a user-facing string when Sentry refused the primary request (e.g. a
// scoped 403); scanned is the number of distinct open issues we examined; capped
// is true when the primary search hit PRIMARY_LIMIT (so more open issues likely
// exist beyond what we scanned); warnings is a (usually empty) list of
// non-fatal priority-bucket labels that failed to load, so a partially-complete
// scan isn't silently reported as all-clear; older counts unresolved issues that
// exist OUTSIDE the window (only computed when the window came back empty) so the
// empty state can point the user at a wider range.
export async function scanIssues(org, project, period = '24h') {
const win = typeof period === 'string' && period ? period : '24h'
// The Sentry CLI treats a bare slug as a *project*, so an org-wide scan must
// pass the `<org>/` form; a scoped scan keeps the `<org>/<project>` form.
const orgProject = project ? `${org}/${project}` : `${String(org || '').replace(/\/+$/, '')}/`
// A tool error on the primary search means we can't trust anything below it —
// surface it instead of falling through to an empty board.
let issues
try {
issues = await searchIssueList(orgProject, 'is:unresolved', win, PRIMARY_LIMIT)
} catch (err) {
return { categories: [], error: describeScanError(sentryErrorInfo(err), project), scanned: 0, capped: false, warnings: [] }
}
const capped = issues.length >= PRIMARY_LIMIT
const warnings = []
const regressedIssues = await searchPriority(orgProject, 'is:unresolved is:regressed', win, warnings, 'regressed issues')
const escalatingIssues = await searchPriority(orgProject, 'is:unresolved is:escalating', win, warnings, 'escalating issues')
const regressed = new Set(regressedIssues.map((i) => i.key))
const escalating = new Set(escalatingIssues.map((i) => i.key))
// Union the priority issues in so an important regression/escalation outside
// the 100-most-recent primary list still gets surfaced. Main list first so its
// records win on dedup; categorize re-sorts each bucket by impact anyway.
const merged = mergeIssues(issues, regressedIssues, escalatingIssues)
// When the chosen window is empty, the project may still have unresolved
// issues that are simply older than the window (a common source of "but I know
// there are issues here!" confusion). Do one wider look-back so the empty state
// can say how many exist outside the window and nudge the user to widen it.
// Only when the window itself came back empty (so anything the wide search
// finds is genuinely outside it) and only when a wider window exists.
let older = 0
let olderCapped = false
if (merged.length === 0 && win !== '90d') {
try {
const wide = await searchIssueList(orgProject, 'is:unresolved', '90d', PRIMARY_LIMIT)
older = wide.length
olderCapped = wide.length >= PRIMARY_LIMIT
} catch {
/* best-effort — leave older at 0 */
}
}
return {
categories: categorize({ issues: merged, regressed, escalating, period: win }),
error: null,
scanned: merged.length,
capped,
older,
olderCapped,
warnings,
}
}