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 { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } async function parseOkJson( response: Response, auth: SentryAuthSelection = {}, method = 'GET', path = '', ): Promise { 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, '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( response: Response, auth: SentryAuthSelection = {}, method = 'GET', path = '', ): Promise> { 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( path: string, options: { query?: QueryInput; init?: RequestInit; host?: string; dryRun?: boolean } & SentryAuthSelection = {}, ): Promise { 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( await rawSentryRequest(path, { query: options.query, init: options.init, host: options.host, ...auth, }), auth, method, path, ) } export async function listOrganizations( input: ListOrganizationsInput = {}, ): Promise { const auth = clientAuth(input) return parseOkJson( await listorganizations({ host: input.host, ...auth }), auth, 'GET', '/organizations/', ) } export async function listProjects(input: ListProjectsInput): Promise { const auth = clientAuth(input) const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/projects/' return parseOkJson( await listorganizationprojects({ params: { organization_id_or_slug: input.orgSlug }, host: input.host, ...auth, }), auth, 'GET', path, ) } export async function searchIssues(input: SearchIssuesInput): Promise { const normalizedInput = await normalizeSearchIssuesInput(input) const auth = clientAuth(input) const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/issues/' return parseOkJson( 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> { const normalizedInput = await normalizeSearchIssuesInput(input) const auth = clientAuth(input) const path = '/organizations/' + encodeURIComponent(input.orgSlug) + '/issues/' return parseOkJsonPage( 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 { 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 { // Bare /api/0/issues/{id}/ is not in the public OpenAPI inventory. return sentryRequest('/issues/' + encodeURIComponent(String(input.issueId)) + '/', { ...clientHostAuth(input), }) as Promise } export async function getLatestIssueEvent(input: IssueInput): Promise { // /events/latest/ is not in the public OpenAPI inventory. return sentryRequest( '/issues/' + encodeURIComponent(String(input.issueId)) + '/events/latest/', { ...clientHostAuth(input) }, ) as Promise } export async function getIssueEvents(input: IssueEventsInput): Promise { return sentryRequest( '/issues/' + encodeURIComponent(String(input.issueId)) + '/events/', { query: { limit: input.limit, cursor: input.cursor }, ...clientHostAuth(input) }, ) as Promise } export async function searchIssuesWithLatestSummaries( input: SearchIssuesWithLatestSummariesInput, ): Promise { 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 { 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 { 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) } }