import { sentryErrorMessage, type SentryAuthSelection } from './auth.ts'
import { isSafeHttpMethod, normalizeSentryHost } from './host.ts'
import {
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,
ListOrganizationsInput,
ListProjectsInput,
ResolveProjectInput,
SearchIssuesInput,
SearchIssuesWithLatestSummariesInput,
SentryDryRunResult,
SentryEvent,
SentryIssue,
SentryOrganization,
SentryPage,
SentryPaginationLink,
SentryProject,
SentryRequestInput,
} from './types.ts'
export type { QueryInput } from './openapi-client.ts'
export type {
IssueEventsInput,
IssueInput,
IssueTriage,
IssueTriageInput,
IssueWithLatestSummary,
JsonRecord,
ListOrganizationsInput,
ListProjectsInput,
ResolveProjectInput,
SearchIssuesInput,
SearchIssuesWithLatestSummariesInput,
SentryBreadcrumbSummary,
SentryClientOptions,
SentryDryRunResult,
SentryEvent,
SentryEventSummary,
SentryExceptionSummary,
SentryIssue,
SentryOrganization,
SentryPage,
SentryPaginationLink,
SentryProject,
SentryRequestInput,
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
readonly method: string
readonly path: string
readonly missingScope: string | null
constructor(
message: string,
options: {
status: number
statusText: string
details: unknown
method?: string
path?: string
missingScope?: string | null
},
) {
super(message)
this.name = 'SentryApiError'
this.status = options.status
this.statusText = options.statusText
this.details = options.details
this.method = options.method ?? 'GET'
this.path = options.path ?? ''
this.missingScope = options.missingScope ?? null
}
}
function clientAuth(input: SentryAuthSelection = {}): SentryAuthSelection {
return { secretName: input.secretName, integration: input.integration }
}
function clientHostAuth(input: { host?: string } & SentryAuthSelection) {
return { host: input.host, ...clientAuth(input) }
}
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,
auth: SentryAuthSelection = {},
method = 'GET',
path = '',
): Promise<T> {
const body = await parseResponseBody(response)
if (!response.ok) {
const { message, missingScope } = sentryErrorMessage(auth, response, method, path, body)
throw new SentryApiError(message, {
status: response.status,
statusText: response.statusText,
details: body,
method,
path,
missingScope,
})
}
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,
auth: SentryAuthSelection = {},
method = 'GET',
path = '',
): Promise<SentryPage<T>> {
const body = await parseResponseBody(response)
if (!response.ok) {
const { message, missingScope } = sentryErrorMessage(auth, response, method, path, body)
throw new SentryApiError(message, {
status: response.status,
statusText: response.statusText,
details: body,
method,
path,
missingScope,
})
}
return {
items: body as T,
...parsePaginationHeader(response.headers.get('link')),
}
}
function requestMethod(init?: RequestInit): string {
return typeof init?.method === 'string' && init.method.trim() ? init.method.trim().toUpperCase() : 'GET'
}
/** Escape-hatch REST helper for paths not covered by the OpenAPI scaffold. */
export async function sentryRequest<T>(
path: string,
options: { query?: QueryInput; init?: RequestInit; host?: string; dryRun?: boolean } & SentryAuthSelection = {},
): Promise<T | SentryDryRunResult> {
const method = requestMethod(options.init)
const auth = clientAuth(options)
if (!isSafeHttpMethod(method) && options.dryRun !== false) {
return {
dryRun: true,
wouldCall: {
method,
host: normalizeSentryHost(options.host),
path,
query: options.query,
body: options.init?.body ?? undefined,
},
}
}
return parseOkJson<T>(
await rawSentryRequest(path, {
query: options.query,
init: options.init,
host: options.host,
...auth,
}),
auth,
method,
path,
)
}
export async function listOrganizations(
input: ListOrganizationsInput = {},
): Promise<SentryOrganization[]> {
const auth = clientAuth(input)
return parseOkJson<SentryOrganization[]>(
await listorganizations({ host: input.host, ...auth }),
auth,
'GET',
'/organizations/',
)
}
export async function listProjects(input: ListProjectsInput): Promise<SentryProject[]> {
const auth = clientAuth(input)
const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/projects/'
return parseOkJson<SentryProject[]>(
await listorganizationprojects({
params: { organization_id_or_slug: input.orgSlug },
host: input.host,
...auth,
}),
auth,
'GET',
path,
)
}
export async function searchIssues(input: SearchIssuesInput): Promise<SentryIssue[]> {
const normalizedInput = await normalizeSearchIssuesInput(input)
const auth = clientAuth(input)
const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/issues/'
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,
},
host: input.host,
...auth,
}),
auth,
'GET',
path,
)
}
export async function searchIssuesPage(input: SearchIssuesInput): Promise<SentryPage<SentryIssue[]>> {
const normalizedInput = await normalizeSearchIssuesInput(input)
const auth = clientAuth(input)
const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/issues/'
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,
},
host: input.host,
...auth,
}),
auth,
'GET',
path,
)
}
export async function resolveProject(input: ResolveProjectInput): Promise<SentryProject> {
if (input.projectId !== undefined) {
const projects = await listProjects({ orgSlug: input.orgSlug, ...clientHostAuth(input) })
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, ...clientHostAuth(input) })
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)) + '/', {
...clientHostAuth(input),
}) as Promise<SentryIssue>
}
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/',
{ ...clientHostAuth(input) },
) as Promise<SentryEvent>
}
export async function getIssueEvents(input: IssueEventsInput): Promise<SentryEvent[]> {
return sentryRequest<SentryEvent[]>(
'/issues/' + encodeURIComponent(String(input.issueId)) + '/events/',
{ query: { limit: input.limit, cursor: input.cursor }, ...clientHostAuth(input) },
) as Promise<SentryEvent[]>
}
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, ...clientHostAuth(input) })
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, ...clientHostAuth(input) }),
])
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
}
export async function sentryRequestFromInput(input: SentryRequestInput) {
return sentryRequest(input.path, {
query: input.query,
init: input.init,
dryRun: input.dryRun,
...clientHostAuth(input),
})
}
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,
...clientHostAuth(input),
})
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) }
}