|
const SENTRY_ORG_SLUG = ''; |
|
|
|
type JsonValue = |
|
| null |
|
| boolean |
|
| number |
|
| string |
|
| JsonValue[] |
|
| { [key: string]: JsonValue }; |
|
|
|
type CliFlags = Record<string, string | string[] | boolean>; |
|
|
|
type ParsedArgs = { |
|
command: string; |
|
positionals: string[]; |
|
flags: CliFlags; |
|
}; |
|
|
|
type SentryIssue = { |
|
id?: string | number; |
|
shortId?: string; |
|
title?: string; |
|
culprit?: string; |
|
level?: string; |
|
status?: string; |
|
permalink?: string; |
|
project?: { |
|
id?: string | number; |
|
name?: string; |
|
slug?: string; |
|
} | null; |
|
metadata?: Record<string, unknown> | null; |
|
firstSeen?: string; |
|
lastSeen?: string; |
|
count?: string | number; |
|
userCount?: number; |
|
type?: string; |
|
assignedTo?: { |
|
id?: string | number; |
|
name?: string | null; |
|
email?: string | null; |
|
} | null; |
|
}; |
|
|
|
type SentryEvent = { |
|
id?: string; |
|
eventID?: string; |
|
groupID?: string | number; |
|
title?: string; |
|
message?: string; |
|
platform?: string; |
|
type?: string; |
|
level?: string; |
|
dateCreated?: string; |
|
tags?: Array<{ key?: string; value?: string }>; |
|
contexts?: Record<string, unknown>; |
|
entries?: Array<{ |
|
type?: string; |
|
data?: Record<string, unknown>; |
|
}>; |
|
metadata?: Record<string, unknown> | null; |
|
}; |
|
|
|
type SentryTagValue = { |
|
key?: string; |
|
name?: string; |
|
value?: string; |
|
count?: number; |
|
firstSeen?: string; |
|
lastSeen?: string; |
|
}; |
|
|
|
type SentryIssueHash = { |
|
id?: string; |
|
count?: number; |
|
firstSeen?: string; |
|
lastSeen?: string; |
|
latestEvent?: { |
|
id?: string; |
|
eventID?: string; |
|
title?: string; |
|
message?: string; |
|
metadata?: Record<string, unknown> | null; |
|
tags?: Array<{ key?: string; value?: string }>; |
|
}; |
|
}; |
|
|
|
class SentryApiError extends Error { |
|
status: number; |
|
body: unknown; |
|
|
|
constructor(message: string, status: number, body: unknown) { |
|
super(message); |
|
this.name = 'SentryApiError'; |
|
this.status = status; |
|
this.body = body; |
|
} |
|
} |
|
|
|
function parseArgs(argv: string[]): ParsedArgs { |
|
const flags: CliFlags = {}; |
|
const positionals: string[] = []; |
|
|
|
for (let index = 0; index < argv.length; index += 1) { |
|
const current = argv[index]; |
|
if (!current.startsWith('--')) { |
|
positionals.push(current); |
|
continue; |
|
} |
|
|
|
const flag = current.slice(2); |
|
const equalsIndex = flag.indexOf('='); |
|
if (equalsIndex >= 0) { |
|
const key = flag.slice(0, equalsIndex); |
|
const value = flag.slice(equalsIndex + 1); |
|
appendFlag(flags, key, value); |
|
continue; |
|
} |
|
|
|
const next = argv[index + 1]; |
|
if (next && !next.startsWith('--')) { |
|
appendFlag(flags, flag, next); |
|
index += 1; |
|
continue; |
|
} |
|
|
|
appendFlag(flags, flag, true); |
|
} |
|
|
|
const [command = 'help', ...rest] = positionals; |
|
return { |
|
command, |
|
positionals: rest, |
|
flags |
|
}; |
|
} |
|
|
|
function appendFlag(flags: CliFlags, key: string, value: string | boolean) { |
|
const existing = flags[key]; |
|
if (existing === undefined) { |
|
flags[key] = value; |
|
return; |
|
} |
|
|
|
if (Array.isArray(existing)) { |
|
existing.push(String(value)); |
|
return; |
|
} |
|
|
|
flags[key] = [String(existing), String(value)]; |
|
} |
|
|
|
function getFlag(flags: CliFlags, key: string): string | undefined { |
|
const value = flags[key]; |
|
if (Array.isArray(value)) { |
|
return value.at(-1); |
|
} |
|
if (typeof value === 'string') { |
|
return value; |
|
} |
|
return undefined; |
|
} |
|
|
|
function getFlagValues(flags: CliFlags, key: string): string[] { |
|
const value = flags[key]; |
|
if (Array.isArray(value)) { |
|
return value.map(String); |
|
} |
|
if (typeof value === 'string') { |
|
return [value]; |
|
} |
|
return []; |
|
} |
|
|
|
function hasFlag(flags: CliFlags, key: string): boolean { |
|
const value = flags[key]; |
|
if (typeof value === 'boolean') return value; |
|
if (typeof value === 'string') { |
|
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase()); |
|
} |
|
return false; |
|
} |
|
|
|
function requireEnv(name: string): string { |
|
const value = process.env[name]?.trim(); |
|
if (!value) { |
|
throw new Error(`Missing required environment variable: ${name}`); |
|
} |
|
return value; |
|
} |
|
|
|
function normalizeBaseUrl(value?: string): string { |
|
const base = value?.trim() || 'https://sentry.io/api/0/'; |
|
return base.endsWith('/') ? base : `${base}/`; |
|
} |
|
|
|
function parseIssueIdFromInput(value: string): string | null { |
|
const trimmed = value.trim(); |
|
if (!trimmed) return null; |
|
if (/^\d+$/.test(trimmed)) return trimmed; |
|
|
|
try { |
|
const url = new URL(trimmed); |
|
const match = url.pathname.match(/\/issues\/(\d+)\b/); |
|
return match?.[1] ?? null; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function parseJsonBody(text: string): unknown { |
|
if (!text) return null; |
|
try { |
|
return JSON.parse(text) as JsonValue; |
|
} catch { |
|
return text; |
|
} |
|
} |
|
|
|
class SentryClient { |
|
private readonly token: string; |
|
private readonly orgSlug: string; |
|
private readonly baseUrl: string; |
|
|
|
constructor(config: { token: string; orgSlug: string; baseUrl?: string }) { |
|
this.token = config.token; |
|
this.orgSlug = config.orgSlug; |
|
this.baseUrl = normalizeBaseUrl(config.baseUrl); |
|
} |
|
|
|
async request<T>( |
|
pathname: string, |
|
options?: { |
|
method?: 'GET' | 'POST'; |
|
query?: Record<string, string | boolean | number | undefined>; |
|
body?: Record<string, unknown>; |
|
} |
|
): Promise<T> { |
|
const url = new URL(pathname.replace(/^\//, ''), this.baseUrl); |
|
|
|
for (const [key, value] of Object.entries(options?.query ?? {})) { |
|
if (value === undefined) continue; |
|
url.searchParams.set(key, String(value)); |
|
} |
|
|
|
const response = await fetch(url, { |
|
method: options?.method ?? 'GET', |
|
headers: { |
|
Accept: 'application/json', |
|
Authorization: `Bearer ${this.token}`, |
|
...(options?.body ? { 'Content-Type': 'application/json' } : {}) |
|
}, |
|
body: options?.body ? JSON.stringify(options.body) : undefined |
|
}); |
|
|
|
const text = await response.text(); |
|
const body = parseJsonBody(text); |
|
|
|
if (!response.ok) { |
|
throw new SentryApiError( |
|
`Sentry API request failed with ${response.status} ${response.statusText}`, |
|
response.status, |
|
body |
|
); |
|
} |
|
|
|
return body as T; |
|
} |
|
|
|
async resolveIssueId(issueRef: string): Promise<string> { |
|
const directId = parseIssueIdFromInput(issueRef); |
|
if (directId) return directId; |
|
|
|
const query = issueRef.trim(); |
|
if (!query) { |
|
throw new Error('Missing issue reference.'); |
|
} |
|
|
|
const issues = await this.request<SentryIssue[]>( |
|
`organizations/${this.orgSlug}/issues/`, |
|
{ |
|
query: { |
|
query, |
|
shortIdLookup: 1 |
|
} |
|
} |
|
); |
|
|
|
const exact = |
|
issues.find((issue) => issue.shortId?.toLowerCase() === query.toLowerCase()) ?? issues[0]; |
|
|
|
if (!exact?.id) { |
|
throw new Error( |
|
`Could not resolve issue reference "${issueRef}". Pass a numeric issue id, issue URL, or short id.` |
|
); |
|
} |
|
|
|
return String(exact.id); |
|
} |
|
|
|
async getIssue(issueId: string) { |
|
return this.request<SentryIssue>(`organizations/${this.orgSlug}/issues/${issueId}/`); |
|
} |
|
|
|
async listIssues(options?: { |
|
query?: string; |
|
limit?: number; |
|
project?: string[]; |
|
environment?: string[]; |
|
}) { |
|
const query: Record<string, string | boolean | number | undefined> = {}; |
|
if (options?.query) { |
|
query.query = options.query; |
|
} |
|
if (options?.limit !== undefined) { |
|
query.limit = options.limit; |
|
} |
|
if ((options?.project?.length ?? 0) > 0) { |
|
query.project = options?.project?.join(','); |
|
} |
|
if ((options?.environment?.length ?? 0) > 0) { |
|
query.environment = options?.environment?.join(','); |
|
} |
|
|
|
return this.request<SentryIssue[]>(`organizations/${this.orgSlug}/issues/`, { query }); |
|
} |
|
|
|
async listIssueEvents(issueId: string, options?: { full?: boolean; environment?: string[] }) { |
|
const query: Record<string, string | boolean | number | undefined> = {}; |
|
if (options?.full) { |
|
query.full = true; |
|
} |
|
|
|
const environments = options?.environment ?? []; |
|
if (environments.length > 0) { |
|
query.environment = environments.join(','); |
|
} |
|
|
|
return this.request<SentryEvent[]>( |
|
`organizations/${this.orgSlug}/issues/${issueId}/events/`, |
|
{ query } |
|
); |
|
} |
|
|
|
async getIssueEvent(issueId: string, eventId: string, options?: { environment?: string[] }) { |
|
const query: Record<string, string | boolean | number | undefined> = {}; |
|
const environments = options?.environment ?? []; |
|
if (environments.length > 0) { |
|
query.environment = environments.join(','); |
|
} |
|
|
|
return this.request<SentryEvent>( |
|
`organizations/${this.orgSlug}/issues/${issueId}/events/${eventId}/`, |
|
{ query } |
|
); |
|
} |
|
|
|
async getIssueTagValues( |
|
issueId: string, |
|
key: string, |
|
options?: { sort?: string; environment?: string[] } |
|
) { |
|
const query: Record<string, string | boolean | number | undefined> = {}; |
|
if (options?.sort) { |
|
query.sort = options.sort; |
|
} |
|
const environments = options?.environment ?? []; |
|
if (environments.length > 0) { |
|
query.environment = environments.join(','); |
|
} |
|
|
|
return this.request<SentryTagValue[]>( |
|
`organizations/${this.orgSlug}/issues/${issueId}/tags/${encodeURIComponent(key)}/values/`, |
|
{ query } |
|
); |
|
} |
|
|
|
async getIssueHashes(issueId: string, options?: { full?: boolean }) { |
|
const query: Record<string, string | boolean | number | undefined> = {}; |
|
if (options?.full) { |
|
query.full = 1; |
|
} |
|
|
|
return this.request<SentryIssueHash[]>( |
|
`organizations/${this.orgSlug}/issues/${issueId}/hashes/`, |
|
{ query } |
|
); |
|
} |
|
|
|
async getReplayCount(options: { |
|
query: string; |
|
statsPeriod?: string; |
|
project?: string[]; |
|
environment?: string[]; |
|
}) { |
|
const query: Record<string, string | boolean | number | undefined> = { |
|
query: options.query |
|
}; |
|
if (options.statsPeriod) { |
|
query.statsPeriod = options.statsPeriod; |
|
} |
|
if ((options.project?.length ?? 0) > 0) { |
|
query.project_id_or_slug = options.project?.join(','); |
|
} |
|
if ((options.environment?.length ?? 0) > 0) { |
|
query.environment = options.environment?.join(','); |
|
} |
|
|
|
return this.request<Record<string, number>>( |
|
`organizations/${this.orgSlug}/replay-count/`, |
|
{ query } |
|
); |
|
} |
|
|
|
async getSourceMapDebug(options: { |
|
project: string; |
|
eventId: string; |
|
exceptionIndex: number; |
|
frameIndex: number; |
|
}) { |
|
return this.request<unknown>( |
|
`projects/${this.orgSlug}/${options.project}/events/${options.eventId}/source-map-debug/`, |
|
{ |
|
query: { |
|
exception_idx: options.exceptionIndex, |
|
frame_idx: options.frameIndex |
|
} |
|
} |
|
); |
|
} |
|
|
|
async queryExplore(options: { |
|
dataset: 'logs' | 'spans'; |
|
query?: string; |
|
fields: string[]; |
|
limit?: number; |
|
sort?: string; |
|
statsPeriod?: string; |
|
}) { |
|
const query: Record<string, string | boolean | number | undefined> = { |
|
dataset: options.dataset, |
|
per_page: options.limit ?? 10 |
|
}; |
|
if (options.query) { |
|
query.query = options.query; |
|
} |
|
if (options.sort) { |
|
query.sort = options.sort; |
|
} |
|
if (options.statsPeriod) { |
|
query.statsPeriod = options.statsPeriod; |
|
} |
|
|
|
const url = new URL(`organizations/${this.orgSlug}/events/`, this.baseUrl); |
|
for (const [key, value] of Object.entries(query)) { |
|
if (value === undefined) continue; |
|
url.searchParams.set(key, String(value)); |
|
} |
|
for (const field of options.fields) { |
|
url.searchParams.append('field', field); |
|
} |
|
|
|
const response = await fetch(url, { |
|
method: 'GET', |
|
headers: { |
|
Accept: 'application/json', |
|
Authorization: `Bearer ${this.token}` |
|
} |
|
}); |
|
|
|
const text = await response.text(); |
|
const body = parseJsonBody(text); |
|
|
|
if (!response.ok) { |
|
throw new SentryApiError( |
|
`Sentry API request failed with ${response.status} ${response.statusText}`, |
|
response.status, |
|
body |
|
); |
|
} |
|
|
|
return body as { |
|
data: Array<Record<string, unknown>>; |
|
meta?: Record<string, unknown>; |
|
}; |
|
} |
|
} |
|
|
|
function extractExceptionSummary(event: SentryEvent | null | undefined) { |
|
if (!event?.entries) return null; |
|
|
|
const exceptionEntry = event.entries.find((entry) => entry.type === 'exception'); |
|
const values = Array.isArray(exceptionEntry?.data?.values) |
|
? (exceptionEntry?.data?.values as Array<Record<string, unknown>>) |
|
: []; |
|
|
|
if (values.length === 0) return null; |
|
|
|
const primary = values[0]; |
|
const stacktrace = primary.stacktrace as Record<string, unknown> | undefined; |
|
const frames = Array.isArray(stacktrace?.frames) |
|
? (stacktrace?.frames as Array<Record<string, unknown>>) |
|
: []; |
|
|
|
const inAppFrames = frames.filter((frame) => frame.inApp !== false); |
|
const selectedFrames = (inAppFrames.length > 0 ? inAppFrames : frames).slice(-8); |
|
|
|
return { |
|
type: typeof primary.type === 'string' ? primary.type : null, |
|
value: typeof primary.value === 'string' ? primary.value : null, |
|
mechanism: |
|
typeof (primary.mechanism as Record<string, unknown> | undefined)?.type === 'string' |
|
? ((primary.mechanism as Record<string, unknown>).type as string) |
|
: null, |
|
frames: selectedFrames.map((frame) => ({ |
|
function: |
|
typeof frame.function === 'string' |
|
? frame.function |
|
: typeof frame.raw_function === 'string' |
|
? frame.raw_function |
|
: null, |
|
file: |
|
typeof frame.filename === 'string' |
|
? frame.filename |
|
: typeof frame.absPath === 'string' |
|
? frame.absPath |
|
: null, |
|
line: typeof frame.lineNo === 'number' ? frame.lineNo : null, |
|
column: typeof frame.colNo === 'number' ? frame.colNo : null, |
|
inApp: frame.inApp !== false |
|
})) |
|
}; |
|
} |
|
|
|
function extractBreadcrumbSummary(event: SentryEvent | null | undefined) { |
|
if (!event?.entries) return []; |
|
|
|
const breadcrumbEntry = event.entries.find((entry) => entry.type === 'breadcrumbs'); |
|
const values = Array.isArray(breadcrumbEntry?.data?.values) |
|
? (breadcrumbEntry.data?.values as Array<Record<string, unknown>>) |
|
: []; |
|
|
|
return values.slice(-10).map((crumb) => ({ |
|
timestamp: typeof crumb.timestamp === 'string' ? crumb.timestamp : null, |
|
type: typeof crumb.type === 'string' ? crumb.type : null, |
|
category: typeof crumb.category === 'string' ? crumb.category : null, |
|
level: typeof crumb.level === 'string' ? crumb.level : null, |
|
message: typeof crumb.message === 'string' ? crumb.message : null, |
|
data: |
|
crumb.data && typeof crumb.data === 'object' |
|
? (crumb.data as Record<string, unknown>) |
|
: null |
|
})); |
|
} |
|
|
|
function extractTraceSummary(event: SentryEvent | null | undefined) { |
|
const trace = (event?.contexts?.trace as Record<string, unknown> | undefined) ?? null; |
|
if (!trace) return null; |
|
|
|
return { |
|
traceId: typeof trace.trace_id === 'string' ? trace.trace_id : null, |
|
spanId: typeof trace.span_id === 'string' ? trace.span_id : null, |
|
op: typeof trace.op === 'string' ? trace.op : null, |
|
status: typeof trace.status === 'string' ? trace.status : null |
|
}; |
|
} |
|
|
|
function summarizeEvent(event: SentryEvent | null | undefined) { |
|
if (!event) return null; |
|
|
|
return { |
|
id: event.id ?? null, |
|
eventID: event.eventID ?? null, |
|
title: event.title ?? null, |
|
message: event.message ?? null, |
|
platform: event.platform ?? null, |
|
type: event.type ?? null, |
|
dateCreated: event.dateCreated ?? null, |
|
metadata: event.metadata ?? null, |
|
trace: extractTraceSummary(event), |
|
exception: extractExceptionSummary(event), |
|
breadcrumbs: extractBreadcrumbSummary(event), |
|
tags: (event.tags ?? []).map((tag) => ({ |
|
key: tag.key ?? null, |
|
value: tag.value ?? null |
|
})) |
|
}; |
|
} |
|
|
|
function printHelp() { |
|
console.log(` |
|
Local Sentry CLI |
|
|
|
Usage: |
|
pnpm agent:sentry <command> [flags] |
|
|
|
Commands: |
|
list-issues Browse recent issues in the org |
|
query-logs Query Sentry logs through the Explore API |
|
query-traces Query Sentry spans through the Explore API |
|
get-issue Fetch a Sentry issue by numeric ID, issue URL, or short ID |
|
issue-tags List values for a specific issue tag key |
|
issue-hashes List an issue's grouping hashes |
|
replay-count Count replays for an issue |
|
source-map-debug Ask Sentry why a specific event frame failed sourcemap resolution |
|
list-events Fetch recent events for an issue |
|
get-event Fetch a specific issue event, or "recommended", "latest", "oldest" |
|
bundle Build a normalized JSON bundle for a Codex-style debugging prompt |
|
|
|
Environment: |
|
SENTRY_AGENT_TOKEN Required bearer token |
|
|
|
Common flags: |
|
--query=<value> Search query for list-issues |
|
--field=<value> Repeatable Explore field selection |
|
--issue=<value> Numeric issue ID, issue URL, or short ID |
|
--project=<slug> Repeatable project filter for list-issues |
|
--key=<value> Tag key for issue-tags |
|
--event=<value> Event ID or one of recommended/latest/oldest |
|
--stats-period=<v> Replay window, for example 24h or 7d |
|
--sort=<value> Explore sort field, for example -timestamp |
|
--exception-idx=<n> Exception index for source-map-debug |
|
--frame-idx=<n> Frame index for source-map-debug |
|
--environment=<env> Repeatable environment filter |
|
--json Print raw JSON where supported |
|
|
|
Examples: |
|
pnpm agent:sentry list-issues --limit=10 |
|
pnpm agent:sentry list-issues --project=engine --query='is:unresolved' |
|
pnpm agent:sentry query-logs --query='severity:warn project.name:engine' |
|
pnpm agent:sentry query-traces --query='trace:67bc386862b3534c08713e1ad3a98d72' |
|
pnpm agent:sentry get-issue --issue=ENG-123 |
|
pnpm agent:sentry issue-tags --issue=APP-5 --key=release |
|
pnpm agent:sentry issue-hashes --issue=APP-5 |
|
pnpm agent:sentry replay-count --issue=APP-5 --stats-period=7d |
|
pnpm agent:sentry source-map-debug --project=app --event=<event_id> --exception-idx=0 --frame-idx=0 |
|
pnpm agent:sentry list-events --issue=https://sentry.io/organizations/acme/issues/123/ --json |
|
pnpm agent:sentry get-event --issue=123 --event=recommended |
|
pnpm agent:sentry bundle --issue=123 > sentry-bundle.json |
|
`.trim()); |
|
} |
|
|
|
function printPrettyIssueList(issues: SentryIssue[]) { |
|
if (issues.length === 0) { |
|
console.log('No issues returned.'); |
|
return; |
|
} |
|
|
|
for (const [index, issue] of issues.entries()) { |
|
console.log( |
|
`${index + 1}. ${issue.shortId ?? issue.id ?? 'unknown'} | ${issue.status ?? 'n/a'} | ${issue.level ?? 'n/a'} | ${issue.project?.slug ?? issue.project?.name ?? 'n/a'} | ${issue.title ?? 'Untitled'}` |
|
); |
|
} |
|
} |
|
|
|
function printPrettyTagValues(values: SentryTagValue[]) { |
|
if (values.length === 0) { |
|
console.log('No tag values returned.'); |
|
return; |
|
} |
|
|
|
for (const [index, value] of values.entries()) { |
|
console.log( |
|
`${index + 1}. ${value.value ?? value.name ?? value.key ?? 'unknown'} | count=${value.count ?? 0} | firstSeen=${value.firstSeen ?? 'n/a'} | lastSeen=${value.lastSeen ?? 'n/a'}` |
|
); |
|
} |
|
} |
|
|
|
function printPrettyIssueHashes(hashes: SentryIssueHash[]) { |
|
if (hashes.length === 0) { |
|
console.log('No hashes returned.'); |
|
return; |
|
} |
|
|
|
for (const [index, hash] of hashes.entries()) { |
|
console.log( |
|
`${index + 1}. ${hash.id ?? 'unknown'} | latestEvent=${hash.latestEvent?.eventID ?? hash.latestEvent?.id ?? 'n/a'} | ${hash.latestEvent?.title ?? hash.latestEvent?.message ?? 'Untitled'}` |
|
); |
|
} |
|
} |
|
|
|
function printPrettyExploreRows(rows: Array<Record<string, unknown>>, fields: string[]) { |
|
if (rows.length === 0) { |
|
console.log('No rows returned.'); |
|
return; |
|
} |
|
|
|
for (const [index, row] of rows.entries()) { |
|
const summary = fields |
|
.map((field) => `${field}=${formatExploreValue(row[field])}`) |
|
.join(' | '); |
|
console.log(`${index + 1}. ${summary}`); |
|
} |
|
} |
|
|
|
function formatExploreValue(value: unknown) { |
|
if (value === null || value === undefined) return 'null'; |
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { |
|
return String(value); |
|
} |
|
return JSON.stringify(value); |
|
} |
|
|
|
function printPrettyIssue(issue: SentryIssue) { |
|
console.log(`Issue ${issue.shortId ?? issue.id ?? 'unknown'}`); |
|
console.log(`Title: ${issue.title ?? 'n/a'}`); |
|
console.log(`Status: ${issue.status ?? 'n/a'}`); |
|
console.log(`Level: ${issue.level ?? 'n/a'}`); |
|
console.log(`Project: ${issue.project?.slug ?? issue.project?.name ?? 'n/a'}`); |
|
console.log(`First seen: ${issue.firstSeen ?? 'n/a'}`); |
|
console.log(`Last seen: ${issue.lastSeen ?? 'n/a'}`); |
|
console.log(`Count: ${issue.count ?? 'n/a'}`); |
|
console.log(`Users: ${issue.userCount ?? 'n/a'}`); |
|
console.log(`Culprit: ${issue.culprit ?? 'n/a'}`); |
|
if (issue.permalink) { |
|
console.log(`Permalink: ${issue.permalink}`); |
|
} |
|
if (issue.assignedTo) { |
|
console.log( |
|
`Assigned to: ${issue.assignedTo.name ?? issue.assignedTo.email ?? issue.assignedTo.id ?? 'n/a'}` |
|
); |
|
} |
|
} |
|
|
|
function printPrettyEvents(events: SentryEvent[]) { |
|
if (events.length === 0) { |
|
console.log('No events returned.'); |
|
return; |
|
} |
|
|
|
for (const [index, event] of events.entries()) { |
|
console.log( |
|
`${index + 1}. ${event.eventID ?? event.id ?? 'unknown'} | ${event.dateCreated ?? 'n/a'} | ${event.title ?? event.message ?? 'Untitled'}` |
|
); |
|
} |
|
} |
|
|
|
function printPrettyEvent(event: SentryEvent) { |
|
const summary = summarizeEvent(event); |
|
console.log(`Event ${summary?.eventID ?? summary?.id ?? 'unknown'}`); |
|
console.log(`Title: ${summary?.title ?? 'n/a'}`); |
|
console.log(`Date: ${summary?.dateCreated ?? 'n/a'}`); |
|
console.log(`Platform: ${summary?.platform ?? 'n/a'}`); |
|
console.log(`Type: ${summary?.type ?? 'n/a'}`); |
|
if (summary?.trace?.traceId) { |
|
console.log(`Trace: ${summary.trace.traceId}`); |
|
} |
|
if (summary?.exception?.type || summary?.exception?.value) { |
|
console.log( |
|
`Exception: ${summary.exception.type ?? 'unknown'}${summary.exception.value ? `: ${summary.exception.value}` : ''}` |
|
); |
|
} |
|
if ((summary?.exception?.frames?.length ?? 0) > 0) { |
|
console.log('Frames:'); |
|
for (const frame of summary!.exception!.frames) { |
|
console.log( |
|
` - ${frame.function ?? '<anonymous>'} (${frame.file ?? 'unknown'}:${frame.line ?? '?'})` |
|
); |
|
} |
|
} |
|
} |
|
|
|
function printJson(value: unknown) { |
|
console.log(JSON.stringify(value, null, 2)); |
|
} |
|
|
|
function getOptionalIssueFlag(flags: CliFlags) { |
|
const issue = getFlag(flags, 'issue') ?? ''; |
|
if (!issue) { |
|
throw new Error('Missing issue reference. Provide --issue.'); |
|
} |
|
return issue; |
|
} |
|
|
|
function getEnvironmentFilters(flags: CliFlags) { |
|
return getFlagValues(flags, 'environment'); |
|
} |
|
|
|
function requireFlag(flags: CliFlags, key: string, message?: string) { |
|
const value = getFlag(flags, key); |
|
if (!value) { |
|
throw new Error(message ?? `Missing required flag --${key}.`); |
|
} |
|
return value; |
|
} |
|
|
|
async function main() { |
|
const { command, flags } = parseArgs(process.argv.slice(2)); |
|
|
|
if (command === 'help' || hasFlag(flags, 'help')) { |
|
printHelp(); |
|
return; |
|
} |
|
|
|
const client = new SentryClient({ |
|
token: requireEnv('SENTRY_AGENT_TOKEN'), |
|
orgSlug: SENTRY_ORG_SLUG, |
|
baseUrl: 'https://sentry.io/api/0/' |
|
}); |
|
|
|
const json = hasFlag(flags, 'json'); |
|
const environments = getEnvironmentFilters(flags); |
|
const projects = getFlagValues(flags, 'project'); |
|
const exploreFields = getFlagValues(flags, 'field'); |
|
|
|
switch (command) { |
|
case 'list-issues': { |
|
const limit = Number(getFlag(flags, 'limit') ?? '10'); |
|
const issues = await client.listIssues({ |
|
query: getFlag(flags, 'query'), |
|
limit: Number.isFinite(limit) ? Math.max(limit, 1) : 10, |
|
project: projects, |
|
environment: environments |
|
}); |
|
if (json) { |
|
printJson(issues); |
|
} else { |
|
printPrettyIssueList(issues); |
|
} |
|
return; |
|
} |
|
|
|
case 'query-logs': { |
|
const fields = |
|
exploreFields.length > 0 |
|
? exploreFields |
|
: ['timestamp', 'project.name', 'severity_text', 'trace', 'message']; |
|
const result = await client.queryExplore({ |
|
dataset: 'logs', |
|
query: getFlag(flags, 'query'), |
|
fields, |
|
limit: Number(getFlag(flags, 'limit') ?? '10'), |
|
sort: getFlag(flags, 'sort') ?? '-timestamp', |
|
statsPeriod: getFlag(flags, 'stats-period') |
|
}); |
|
if (json) { |
|
printJson(result); |
|
} else { |
|
printPrettyExploreRows(result.data, fields); |
|
} |
|
return; |
|
} |
|
|
|
case 'query-traces': { |
|
const fields = |
|
exploreFields.length > 0 |
|
? exploreFields |
|
: ['timestamp', 'project.name', 'transaction', 'span.op', 'span.duration', 'trace', 'id']; |
|
const result = await client.queryExplore({ |
|
dataset: 'spans', |
|
query: getFlag(flags, 'query'), |
|
fields, |
|
limit: Number(getFlag(flags, 'limit') ?? '10'), |
|
sort: getFlag(flags, 'sort') ?? '-timestamp', |
|
statsPeriod: getFlag(flags, 'stats-period') |
|
}); |
|
if (json) { |
|
printJson(result); |
|
} else { |
|
printPrettyExploreRows(result.data, fields); |
|
} |
|
return; |
|
} |
|
|
|
case 'get-issue': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const issue = await client.getIssue(issueId); |
|
if (json) { |
|
printJson(issue); |
|
} else { |
|
printPrettyIssue(issue); |
|
} |
|
return; |
|
} |
|
|
|
case 'issue-tags': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const key = requireFlag(flags, 'key', 'Missing tag key. Provide --key.'); |
|
const values = await client.getIssueTagValues(issueId, key, { |
|
sort: getFlag(flags, 'sort'), |
|
environment: environments |
|
}); |
|
if (json) { |
|
printJson(values); |
|
} else { |
|
printPrettyTagValues(values); |
|
} |
|
return; |
|
} |
|
|
|
case 'issue-hashes': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const hashes = await client.getIssueHashes(issueId, { full: hasFlag(flags, 'full') }); |
|
if (json) { |
|
printJson(hashes); |
|
} else { |
|
printPrettyIssueHashes(hashes); |
|
} |
|
return; |
|
} |
|
|
|
case 'replay-count': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const result = await client.getReplayCount({ |
|
query: `issue.id:["${issueId}"]`, |
|
statsPeriod: getFlag(flags, 'stats-period') ?? '14d', |
|
project: projects, |
|
environment: environments |
|
}); |
|
if (json) { |
|
printJson(result); |
|
} else { |
|
console.log(`Replay count for issue ${issueRef}: ${result[issueId] ?? 0}`); |
|
} |
|
return; |
|
} |
|
|
|
case 'source-map-debug': { |
|
const project = requireFlag(flags, 'project', 'Missing project slug. Provide --project.'); |
|
const eventId = requireFlag(flags, 'event', 'Missing event id. Provide --event.'); |
|
const exceptionIndex = Number(getFlag(flags, 'exception-idx') ?? '0'); |
|
const frameIndex = Number(getFlag(flags, 'frame-idx') ?? '0'); |
|
const result = await client.getSourceMapDebug({ |
|
project, |
|
eventId, |
|
exceptionIndex, |
|
frameIndex |
|
}); |
|
printJson(result); |
|
return; |
|
} |
|
|
|
case 'list-events': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const limit = Number(getFlag(flags, 'limit') ?? '5'); |
|
const events = await client.listIssueEvents(issueId, { |
|
full: hasFlag(flags, 'full'), |
|
environment: environments |
|
}); |
|
const sliced = events.slice(0, Number.isFinite(limit) ? Math.max(limit, 0) : 5); |
|
if (json) { |
|
printJson(sliced); |
|
} else { |
|
printPrettyEvents(sliced); |
|
} |
|
return; |
|
} |
|
|
|
case 'get-event': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const eventId = getFlag(flags, 'event') ?? 'recommended'; |
|
const event = await client.getIssueEvent(issueId, eventId, { |
|
environment: environments |
|
}); |
|
if (json) { |
|
printJson(event); |
|
} else { |
|
printPrettyEvent(event); |
|
} |
|
return; |
|
} |
|
|
|
case 'bundle': { |
|
const issueRef = getOptionalIssueFlag(flags); |
|
const issueId = await client.resolveIssueId(issueRef); |
|
const issue = await client.getIssue(issueId); |
|
const [recommendedEvent, recentEvents] = await Promise.all([ |
|
client.getIssueEvent(issueId, getFlag(flags, 'event') ?? 'recommended', { |
|
environment: environments |
|
}), |
|
client.listIssueEvents(issueId, { full: false, environment: environments }) |
|
]); |
|
|
|
const bundle = { |
|
organization: SENTRY_ORG_SLUG, |
|
issue: { |
|
id: issue.id ?? null, |
|
shortId: issue.shortId ?? null, |
|
title: issue.title ?? null, |
|
status: issue.status ?? null, |
|
level: issue.level ?? null, |
|
type: issue.type ?? null, |
|
project: issue.project ?? null, |
|
firstSeen: issue.firstSeen ?? null, |
|
lastSeen: issue.lastSeen ?? null, |
|
culprit: issue.culprit ?? null, |
|
permalink: issue.permalink ?? null, |
|
metadata: issue.metadata ?? null |
|
}, |
|
recommendedEvent: summarizeEvent(recommendedEvent), |
|
recentEvents: recentEvents.slice(0, 5).map((event) => ({ |
|
id: event.id ?? null, |
|
eventID: event.eventID ?? null, |
|
title: event.title ?? null, |
|
message: event.message ?? null, |
|
dateCreated: event.dateCreated ?? null, |
|
metadata: event.metadata ?? null |
|
})) |
|
}; |
|
|
|
printJson(bundle); |
|
return; |
|
} |
|
|
|
default: |
|
throw new Error(`Unknown command "${command}". Run with "help" to see supported commands.`); |
|
} |
|
} |
|
|
|
main().catch((error) => { |
|
if (error instanceof SentryApiError) { |
|
console.error(error.message); |
|
console.error(JSON.stringify(error.body, null, 2)); |
|
process.exit(1); |
|
} |
|
|
|
if (error instanceof Error) { |
|
console.error(error.message); |
|
process.exit(1); |
|
} |
|
|
|
console.error(String(error)); |
|
process.exit(1); |
|
}); |