Skip to content

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

Package listing

@kody/sentry

src/domain.ts

162 lines · 5.4 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
}