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/shared.ts

317 lines · 14.0 KB · TypeScript
export const discordChannelId = '1530233790516039881'
export const sentryOrgSlug = 'kent-c-dodds-tech-llc'
export const agentModelId = 'grok-4.6'
/**
 * Model for ship agents (review + ship a Seer-drafted PR). The investigation
 * is already done (Seer RCA + Seer code change), so the job is review, test,
 * and CI iteration — Composer's speed fits it.
 */
export const shipAgentModelId = 'composer-2.5'

/** Max cloud agents spawned per rolling clock hour (across all projects). */
export const maxAgentsPerHour = 10
/** Issues seen per hour above this suggests an incident/loop; triage pauses. */
export const issueAnomalyThresholdPerHour = 30

/**
 * Seer-first triage: on a new issue, request a Seer root-cause analysis and
 * let the `seer.root_cause_completed` webhook wake Cole with the RCA,
 * instead of immediately investigating from scratch. Sentry's Seer does the
 * investigation once (server-side), and our agent starts from a verified
 * hypothesis — the token/latency sink we measured was investigation, not
 * fetching. Falls back to from-scratch triage when Seer cannot run (see the
 * handler) so nothing is ever silently dropped.
 */
export const seerFirstEnabled = true
/**
 * If an issue is still awaiting a Seer RCA this long after we requested it
 * (Seer stalled or produced nothing), a later delivery triages it from
 * scratch instead of waiting forever.
 */
export const seerGraceMs = 15 * 60 * 1000
/**
 * Seer-PR-first: when a project's Seer stopping point is "open_pr", the RCA
 * webhook holds the issue (awaiting-seer-pr) while Seer continues through
 * solution/coding to a draft PR; `seer.pr_created` then wakes Cole for ship review
 * on that PR. If the PR never arrives within this window (Seer coding can
 * fail), a later delivery or the reconciler falls back to an investigation
 * agent seeded with the stored RCA.
 */
export const seerPrGraceMs = 45 * 60 * 1000

/**
 * Sentry projects under triage. One Sentry internal-integration webhook
 * covers the whole org; this registry decides which projects get triage
 * agents and which repository each agent works in. Issues from projects not
 * listed here are recorded as skipped.
 *
 * Per-project prompt content (facts, filter idiom, risk guide) keeps the
 * agent prompt accurate for each codebase — a wrong "repo fact" is worse
 * than none.
 */
