Skip to content
← Public packages

@kentcdodds/sentry

Inspect Sentry organizations, projects, issues, and event summaries.

src/client.ts

322 lines · 9.8 KB · TypeScript
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,
  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) }
}