Skip to content
← Public packages

@kentcdodds/sentry

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

src/domain.ts

162 lines · 5.7 KB · TypeScript
import type {
  IssueTriage,
  JsonRecord,
  SentryBreadcrumbSummary,
  SentryEvent,
  SentryEventSummary,
  SentryExceptionSummary,
  SentryIssue,
  SummarizeIssueEventOptions,
} from './types.ts'

export function summarizeIssueEvent(
  event: SentryEvent,
  options: SummarizeIssueEventOptions = {},
): SentryEventSummary {
  const eventRecord = asRecord(event) ?? {}
  const metadata = asRecord(eventRecord.metadata) ?? {}
  const contexts = asRecord(eventRecord.contexts) ?? {}
  const entries = asArray(eventRecord.entries).map((entry) => asRecord(entry)).filter(Boolean) as JsonRecord[]
  const exceptionChain = extractExceptionChain(entries, options.maxFramesPerException ?? 8)
  const breadcrumbs = extractBreadcrumbs(entries, options.maxBreadcrumbs ?? 20)
  const tags = normalizeTags(eventRecord.tags)

  return {
    id: stringValue(eventRecord.id),
    eventId: stringValue(eventRecord.eventID) ?? stringValue(eventRecord.eventId),
    title: stringValue(eventRecord.title) ?? stringValue(metadata.title),
    message: stringValue(eventRecord.message) ?? stringValue(metadata.value),
    culprit: stringValue(eventRecord.culprit),
    platform: stringValue(eventRecord.platform),
    projectId: stringValue(eventRecord.projectID) ?? stringValue(eventRecord.projectId),
    groupId: stringValue(eventRecord.groupID) ?? stringValue(eventRecord.groupId),
    release: stringValue(eventRecord.release) ?? stringValue(tags.release),
    environment: stringValue(eventRecord.environment) ?? stringValue(tags.environment),
    timestamp: stringValue(eventRecord.dateCreated) ?? stringValue(eventRecord.dateReceived),
    exceptionChain,
    tags,
    breadcrumbs,
    contexts,
    contextKeys: Object.keys(contexts),
    user: eventRecord.user,
    request: eventRecord.request,
  }
}

export function summarizeIssueMetadata(issue: SentryIssue): IssueTriage['issue'] {
  return {
    id: issue.id,
    shortId: issue.shortId,
    title: issue.title,
    status: issue.status,
    level: stringValue(issue.level),
    permalink: issue.permalink,
    firstSeen: stringValue(issue.firstSeen),
    lastSeen: stringValue(issue.lastSeen),
    count: issue.count as string | number | undefined,
    userCount: issue.userCount as string | number | undefined,
    project: issue.project,
    assignedTo: issue.assignedTo,
  }
}

export function uniqueStrings(values: Array<string | undefined>): string[] {
  return [...new Set(values.filter(Boolean) as string[])]
}

function extractExceptionChain(entries: JsonRecord[], maxFramesPerException: number): SentryExceptionSummary[] {
  return entries
    .filter((entry) => entry.type === 'exception')
    .flatMap((entry) => {
      const data = asRecord(entry.data) ?? {}
      return asArray(data.values)
    })
    .map((exception) => {
      const exceptionRecord = asRecord(exception) ?? {}
      const stacktrace = asRecord(exceptionRecord.stacktrace) ?? {}
      const frames = asArray(stacktrace.frames).map((frame) => asRecord(frame)).filter(Boolean) as JsonRecord[]
      const selectedFrames = frames.slice(-maxFramesPerException).map(summarizeFrame)

      return {
        type: stringValue(exceptionRecord.type),
        value: stringValue(exceptionRecord.value),
        module: stringValue(exceptionRecord.module),
        mechanism: exceptionRecord.mechanism,
        frameCount: frames.length,
        frames: selectedFrames,
      }
    })
}

function summarizeFrame(frame: JsonRecord): SentryExceptionSummary['frames'][number] {
  return {
    function: stringValue(frame.function),
    module: stringValue(frame.module),
    filename: stringValue(frame.filename),
    absPath: stringValue(frame.absPath) ?? stringValue(frame.abs_path),
    lineNo: numberValue(frame.lineNo) ?? numberValue(frame.lineno),
    colNo: numberValue(frame.colNo) ?? numberValue(frame.colno),
    inApp: booleanValue(frame.inApp) ?? booleanValue(frame.in_app),
    contextLine: stringValue(frame.contextLine) ?? stringValue(frame.context_line),
  }
}

function extractBreadcrumbs(entries: JsonRecord[], maxBreadcrumbs: number): SentryBreadcrumbSummary[] {
  const breadcrumbEntry = entries.find((entry) => entry.type === 'breadcrumbs')
  const data = asRecord(breadcrumbEntry?.data) ?? {}

  return asArray(data.values)
    .slice(-maxBreadcrumbs)
    .map((breadcrumb) => {
      const record = asRecord(breadcrumb) ?? {}
      return {
        timestamp: stringValue(record.timestamp),
        type: stringValue(record.type),
        category: stringValue(record.category),
        level: stringValue(record.level),
        message: stringValue(record.message),
        data: record.data,
      }
    })
}

function normalizeTags(tags: unknown): Record<string, unknown> {
  if (Array.isArray(tags)) {
    return Object.fromEntries(
      tags
        .map((tag) => {
          const record = asRecord(tag)
          if (!record) return null
          const key = stringValue(record.key) ?? stringValue(record.name)
          if (!key) return null
          return [key, record.value]
        })
        .filter(Boolean) as Array<[string, unknown]>,
    )
  }

  return asRecord(tags) ?? {}
}

function asArray(value: unknown): unknown[] {
  return Array.isArray(value) ? value : []
}

function asRecord(value: unknown): JsonRecord | null {
  if (value && typeof value === 'object' && !Array.isArray(value)) {
    return value as JsonRecord
  }
  return null
}

function stringValue(value: unknown): string | undefined {
  return typeof value === 'string' && value.length > 0 ? value : undefined
}

function numberValue(value: unknown): number | undefined {
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}

function booleanValue(value: unknown): boolean | undefined {
  return typeof value === 'boolean' ? value : undefined
}