← Public packages
@kentcdodds/sentry
Inspect Sentry organizations, projects, issues, and event summaries.
src/client.ts
322 lines · 9.8 KB · TypeScriptimport {
type QueryInput,
listorganizationissues,
listorganizationprojects,
listorganizations,
rawSentryRequest,
} from './openapi-client.ts'
import { summarizeIssueEvent, summarizeIssueMetadata, uniqueStrings } from './domain.ts'
import type {
IssueEventsInput,
IssueInput,
IssueTriage,
IssueTriageInput,
IssueWithLatestSummary,
JsonRecord,
ListProjectsInput,
ResolveProjectInput,
SearchIssuesInput,
SearchIssuesWithLatestSummariesInput,
SentryEvent,
SentryIssue,
SentryOrganization,
SentryPage,
SentryPaginationLink,
SentryProject,
} from './types.ts'
export type { QueryInput } from './openapi-client.ts'
export type {
IssueEventsInput,
IssueInput,
IssueTriage,
IssueTriageInput,
IssueWithLatestSummary,
JsonRecord,
ListProjectsInput,
ResolveProjectInput,
SearchIssuesInput,
SearchIssuesWithLatestSummariesInput,
SentryBreadcrumbSummary,
SentryEvent,
SentryEventSummary,
SentryExceptionSummary,
SentryIssue,
SentryOrganization,
SentryPage,
SentryPaginationLink,
SentryProject,
SummarizeIssueEventOptions,
} from './types.ts'
export { summarizeIssueEvent } from './domain.ts'
export {
listorganizationissues,
listorganizationprojects,
listorganizations,
} from './openapi-client.ts'
export class SentryApiError extends Error {
readonly status: number
readonly statusText: string
readonly details: unknown
constructor(message: string, options: { status: number; statusText: string; details: unknown }) {
super(message)
this.name = 'SentryApiError'
this.status = options.status
this.statusText = options.statusText
this.details = options.details
}
}
async function parseResponseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
async function parseOkJson<T>(response: Response): Promise<T> {
const body = await parseResponseBody(response)
if (!response.ok) {
throw new SentryApiError('Sentry API request failed: ' + response.status + ' ' + response.statusText, {
status: response.status,
statusText: response.statusText,
details: body,
})
}
return body as T
}
function parsePaginationHeader(header: string | null): Pick<SentryPage<unknown>, 'next' | 'previous'> {
if (!header) return {}
return Object.fromEntries(
header
.split(',')
.map((part) => {
const urlMatch = part.match(/<([^>]+)>/)
const relMatch = part.match(/rel="([^"]+)"/)
if (!urlMatch || !relMatch) return null
const url = new URL(urlMatch[1])
return [
relMatch[1],
{
cursor: url.searchParams.get('cursor') ?? undefined,
hasResults: part.includes('results="true"'),
url: url.toString(),
},
]
})
.filter(Boolean) as Array<[string, SentryPaginationLink]>,
)
}
async function parseOkJsonPage<T>(response: Response): Promise<SentryPage<T>> {
const body = await parseResponseBody(response)
if (!response.ok) {
throw new SentryApiError('Sentry API request failed: ' + response.status + ' ' + response.statusText, {
status: response.status,
statusText: response.statusText,
details: body,
})
}
return {
items: body as T,
...parsePaginationHeader(response.headers.get('link')),
}
}
/** Escape-hatch REST helper for paths not covered by the OpenAPI scaffold. */
export async function sentryRequest<T>(
path: string,
options: { query?: QueryInput; init?: RequestInit } = {},
): Promise<T> {
return parseOkJson<T>(await rawSentryRequest(path, options))
}
export async function listOrganizations(): Promise<SentryOrganization[]> {
return parseOkJson<SentryOrganization[]>(await listorganizations({}))
}
export async function listProjects(input: ListProjectsInput): Promise<SentryProject[]> {
return parseOkJson<SentryProject[]>(
await listorganizationprojects({
params: { organization_id_or_slug: input.orgSlug },
}),
)
}
export async function searchIssues(input: SearchIssuesInput): Promise<SentryIssue[]> {
const normalizedInput = await normalizeSearchIssuesInput(input)
return parseOkJson<SentryIssue[]>(
await listorganizationissues({
params: { organization_id_or_slug: input.orgSlug },
query: {
query: normalizedInput.query,
statsPeriod: normalizedInput.statsPeriod,
limit: normalizedInput.limit,
project: normalizedInput.project,
cursor: normalizedInput.cursor,
},
}),
)
}
export async function searchIssuesPage(input: SearchIssuesInput): Promise<SentryPage<SentryIssue[]>> {
const normalizedInput = await normalizeSearchIssuesInput(input)
return parseOkJsonPage<SentryIssue[]>(
await listorganizationissues({
params: { organization_id_or_slug: input.orgSlug },
query: {
query: normalizedInput.query,
statsPeriod: normalizedInput.statsPeriod,
limit: normalizedInput.limit,
project: normalizedInput.project,
cursor: normalizedInput.cursor,
},
}),
)
}
export async function resolveProject(input: ResolveProjectInput): Promise<SentryProject> {
if (input.projectId !== undefined) {
const projects = await listProjects({ orgSlug: input.orgSlug })
const project = projects.find((item) => String(item.id) === String(input.projectId))
if (project) return project
throw new Error('No Sentry project found for projectId "' + String(input.projectId) + '".')
}
if (!input.projectSlug) {
throw new Error('resolve-project requires params.projectSlug or params.projectId.')
}
const projects = await listProjects({ orgSlug: input.orgSlug })
const project = projects.find((item) => item.slug === input.projectSlug)
if (!project) {
throw new Error('No Sentry project found for projectSlug "' + input.projectSlug + '".')
}
return project
}
export async function getIssue(input: IssueInput): Promise<SentryIssue> {
// Bare /api/0/issues/{id}/ is not in the public OpenAPI inventory.
return sentryRequest<SentryIssue>('/issues/' + encodeURIComponent(String(input.issueId)) + '/')
}
export async function getLatestIssueEvent(input: IssueInput): Promise<SentryEvent> {
// /events/latest/ is not in the public OpenAPI inventory.
return sentryRequest<SentryEvent>(
'/issues/' + encodeURIComponent(String(input.issueId)) + '/events/latest/',
)
}
export async function getIssueEvents(input: IssueEventsInput): Promise<SentryEvent[]> {
return sentryRequest<SentryEvent[]>(
'/issues/' + encodeURIComponent(String(input.issueId)) + '/events/',
{ query: { limit: input.limit, cursor: input.cursor } },
)
}
export async function searchIssuesWithLatestSummaries(
input: SearchIssuesWithLatestSummariesInput,
): Promise<IssueWithLatestSummary[]> {
const issues = await searchIssues(input)
return Promise.all(
issues.map(async (issue) => {
try {
const latestEvent = await getLatestIssueEvent({ issueId: issue.id })
return {
issue,
latestEventSummary: summarizeIssueEvent(latestEvent, input.summaryOptions),
}
} catch (error) {
return {
issue,
latestEventSummary: null,
latestEventError: normalizeError(error),
}
}
}),
)
}
export async function triageIssue(input: IssueTriageInput): Promise<IssueTriage> {
const [issue, latestEvent, recentEvents] = await Promise.all([
getIssue(input),
getLatestIssueEvent(input),
getIssueEvents({ issueId: input.issueId, limit: input.recentEventLimit ?? 3 }),
])
const latestEventSummary = summarizeIssueEvent(latestEvent, input.options)
const recentEventSummaries = recentEvents
.map((event) => summarizeIssueEvent(event, input.options))
.filter((summary) => latestEventSummary.id == null || summary.id !== latestEventSummary.id)
const summaries = [latestEventSummary, ...recentEventSummaries]
const exceptionTypes = uniqueStrings(
summaries.flatMap((summary) => summary.exceptionChain.map((exception) => exception.type).filter(Boolean)),
)
const topInAppFrames = summaries
.flatMap((summary) => summary.exceptionChain.flatMap((exception) => exception.frames))
.filter((frame) => frame.inApp)
.slice(0, 8)
return {
issue: summarizeIssueMetadata(issue),
latestEventSummary,
recentEventSummaries,
signals: {
exceptionTypes,
likelyCulprit: latestEventSummary.culprit ?? topInAppFrames[0]?.module ?? topInAppFrames[0]?.filename,
topInAppFrames,
tagKeys: Object.keys(latestEventSummary.tags),
contextKeys: latestEventSummary.contextKeys,
recentBreadcrumbs: latestEventSummary.breadcrumbs.slice(-8),
},
}
}
export function requireRecord(value: unknown, utilityName: string): JsonRecord {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonRecord
}
throw new Error(utilityName + ' requires an object params value.')
}
export function requireStringOrNumber(record: JsonRecord, key: string, utilityName: string): string | number {
const value = record[key]
if ((typeof value !== 'string' || value.length === 0) && typeof value !== 'number') {
throw new Error(utilityName + ' requires params.' + key + '.')
}
return value
}
async function normalizeSearchIssuesInput(input: SearchIssuesInput): Promise<SearchIssuesInput> {
if (input.project !== undefined || !input.projectSlug) return input
const project = await resolveProject({
orgSlug: input.orgSlug,
projectSlug: input.projectSlug,
})
return {
...input,
project: project.id,
}
}
function normalizeError(error: unknown): IssueWithLatestSummary['latestEventError'] {
if (error instanceof SentryApiError) {
return { name: error.name, message: error.message, status: error.status }
}
if (error instanceof Error) {
return { name: error.name, message: error.message }
}
return { message: String(error) }
}