Skip to content
← Public packages

@kentcdodds/sentry-triage

Sentry triage wakes Cole (Grok Bot) per issue: one active wake per repo (lease + queue), loop-safe Discord. Cole may spawn Cursor for isolated repo work.

src/handle-sentry-webhook.ts

260 lines · 7.2 KB · TypeScript
import { packageStorage, workflows } from 'kody:runtime'
import processSentryWebhook from './process-sentry-webhook.ts'
import {
	issueStorageKey,
	seerGraceMs,
	seerPrGraceMs,
	webhookPayloadStorageKey,
} from './shared.ts'
import type {
	HandleSentryWebhookInput,
	HandleSentryWebhookResult,
	SentryWebhookPayload,
} from './types.ts'

function isSeerResource(
	resource: string | null,
	payload: SentryWebhookPayload | null | undefined,
) {
	const action = String(payload?.action ?? '')
	return (
		resource === 'seer' ||
		action.startsWith('root_cause') ||
		action.startsWith('solution') ||
		action.startsWith('coding') ||
		payload?.action === 'pr_created'
	)
}

function isInterestingSeerAction(action: string | null) {
	return action === 'root_cause_completed' || action === 'pr_created'
}

function extractIssueId(payload: SentryWebhookPayload | null | undefined) {
	const issueId = payload?.data?.issue?.id
	if (issueId != null && String(issueId)) return String(issueId)
	const eventIssueId = payload?.data?.event?.issue_id
	if (eventIssueId != null && String(eventIssueId)) return String(eventIssueId)
	return null
}

/**
 * Build a stable workflow idempotency key for issue.created deliveries.
 * Retries of the same logical event reuse the key; stale re-triage / rescue
 * uses a distinct key anchored on the prior record timestamp so regressions
 * are not blocked by a completed prior workflow.
 */
async function issueIdempotencyKey(
	issueId: string,
	payload: SentryWebhookPayload,
) {
	if (payload.kodySweeperRescue === true) {
		const existing = await packageStorage()
			.get(issueStorageKey(issueId))
			.catch(() => null)
		const anchor =
			existing?.seerPrAwaitedAt ??
			existing?.seerRequestedAt ??
			existing?.seenAt ??
			'rescue'
		return `sentry-triage:issue:${issueId}:rescue:${anchor}`
	}

	const existing = await packageStorage()
		.get(issueStorageKey(issueId))
		.catch(() => null)
	if (!existing) return `sentry-triage:issue:${issueId}:created`

	const finishedAt =
		existing.completedAt ?? existing.spawnedAt ?? existing.seenAt ?? null
	const ageMs =
		finishedAt != null ? Date.now() - Date.parse(String(finishedAt)) : 0
	const awaitingAgeMs =
		Date.now() -
		(Date.parse(
			String(existing.seerRequestedAt ?? finishedAt ?? ''),
		) || Date.now())
	const awaitingPrAgeMs =
		Date.now() -
		(Date.parse(
			String(existing.seerPrAwaitedAt ?? finishedAt ?? ''),
		) || Date.now())
	const retriageAfterMs = 6 * 60 * 60 * 1000
	const inFlightStaleMs = 3 * 60 * 60 * 1000
	const spawnFailedRetryMs = 15 * 60 * 1000
	const inFlight = existing.status === 'agent-spawned'
	const stale =
		existing.status === 'awaiting-seer'
			? awaitingAgeMs > seerGraceMs
			: existing.status === 'awaiting-seer-pr'
				? awaitingPrAgeMs > seerPrGraceMs
				: existing.status === 'spawn-failed'
					? ageMs > spawnFailedRetryMs
					: inFlight
						? ageMs > inFlightStaleMs
						: ageMs > retriageAfterMs
	if (!stale) {
		// Still in the dedupe window — dispatch with the base key so a
		// concurrent Sentry retry joins the in-flight / completed workflow
		// instead of waking Cole a second time.
		return `sentry-triage:issue:${issueId}:created`
	}
	const anchor = String(finishedAt ?? existing.seenAt ?? 'retriage')
	return `sentry-triage:issue:${issueId}:retriage:${anchor}`
}

function seerIdempotencyKey(groupId: string, action: string, runId: unknown) {
	const runPart = runId != null && String(runId) ? `:run:${runId}` : ''
	return `sentry-triage:seer:${groupId}:${action}${runPart}`
}