export const triageProjects = [
	{
		slug: 'kody',
		slugAliases: ['kody-cloudflare'],
		projectId: '4511072505036800',
		repository: 'https://github.com/kentcdodds/kody',
		repoSlug: 'kentcdodds/kody',
		contextNote: 'production worker "kody-production"',
		repoFacts: [
			'- `npm run validate` is the single authoritative gate; CI runs exactly it.',
			'- Sentry wiring: `packages/worker/src/sentry-options.ts` (options +',
			'  `beforeSend` filters, e.g. `filterRetryableD1LockSentryEvent`);',
			'  MCP failure capture: `packages/worker/src/mcp/observability.ts`.',
			'- Issues titled "Unknown: ..." with tag `mcp.sandbox_error=true` are user',
			'  code failing inside the execute sandbox, not platform bugs — but per',
			'  the no-user-error principle, a RECURRING sandbox mistake pattern may',
			'  signal a missing validation, unclear error message, or capability-doc',
			'  gap worth eliminating.',
			'- Kody MCP execute calls can transiently time out (MCP error -32001): retry',
			'  idempotent reads once before treating them as failures.',
		].join('\n'),
		filterGuidance: [
			'The repo idiom is a targeted `beforeSend` filter in',
			'`packages/worker/src/sentry-options.ts` — see',
			'`filterRetryableD1LockSentryEvent` for the precedent (keep filters narrow:',
			'match the specific error signature, never blanket-drop).',
		].join('\n'),
		riskGuide: 'classify per `.agents/skills/visual-recap/SKILL.md`',
	},
	{
		slug: 'kody-home-connector',
		projectId: '4511105963655168',
		repository: 'https://github.com/kentcdodds/kody-home-connector',
		repoSlug: 'kentcdodds/kody-home-connector',
		contextNote:
			"the local-network 'home' remote connector for Kody (Sonos, Bond, Venstar, Roku, Samsung TV, Lutron, JellyFish, Kasa)",
		repoFacts: [
			'- `npm run validate` is the gate (format:check + oxlint + vitest). Use',
			'  Node 24 and npm. Follow AGENTS.md.',
			"- Long-running service on Kent's home network; shared connector protocol",
			'  helpers come from `@kody-bot/connector-kit`. Device handlers live in',
			'  `app/*-handlers.ts` with MSW-backed device mocks under `mocks/`.',
			'- Sentry wiring: `src/sentry-init.ts` (startup init) and `src/sentry.ts`',
			'  (capture helpers with `shouldCapture`, fingerprints, and TTL dedupe;',
			'  tests in `src/sentry.node.test.ts`).',
			'- Many errors reflect flaky local devices or network (timeouts,',
			'  unreachable hosts, device quirks) rather than code bugs — check whether',
			'  the failure is an expected device condition the code should tolerate.',
		].join('\n'),
		filterGuidance: [
			'The repo idiom is the capture-helper layer in `src/sentry.ts`: prefer a',
			'`shouldCapture` condition, a TTL `dedupe`, or a fingerprint for the',
			'specific error signature over blanket drops, and extend',
			'`src/sentry.node.test.ts` alongside.',
		].join('\n'),
		riskGuide: 'classify the risk yourself from the blast radius of the change',
	},
	{
		slug: 'epicshop',
		projectId: '4509630082252800',
		repository: 'https://github.com/epicweb-dev/epicshop',
		repoSlug: 'epicweb-dev/epicshop',
		contextNote: 'the EpicWeb.dev workshop app',
		repoFacts: [
			'- npm workspaces monorepo (`packages/*`): `workshop-app` (the local web',
			'  app), `workshop-cli`, `workshop-mcp`, `workshop-utils`, and more.',
			'- `npm run validate` is the authoritative gate (build, typecheck, lint,',
			'  test via nx). Follow AGENTS.md.',
			'- This app runs locally on learners\' machines (macOS/Windows/Linux, varied',
			'  Node versions, sometimes offline). Many errors originate in environments',
			'  you cannot reproduce; weigh the "filtered" outcome accordingly, but keep',
			'  every filter narrow.',
			'- Sentry wiring: server init `packages/workshop-app/instrument.js` with',
			'  filters in `packages/workshop-app/sentry-server-filters.js` (tests:',
			'  `packages/workshop-app/tests/sentry-filters.test.ts`); client',
			'  `packages/workshop-app/app/utils/monitoring.client.ts` with',
			'  `packages/workshop-app/app/utils/sentry-filters.ts`; CLI',
			'  `packages/workshop-cli/src/utils/sentry-cli-filters.ts`; MCP',
			'  `packages/workshop-mcp/src/sentry-filters.ts`.',
		].join('\n'),
		filterGuidance: [
			'Add the filter to the module for the surface that emitted the event —',
			'server: `packages/workshop-app/sentry-server-filters.js`, client:',
			'`packages/workshop-app/app/utils/sentry-filters.ts`, CLI:',
			'`packages/workshop-cli/src/utils/sentry-cli-filters.ts`, MCP:',
			'`packages/workshop-mcp/src/sentry-filters.ts` — and extend the existing',
			'tests beside each one. Match the specific error signature, never',
			'blanket-drop.',
		].join('\n'),
		riskGuide: 'classify the risk yourself from the blast radius of the change',
	},
	{
		slug: 'kody-video',
		projectId: '4511810800713728',
		repository: 'https://github.com/kentcdodds/kody-video',
		repoSlug: 'kentcdodds/kody-video',
		contextNote:
			'Kody Video — a privacy-first, on-device PWA clips camera on Cloudflare Pages (kody.video)',
		repoFacts: [
			'- Gates: `npm run build` (typecheck + production build), `npx vitest run`',
			'  (unit tests), and `node scripts/manual-smoke.mjs` (Playwright smoke;',
			'  needs `npx playwright install chromium`). All three must pass.',
			'- Sentry wiring: `src/lib/error-reporting.ts` — init is gated to the',
			'  production hostnames, errors-only (no tracing, replay, or PII).',
			'- Entirely client-side (IndexedDB storage, WebCodecs export, MediaRecorder',
			'  fallback, no server) except `functions/api/verify-purchase.ts` (Stripe).',
			'  README.md documents the architecture; there is no AGENTS.md.',
			'- Errors are often device/browser-specific (camera, codecs, permissions,',
			'  storage quota): check whether the stack points at a hardware-dependent',
			'  path only some devices hit before assuming a logic bug. The browser/OS',
			'  tags on the event are load-bearing evidence here.',
		].join('\n'),
		filterGuidance: [
			'Add a narrow `beforeSend` filter in `src/lib/error-reporting.ts` matching',
			'the specific error signature — never blanket-drop, and never add data',
			'collection: this is a privacy-focused app whose policy promises',
			'errors-only reporting.',
		].join('\n'),
		riskGuide: 'classify the risk yourself from the blast radius of the change',
	},
	{
		slug: 'kcd',
		projectId: '5878963',
		repository: 'https://github.com/kentcdodds/kentcdodds.com',
		repoSlug: 'kentcdodds/kentcdodds.com',
		contextNote: 'the kentcdodds.com website',
		repoFacts: [
			'- READ `docs/agents/bugfix-workflow.md` FIRST — it has a dedicated "Sentry',
			'  triage" section (reproduce before fixing; only filter with confirmed',
			'  external evidence; client `RouteErrorResponse: 502 Route Error` usually',
			'  wraps Cloudflare edge Bad Gateway noise, confirm via',
			'  `extra.route_error_response.data`).',
			'- npm workspaces monorepo; the site lives in `services/site`; root',
			'  `npm run validate` runs the site workspace validate. Follow AGENTS.md.',
			'- Runs on Cloudflare Workers (workerd) — see',
			'  `docs/agents/cloudflare-worker-architecture.md`. Stack traces that look',
			'  like stale Fly/Express noise may still reproduce on workerd.',
			'- Client Sentry noise filters: `services/site/app/utils/sentry-noise.ts`',
			'  (wired from `monitoring.client.tsx`, tests in',
			'  `services/site/app/utils/__tests__/sentry-noise.test.ts`).',
		].join('\n'),
		filterGuidance: [
			'Follow the "Sentry triage" section of `docs/agents/bugfix-workflow.md`:',
			'client noise filters live in `services/site/app/utils/sentry-noise.ts`',
			'(narrow `ignoreErrors` / `denyUrls` / `beforeSend` signatures, tests in',
			'`__tests__/sentry-noise.test.ts`). Every drop rule must establish an',
			'external source; never broadly filter `Failed to fetch` / network errors —',
			'those can hide real outages.',
		].join('\n'),
		riskGuide: 'classify the risk yourself from the blast radius of the change',
	},
	{
		slug: 'kody-exchange',
		projectId: '4511934498734080',
		repository: 'https://github.com/kentcdodds/kody-exchange',
		repoSlug: 'kentcdodds/kody-exchange',
		contextNote: 'production worker "kody-exchange" at kody.exchange',
		repoFacts: [
			'- `npm run validate` is the single authoritative gate; CI runs exactly it.',
			'- Cloudflare Worker (wrangler.jsonc) with D1, KV, R2, and Durable Object',
			'  `ThreadRoom`. No PR preview deploys; production deploys from `main` after',
			'  Validate succeeds.',
			'- Production Worker secrets are synced from GitHub Actions by',
			'  `tools/ci/sync-worker-secrets.ts`. Do not `wrangler secret put` by hand.',
			'- Guest is one live thread per IP via REST `POST /v1/threads`. `/mcp` and',
			'  `/api/` require an OAuth access token from a free GitHub account.',
			'- Sentry wiring: `src/sentry-options.ts` (`withSentry` + ThreadRoom',
			'  `instrumentDurableObjectWithSentry`, `beforeSend` filters for retryable',
			'  D1 platform noise and bare Durable Object isolate resets).',
		].join('\n'),
		filterGuidance: [
			'The repo idiom is a targeted `beforeSend` filter in',
			'`src/sentry-options.ts` — see `filterRetryableD1PlatformSentryEvent` and',
			'`filterDurableObjectIsolateResetSentryEvent` for the precedent (keep',
			'filters narrow: match the specific error signature, never blanket-drop).',
			'Extend `src/sentry-options.node.test.ts` alongside.',
		].join('\n'),
		riskGuide: 'classify the risk yourself from the blast radius of the change',
	},
]

