Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/sentry

src/client.ts

444 lines · 12.3 KB · TypeScript
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) }
}