/**
 * Stage the full Sentry request in packageStorage and dispatch a durable
 * processor with a bounded params object (well under the 16KB workflows
 * serialize cap). Never embed the full Sentry JSON in workflow params.
 */
async function dispatchDurableProcessor({
	idempotencyKey,
	issueId,
	resource,
	action,
	headers,
	payload,
}: {
	idempotencyKey: string
	issueId: string
	resource: string | null
	action: string | null
	headers: Record<string, string | undefined>
	payload: SentryWebhookPayload
}) {
	const payloadKey = webhookPayloadStorageKey(idempotencyKey)
	await packageStorage().set(payloadKey, {
		headers,
		json: payload,
		stagedAt: new Date().toISOString(),
	})
	const created = await workflows.create({
		exportName: './process-sentry-webhook',
		idempotencyKey,
		params: {
			payloadKey,
			issueId,
			resource,
			action,
		},
	})
	return { payloadKey, created }
}

/**
 * Inbound Sentry webhook entrypoint (HMAC-verified by the platform).
 * Fast skips and `kodyDryRun` stay synchronous; durable issue/Seer work is
 * dispatched with `workflows.create` to `./process-sentry-webhook` so the
 * ingress export returns before prefetch / Discord / Cole wake.
 */
export default async function handleSentryWebhook(
	input: HandleSentryWebhookInput = {},
): Promise<HandleSentryWebhookResult> {
	const payload = (input?.request?.json ?? null) as SentryWebhookPayload | null
	const resource = input?.request?.headers?.['sentry-hook-resource'] ?? null
	const action = payload?.action ?? null

	if (isSeerResource(resource, payload)) {
		const groupId =
			payload?.data?.group_id != null ? String(payload.data.group_id) : null
		if (!groupId) {
			return { ok: true, skipped: 'no-group-id', resource, action }
		}
		if (!isInterestingSeerAction(action)) {
			return {
				ok: true,
				skipped: `seer-action-${action}`,
				resource,
				action,
				issueId: groupId,
			}
		}
		if (payload?.kodyDryRun === true) {
			return processSentryWebhook(input)
		}
		const idempotencyKey = seerIdempotencyKey(
			groupId,
			String(action),
			payload?.data?.run_id,
		)
		const { created } = await dispatchDurableProcessor({
			idempotencyKey,
			issueId: groupId,
			resource: resource ?? 'seer',
			action,
			headers: input?.request?.headers ?? {
				'sentry-hook-resource': 'seer',
			},
			payload: payload ?? {},
		})
		return {
			ok: true,
			accepted: true,
			dispatched: true,
			issueId: groupId,
			resource: resource ?? 'seer',
			action,
			workflowId: created.id,
			workflowStatus: created.status ?? null,
			workflowName: created.workflow_name,
			idempotencyKey,
			exportName: './process-sentry-webhook',
		}
	}

	const issueId = extractIssueId(payload)
	if (!issueId) {
		return { ok: true, skipped: 'not-an-issue-event', resource, action }
	}

	// event_alert deliveries are normalized to action "created" in the
	// processor; only skip clearly non-create issue actions here.
	const normalizedAction =
		!payload?.data?.issue?.id && payload?.data?.event?.issue_id
			? 'created'
			: action
	if (normalizedAction !== 'created') {
		return {
			ok: true,
			skipped: `action-${normalizedAction}`,
			issueId,
			resource,
			action: normalizedAction,
		}
	}

	if (payload?.kodyDryRun === true) {
		return processSentryWebhook(input)
	}

	const idempotencyKey = await issueIdempotencyKey(issueId, payload ?? {})
	const { created } = await dispatchDurableProcessor({
		idempotencyKey,
		issueId,
		resource,
		action: normalizedAction,
		headers: input?.request?.headers ?? {
			'sentry-hook-resource': 'issue',
		},
		payload: payload ?? {},
	})
	return {
		ok: true,
		accepted: true,
		dispatched: true,
		issueId,
		resource,
		action: normalizedAction,
		workflowId: created.id,
		workflowStatus: created.status ?? null,
		workflowName: created.workflow_name,
		idempotencyKey,
		exportName: './process-sentry-webhook',
	}
}