export function findTriageProjectForSlug(projects, slug) {
	if (!slug) return null
	return (
		projects.find(
			(project) =>
				project.slug === slug || project.slugAliases?.includes(slug),
		) ?? null
	)
}

export function findTriageProjectForProjectId(projects, projectId) {
	if (projectId == null) return null
	return (
		projects.find(
			(project) => String(project.projectId) === String(projectId),
		) ?? null
	)
}

export function triageProjectForSlug(slug) {
	return findTriageProjectForSlug(triageProjects, slug)
}

export function triageProjectForProjectId(projectId) {
	return findTriageProjectForProjectId(triageProjects, projectId)
}

export function hourBucket(date = new Date()) {
	return date.toISOString().slice(0, 13)
}

export function issueStorageKey(issueId) {
	return `issue:${issueId}`
}

/**
 * Lower-kebab-case slug for storage keys derived from idempotency strings
 * (colons, slashes, etc. become hyphens).
 */
export function toLowerKebabCase(value) {
	return String(value ?? '')
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, '-')
		.replace(/^-+|-+$/g, '')
}

/**
 * Stable packageStorage key for a staged Sentry webhook body. Derived from
 * the workflow idempotency key so retries overwrite the same slot and the
 * durable processor can load a bounded `{ payloadKey }` reference instead of
 * embedding the full Sentry JSON in `workflows.create` params (16KB cap).
 */
export function webhookPayloadStorageKey(idempotencyKey) {
	return `webhook-payload:${toLowerKebabCase(idempotencyKey)}`
}

export function spawnedCounterKey(bucket = hourBucket()) {
	return `spawned:${bucket}`
}

export function issuesSeenCounterKey(bucket = hourBucket()) {
	return `issues-seen:${bucket}`
}

export function alertSentKey(kind, bucket = hourBucket()) {
	return `alerted:${kind}:${bucket}`
}

export function truncate(text, maxLength) {
	const value = String(text ?? '')
	return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`
}

/**
 * Sanitize attacker-controllable text before embedding it in an agent prompt.
 * Backticks are replaced so error content can never close the prompt's code
 * fences and impersonate prompt-level instructions, and « » are stripped so
 * it cannot forge the prompt's secret-placeholder encoding.
 */
export function sanitizeForPrompt(text) {
	return String(text ?? '')
		.replaceAll('`', 'ˋ')
		.replaceAll('«', '<')
		.replaceAll('»', '>')
}