@zibby/workflow-templates 0.4.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +36 -49
- package/notify-notion/brand/notion-logo.svg +4 -0
- package/notify-notion/icon.png +0 -0
- package/package.json +2 -1
- package/sentry-triage/graph.mjs +26 -37
- package/sentry-triage/icon.png +0 -0
- package/sentry-triage/nodes/classify-node.js +97 -12
- package/sentry-triage/nodes/dispatch-node.js +143 -0
- package/sentry-triage/nodes/fetch-issues-node.js +50 -17
- package/sentry-triage/package.json +2 -1
- package/sentry-triage/state.js +26 -69
- package/sentry-triage/nodes/dispatch-alerts-node.js +0 -191
- package/sentry-triage/nodes/filter-noise-node.js +0 -112
- package/sentry-triage/prompts/classify.md +0 -76
- package/sentry-triage/prompts/fetch-issues.md +0 -66
package/index.js
CHANGED
|
@@ -53,8 +53,7 @@ export const TEMPLATES = {
|
|
|
53
53
|
'Composition: character centered, slight forward lean, dynamic motion lines. Mood: cheerful, playful, fast.',
|
|
54
54
|
'NO text, NO browser UI screenshots, NO outline wireframes.',
|
|
55
55
|
].join('\n'),
|
|
56
|
-
|
|
57
|
-
tags: ['testing', 'playwright', 'e2e', 'browser'],
|
|
56
|
+
tags: ['Testing'],
|
|
58
57
|
capabilities: [
|
|
59
58
|
'Preflight LLM extracts assertions from a plain-English spec',
|
|
60
59
|
'Live Playwright execution with screenshots + DOM at every step',
|
|
@@ -106,8 +105,7 @@ export const TEMPLATES = {
|
|
|
106
105
|
'Composition: brackets centered, subtle drop shadow on the canvas. Mood: high-end, technical, confident — like the cover of a developer tool launch.',
|
|
107
106
|
'NO text, NO outline wireframes, NO flat sticker style — this one is DEEP and 3D-rendered.',
|
|
108
107
|
].join('\n'),
|
|
109
|
-
|
|
110
|
-
tags: ['code-analysis', 'jira', 'github', 'test-generation'],
|
|
108
|
+
tags: ['Code Review', 'Testing'],
|
|
111
109
|
capabilities: [
|
|
112
110
|
'Clone repos + snapshot git baseline so changes are diff-able',
|
|
113
111
|
'LLM gate: skip code-gen when ticket can\'t be implemented as-is',
|
|
@@ -150,8 +148,7 @@ export const TEMPLATES = {
|
|
|
150
148
|
'Mood: friendly, approachable, slightly handmade. Like a children\'s book illustration applied to a developer tool.',
|
|
151
149
|
'NO text, NO photo-realism, NO sleek 3D render — this one is hand-drawn and warm.',
|
|
152
150
|
].join('\n'),
|
|
153
|
-
|
|
154
|
-
tags: ['testing', 'test-generation', 'pull-request', 'review'],
|
|
151
|
+
tags: ['Code Review', 'Testing'],
|
|
155
152
|
capabilities: [
|
|
156
153
|
'Skips ticket analysis — feed it the diff directly',
|
|
157
154
|
'LLM explores the codebase to ground test steps in real components',
|
|
@@ -195,8 +192,7 @@ export const TEMPLATES = {
|
|
|
195
192
|
'Mood: focused, energetic, signal-not-noise — the canonical Slack-flavored notification worker.',
|
|
196
193
|
'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO literal Slack wordmark — the colored pinwheel shape is allowed as the brand reference.',
|
|
197
194
|
].join('\n'),
|
|
198
|
-
|
|
199
|
-
tags: ['slack', 'notification', 'alert', 'child-workflow'],
|
|
195
|
+
tags: ['Notifications', 'child-workflow'],
|
|
200
196
|
capabilities: [
|
|
201
197
|
'Severity-coded Block Kit message (low/medium/high/critical)',
|
|
202
198
|
'Code snippet + action button + caller mentions',
|
|
@@ -239,8 +235,7 @@ export const TEMPLATES = {
|
|
|
239
235
|
'Mood: focused, professional, signal-not-noise.',
|
|
240
236
|
'NO Lark / Feishu logo trademark, NO text, NO photo-realism.',
|
|
241
237
|
].join('\n'),
|
|
242
|
-
|
|
243
|
-
tags: ['lark', 'feishu', 'notification', 'alert', 'child-workflow'],
|
|
238
|
+
tags: ['Notifications', 'child-workflow'],
|
|
244
239
|
capabilities: [
|
|
245
240
|
'Severity-coded Lark Interactive Card',
|
|
246
241
|
'Auto-detects receive_id_type from id prefix (chat_id / open_id / email)',
|
|
@@ -279,16 +274,14 @@ export const TEMPLATES = {
|
|
|
279
274
|
slug: 'notify-notion',
|
|
280
275
|
tagline: 'Reusable Notion archiver — durable record for any workflow.',
|
|
281
276
|
iconPrompt: [
|
|
282
|
-
'
|
|
283
|
-
'
|
|
284
|
-
'
|
|
285
|
-
'
|
|
286
|
-
'
|
|
287
|
-
'
|
|
288
|
-
'NO text, NO logo or trademarked marks, NO photo-realism, NO sleek 3D render, NO literal Notion trademark.',
|
|
277
|
+
'A premium, hi-fi app icon for "Notify Notion" — a workflow node that publishes reports to a Notion database. The real Notion brand mark will be composited on top in a post-process step; this prompt generates the BACKGROUND ONLY, with a clear empty area for the overlay.',
|
|
278
|
+
'Visual style: 3D-rendered hero object floating in space, in the style of Apple Vision Pro icons, Linear\'s changelog hero illustrations, or a Stripe product render. Glossy, dimensional, with subtle reflections and a soft rim-light. Same family as the code-analysis marketplace icon.',
|
|
279
|
+
'Subject: a single 3D-rendered page-document hero object made of glossy frosted glass / brushed silver metal, captured in head-on or near-head-on perspective (NOT three-quarter — keep the page face flat to the camera so the logo overlay sits cleanly). The page surface is COMPLETELY EMPTY — no lines, no text, no icons, no markings of any kind. The right edge curls slightly forward like a fresh page being filed, but the front face stays clean. A tiny cyan-teal glow accent sits in the upper-right corner of the page as a "freshly archived" signal.',
|
|
280
|
+
'Background: a deep midnight-navy gradient (#0F172A at the top, #1E1B4B at the bottom), with a single soft cyan glow behind the page and a few faint star-like specks scattered across the canvas. Square format, 1024×1024.',
|
|
281
|
+
'Composition: page centered, page face takes ~60-70% of the canvas and is empty/blank so a logo overlay can sit naturally on its surface. Subtle drop shadow. Mood: high-end, durable, archival — premium devtool aesthetic.',
|
|
282
|
+
'CRITICAL: the page front face must be COMPLETELY CLEAN AND EMPTY. NO N, NO letters, NO horizontal text lines, NO checkmark, NO icons, NO embossing, NO etching, NO decoration on the page surface. Just blank glossy material. NO Notion logo, NO Notion wordmark, NO trademarked marks. NO text anywhere. NO outline wireframes, NO flat sticker style, NO mascot, NO smiling face, NO cartoon. The page surface MUST be empty so a real logo PNG can be composited on it.',
|
|
289
283
|
].join('\n'),
|
|
290
|
-
|
|
291
|
-
tags: ['notion', 'docs', 'reporting', 'knowledge-base', 'archive'],
|
|
284
|
+
tags: ['Notifications', 'Docs', 'Reports'],
|
|
292
285
|
capabilities: [
|
|
293
286
|
'Create a new page in a Notion database (POST /v1/pages)',
|
|
294
287
|
'Append blocks to an existing page (PATCH /v1/blocks/{pageId}/children)',
|
|
@@ -306,49 +299,45 @@ export const TEMPLATES = {
|
|
|
306
299
|
},
|
|
307
300
|
},
|
|
308
301
|
|
|
309
|
-
// ── sentry-triage:
|
|
302
|
+
// ── sentry-triage: hourly LLM triage → Slack OR Lark ─────────────
|
|
310
303
|
'sentry-triage': {
|
|
311
304
|
name: 'sentry-triage',
|
|
312
305
|
displayName: 'Sentry Triage Bot',
|
|
313
|
-
description: 'Hourly Sentry triage
|
|
306
|
+
description: 'Hourly Sentry triage that classifies new issues with an LLM rubric (CRITICAL/HIGH/MEDIUM/LOW/NOISE) and posts above-threshold alerts to your Slack or Lark — whichever you have connected.',
|
|
314
307
|
path: join(__dirname, 'sentry-triage'),
|
|
315
308
|
defaultSlug: 'sentry-triage',
|
|
316
|
-
deps: { zod: '^3.23.0' },
|
|
309
|
+
deps: { zod: '^3.23.0', '@zibby/skills': '^0.1.25' },
|
|
317
310
|
features: [
|
|
318
|
-
'
|
|
319
|
-
'
|
|
320
|
-
'
|
|
321
|
-
'
|
|
322
|
-
'
|
|
323
|
-
'Configurable severityThreshold (don\'t notify on LOW noise)',
|
|
311
|
+
'3-node LLM graph: fetch_issues → classify → dispatch_alerts',
|
|
312
|
+
'Severity rubric with auditable reasoning per issue',
|
|
313
|
+
'Posts to Slack OR Lark (whichever the project has connected — chat_notify OR-group)',
|
|
314
|
+
'LLM dispatcher batches related issues into one message, dedupes near-duplicates',
|
|
315
|
+
'CRITICAL alerts get optional @-mentions; lower severities don\'t',
|
|
324
316
|
'Cron-friendly: hourly schedule, default sinceMinutes=60',
|
|
325
317
|
],
|
|
326
318
|
marketplace: {
|
|
327
319
|
slug: 'sentry-triage',
|
|
328
|
-
tagline: '
|
|
320
|
+
tagline: 'Triage Sentry, ping your team — hourly.',
|
|
329
321
|
iconPrompt: [
|
|
330
|
-
'Hand-painted storybook illustration in a warm gouache style with soft brushwork and gentle painterly texture, featuring the friendly round lighthouse mascot character with two big smiling eyes and a rosy blush on its white-and-coral-striped tower body, perched on a tiny mint-green island and clutching a small glowing purple SHIELD BADGE in front of its body — the badge is a rounded geometric emblem in Sentry\'s signature deep violet (#362D59 / #7553FF) with a stylized white "S"-mark inside it formed from overlapping rounded parallelogram shapes
|
|
322
|
+
'Hand-painted storybook illustration in a warm gouache style with soft brushwork and gentle painterly texture, featuring the friendly round lighthouse mascot character with two big smiling eyes and a rosy blush on its white-and-coral-striped tower body, perched on a tiny mint-green island and clutching a small glowing purple SHIELD BADGE in front of its body — the badge is a rounded geometric emblem in Sentry\'s signature deep violet (#362D59 / #7553FF) with a stylized white "S"-mark inside it formed from overlapping rounded parallelogram shapes.',
|
|
331
323
|
'The lighthouse lantern emits a soft golden beam that catches one glowing amber alert orb while three faded grey noise specks drift harmlessly past, reinforcing the "filter the signal, calm the noise" idea.',
|
|
332
324
|
'Background is a soft sunrise gradient of pale peach at the top blending through buttercream into a gentle wash of dusty lavender at the base, tying the warm scene to the violet of the badge; a few small fluffy pastel clouds float in for friendliness.',
|
|
333
|
-
'Centered composition with the purple shield badge as the immediate focal point in the lower-center, the lighthouse rising behind and slightly above it, beam angled diagonally; plenty of breathing room so the silhouette reads at 64×64
|
|
334
|
-
'Mood is warm, reassuring, optimistic —
|
|
325
|
+
'Centered composition with the purple shield badge as the immediate focal point in the lower-center, the lighthouse rising behind and slightly above it, beam angled diagonally; plenty of breathing room so the silhouette reads at 64×64.',
|
|
326
|
+
'Mood is warm, reassuring, optimistic — friendly night-watch character, NOT tactical or corporate or alarming.',
|
|
335
327
|
'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
|
|
336
|
-
'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO magnifying glass, NO speech bubbles, NO dark navy or near-black backgrounds, NO bug or insect imagery, NO literal Sentry wordmark.',
|
|
328
|
+
'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO magnifying glass, NO speech bubbles, NO dark navy or near-black backgrounds, NO bug or insect imagery, NO literal Sentry / Slack / Lark wordmark.',
|
|
337
329
|
].join('\n'),
|
|
338
|
-
|
|
339
|
-
tags: ['sentry', 'observability', 'on-call', 'triage', 'alerting'],
|
|
330
|
+
tags: ['On-call', 'Bug Triage', 'Notifications'],
|
|
340
331
|
capabilities: [
|
|
341
332
|
'Hourly scheduled triage of new Sentry issues',
|
|
342
|
-
'Deterministic regex filter drops Script error / ResizeObserver / extension noise',
|
|
343
333
|
'LLM severity classifier with auditable rubric',
|
|
344
|
-
'
|
|
345
|
-
'
|
|
334
|
+
'Posts to Slack or Lark — whichever your project has connected',
|
|
335
|
+
'Batches related issues; CRITICAL-only @-mentions for on-call',
|
|
346
336
|
'Configurable severity threshold per deploy',
|
|
347
337
|
],
|
|
348
338
|
conversationStarters: [
|
|
349
339
|
'Triage all new Sentry issues from the last hour',
|
|
350
|
-
'Notify
|
|
351
|
-
'Run hourly and post a summary to our team Slack',
|
|
340
|
+
'Notify the on-call channel when severity is HIGH or above',
|
|
352
341
|
'Page on-call when a CRITICAL error appears in checkout',
|
|
353
342
|
],
|
|
354
343
|
},
|
|
@@ -375,16 +364,14 @@ export const TEMPLATES = {
|
|
|
375
364
|
slug: 'ai-spend-weekly-digest',
|
|
376
365
|
tagline: 'Track and explain your OpenAI / Anthropic / Cursor spending — every Monday morning, in Lark or Slack.',
|
|
377
366
|
iconPrompt: [
|
|
378
|
-
'
|
|
379
|
-
'
|
|
380
|
-
'
|
|
381
|
-
'
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
-
'NO text, NO letters, NO numbers, NO photo-realism, NO sleek 3D render, NO chart axes or grid lines, NO dark navy or near-black backgrounds, NO literal OpenAI / Anthropic / Cursor logos or wordmarks, NO bar charts.',
|
|
367
|
+
'A premium, hi-fi app icon for "AI Spend Weekly Digest" — a workflow that reports cross-vendor LLM spend to engineering leaders.',
|
|
368
|
+
'Visual style: 3D-rendered hero object floating in space, in the style of Apple Vision Pro icons, Linear\'s changelog hero illustrations, or a Stripe product render. Glossy, dimensional, with subtle reflections and a soft rim-light.',
|
|
369
|
+
'Subject: a 3D-rendered stack of three glossy disc-shaped layered chips — like a tiny ascending bar-chart-of-coins — slightly rotated in three-quarter perspective. Each disc is a different premium accent color: top disc warm gold, middle disc cool platinum-silver, bottom disc deep iridescent purple. A single thin trending-up glowing line traces from lower-left to upper-right behind the stack, rendered as a soft neon ribbon (#7553FF violet glow), suggesting cost-over-time at a glance without literal axes.',
|
|
370
|
+
'Background: a deep midnight-navy gradient (#0F172A at the top, #1E1B4B at the bottom), with a single soft violet glow behind the stack and a few faint star-like specks scattered across the canvas. Square format, 1024×1024.',
|
|
371
|
+
'Composition: stack centered, subtle drop shadow on the canvas. Mood: high-end, executive, confident — like the cover image of a finance-ops product launch.',
|
|
372
|
+
'NO text, NO numbers, NO axes or grid lines, NO outline wireframes, NO flat sticker style, NO mascot, NO piggy bank, NO cartoon faces, NO trademarked OpenAI / Anthropic / Cursor logos — this one is DEEP and 3D-rendered.',
|
|
385
373
|
].join('\n'),
|
|
386
|
-
|
|
387
|
-
tags: ['cost', 'finance', 'reporting', 'openai', 'anthropic', 'cursor', 'digest', 'weekly'],
|
|
374
|
+
tags: ['AI Spend', 'Cost Tracking', 'Reports'],
|
|
388
375
|
capabilities: [
|
|
389
376
|
'Pulls org-wide cost+usage from OpenAI, Anthropic, and Cursor admin APIs in parallel',
|
|
390
377
|
'Joins customer attribution from provider-native project / workspace / member metadata',
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
|
|
3
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
|
|
4
|
+
</svg>
|
package/notify-notion/icon.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zibby/workflow-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, notify-notion, sentry-triage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@anthropic-ai/sdk": "^0.88.0",
|
|
74
74
|
"@zibby/agent-workflow": "^0.4.2",
|
|
75
|
+
"@zibby/skills": "^0.1.25",
|
|
75
76
|
"axios": "^1.15.0",
|
|
76
77
|
"handlebars": "^4.7.9",
|
|
77
78
|
"zod": "^3.23.0 || ^4.0.0"
|
package/sentry-triage/graph.mjs
CHANGED
|
@@ -1,51 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sentry-triage — parent workflow.
|
|
2
|
+
* sentry-triage — parent workflow. Hourly Sentry issue triage.
|
|
3
3
|
*
|
|
4
|
-
* Pipeline:
|
|
4
|
+
* Pipeline (3 LLM nodes, end-to-end agent-driven):
|
|
5
5
|
*
|
|
6
|
-
* fetch_issues
|
|
6
|
+
* fetch_issues (LLM + SKILLS.SENTRY) → list recent unresolved issues
|
|
7
7
|
* ↓
|
|
8
|
-
*
|
|
8
|
+
* classify (LLM, no tools) → label NOISE/LOW/MEDIUM/HIGH/CRITICAL
|
|
9
9
|
* ↓
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* dispatch_alerts (custom execute — sub-graphs to notify-slack OR notify-lark
|
|
13
|
-
* per issue at or above severityThreshold)
|
|
10
|
+
* dispatch_alerts (LLM + SKILLS.CHAT_NOTIFY) → batch + post to Slack OR Lark for
|
|
11
|
+
* issues ≥ SEVERITY_THRESHOLD
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* on
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* Why all three nodes are LLM (not deterministic for-loops):
|
|
14
|
+
* - At hourly cadence with ≤20 issues/run, LLM cost is $1.50–$32/mo
|
|
15
|
+
* depending on model. Trivial relative to Sentry / Slack subscriptions.
|
|
16
|
+
* - LLM dispatch can BATCH related issues (5 errors in /checkout/ →
|
|
17
|
+
* 1 consolidated message) and DE-DUP near-duplicates. A
|
|
18
|
+
* deterministic for-loop can't.
|
|
19
|
+
* - outputSchema enforcement guarantees every above-threshold issue
|
|
20
|
+
* either gets a "sent" record or an explicit "failed/skipped" —
|
|
21
|
+
* no silent drops.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* 100ms vs 1600s — the architecture is what makes this template
|
|
25
|
-
* cheap enough to run hourly.
|
|
23
|
+
* Customize prompts: each node's prompt lives in its own module under
|
|
24
|
+
* nodes/. Override per-deploy by editing the file or by passing a
|
|
25
|
+
* custom prompt string via inputSchema (planned).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { readFileSync, existsSync } from 'fs';
|
|
29
|
-
import { join, dirname } from 'path';
|
|
30
|
-
import { fileURLToPath } from 'url';
|
|
31
28
|
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
32
29
|
|
|
33
30
|
import { fetchIssuesNode } from './nodes/fetch-issues-node.js';
|
|
34
|
-
import { filterNoiseNode } from './nodes/filter-noise-node.js';
|
|
35
31
|
import { classifyNode } from './nodes/classify-node.js';
|
|
36
|
-
import {
|
|
32
|
+
import { dispatchNode } from './nodes/dispatch-node.js';
|
|
37
33
|
|
|
38
34
|
import {
|
|
39
35
|
sentryTriageInputSchema,
|
|
40
36
|
sentryTriageContextSchema,
|
|
41
37
|
} from './state.js';
|
|
42
38
|
|
|
43
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
|
-
function loadPrompt(filename) {
|
|
45
|
-
const path = join(__dirname, 'prompts', filename);
|
|
46
|
-
return existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
39
|
export class SentryTriageAgent extends WorkflowAgent {
|
|
50
40
|
buildGraph() {
|
|
51
41
|
const graph = new WorkflowGraph();
|
|
@@ -53,14 +43,12 @@ export class SentryTriageAgent extends WorkflowAgent {
|
|
|
53
43
|
.setInputSchema(sentryTriageInputSchema)
|
|
54
44
|
.setContextSchema(sentryTriageContextSchema);
|
|
55
45
|
|
|
56
|
-
graph.addNode('fetch_issues',
|
|
57
|
-
graph.addNode('
|
|
58
|
-
graph.addNode('
|
|
59
|
-
graph.addNode('dispatch_alerts', dispatchAlertsNode);
|
|
46
|
+
graph.addNode('fetch_issues', fetchIssuesNode);
|
|
47
|
+
graph.addNode('classify', classifyNode);
|
|
48
|
+
graph.addNode('dispatch_alerts', dispatchNode);
|
|
60
49
|
|
|
61
50
|
graph.setEntryPoint('fetch_issues');
|
|
62
|
-
graph.addEdge('fetch_issues', '
|
|
63
|
-
graph.addEdge('filter_noise', 'classify');
|
|
51
|
+
graph.addEdge('fetch_issues', 'classify');
|
|
64
52
|
graph.addEdge('classify', 'dispatch_alerts');
|
|
65
53
|
graph.addEdge('dispatch_alerts', 'END');
|
|
66
54
|
|
|
@@ -69,10 +57,11 @@ export class SentryTriageAgent extends WorkflowAgent {
|
|
|
69
57
|
|
|
70
58
|
async onComplete(result) {
|
|
71
59
|
const s = result?.state?.dispatch_alerts?.summary || {};
|
|
72
|
-
const
|
|
60
|
+
const classifications = result?.state?.classify?.classifications || [];
|
|
61
|
+
const noise = classifications.filter((c) => c.severity === 'NOISE').length;
|
|
73
62
|
const fetched = result?.state?.fetch_issues?.issues?.length || 0;
|
|
74
63
|
console.log(
|
|
75
|
-
`[sentry-triage] complete — fetched=${fetched}, noise=${
|
|
64
|
+
`[sentry-triage] complete — fetched=${fetched}, noise=${noise}, ` +
|
|
76
65
|
`sent=${s.sent || 0}, skipped=${s.skipped || 0}, failed=${s.failed || 0}`,
|
|
77
66
|
);
|
|
78
67
|
}
|
package/sentry-triage/icon.png
CHANGED
|
Binary file
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* classify node — LLM-driven severity classification.
|
|
3
3
|
*
|
|
4
|
-
* No tools —
|
|
5
|
-
* (
|
|
6
|
-
* NOISE
|
|
4
|
+
* No tools — the LLM sees the rubric AND the concrete issues array
|
|
5
|
+
* (inlined as JSON at render time) and emits one classification record
|
|
6
|
+
* per issue. NOISE detection is part of the rubric itself; no separate
|
|
7
|
+
* pre-filter step.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* guarantees the emitted shape; bad models get a retry with the
|
|
11
|
-
* outputSchema in the prompt.
|
|
9
|
+
* Severity threshold (skip-floor) lives on dispatch, NOT here — this
|
|
10
|
+
* node always classifies every issue. dispatch decides whether to send.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import { z } from '@zibby/core';
|
|
@@ -27,12 +26,98 @@ const ClassifyOutputSchema = z.object({
|
|
|
27
26
|
classifications: z.array(ClassificationShape),
|
|
28
27
|
});
|
|
29
28
|
|
|
29
|
+
const RUBRIC = `You are the classify node of a Sentry triage workflow. Classify each Sentry issue into a severity bucket and explain WHY.
|
|
30
|
+
|
|
31
|
+
The list of issues is appended below as a JSON array. Treat it as authoritative — do NOT call any tool, you have everything you need.
|
|
32
|
+
|
|
33
|
+
# Severity rubric (apply IN ORDER, stop at first match)
|
|
34
|
+
|
|
35
|
+
1. **NOISE** — these never warrant a human ping. Match if ANY:
|
|
36
|
+
- Title is "Script error." (cross-origin opaque error, no stack, useless)
|
|
37
|
+
- Title contains "Non-Error promise rejection captured"
|
|
38
|
+
- Title contains "ResizeObserver loop limit exceeded" or "ResizeObserver loop completed"
|
|
39
|
+
- culprit or metadata.filename URL starts with chrome-extension://, safari-extension://, moz-extension://, webkit-masked-url:// (user's extension crashed, not your code)
|
|
40
|
+
- Title or culprit mentions analytics SDKs: gtag, fbq, _paq, dataLayer, googletagmanager, piwik
|
|
41
|
+
- Title is "AbortError", contains "cancelled", or "Load failed" AND userCount < 3 (user navigated away)
|
|
42
|
+
- Title says "Test ", "Demo ", "[STAGING]" (wrong environment leakage)
|
|
43
|
+
- Stack trace has zero inApp:true frames (3rd-party only — not your code)
|
|
44
|
+
- User-agent in tags indicates a bot (Googlebot, AhrefsBot, etc.)
|
|
45
|
+
|
|
46
|
+
2. **CRITICAL** if ANY of:
|
|
47
|
+
- userCount >= 20 (≥ 20 users affected — real prod impact)
|
|
48
|
+
- culprit or metadata.filename matches /payment|billing|checkout|auth|login|signup|session/i (security/revenue path)
|
|
49
|
+
- level === "fatal" and count >= 10
|
|
50
|
+
- count >= 100 AND firstSeen-to-lastSeen window is < 30 min (active spike)
|
|
51
|
+
|
|
52
|
+
3. **HIGH** if ANY of:
|
|
53
|
+
- userCount >= 5 AND count >= 50
|
|
54
|
+
- level === "fatal" (any count)
|
|
55
|
+
- level === "error" AND userCount >= 3 AND count >= 20
|
|
56
|
+
- Errors in non-critical-but-important paths: settings, profile, search, dashboard, admin
|
|
57
|
+
|
|
58
|
+
4. **MEDIUM** if ANY of:
|
|
59
|
+
- count >= 20 AND userCount >= 2
|
|
60
|
+
- count >= 50 regardless of userCount
|
|
61
|
+
- level === "error" AND count >= 10
|
|
62
|
+
|
|
63
|
+
5. **LOW** — anything else (count < 20 AND userCount < 5, or level === "warning" | "info")
|
|
64
|
+
|
|
65
|
+
# Recommended action per severity
|
|
66
|
+
|
|
67
|
+
- CRITICAL → page_oncall (always notify, always mention rotation)
|
|
68
|
+
- HIGH → notify_channel (notify, no @ unless deploy author known)
|
|
69
|
+
- MEDIUM → notify_channel
|
|
70
|
+
- LOW → digest_only (rolled into a daily summary, not real-time)
|
|
71
|
+
- NOISE → ignore
|
|
72
|
+
|
|
73
|
+
# Output shape
|
|
74
|
+
|
|
75
|
+
For EACH issue in the JSON array below, emit ONE record:
|
|
76
|
+
|
|
77
|
+
\`\`\`json
|
|
78
|
+
{
|
|
79
|
+
"classifications": [
|
|
80
|
+
{
|
|
81
|
+
"issueId": "1234567890",
|
|
82
|
+
"severity": "CRITICAL",
|
|
83
|
+
"confidence": 0.95,
|
|
84
|
+
"reasoning": "12 users affected, culprit handleCheckout (payment path). Likely regression after recent deploy.",
|
|
85
|
+
"suggestedAction": "page_oncall",
|
|
86
|
+
"ruleMatched": "rule 2 (culprit matches /checkout/)"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
# Rules
|
|
93
|
+
|
|
94
|
+
- confidence reflects how cleanly the issue matched. CRITICAL in /payment/ with userCount=50 → 0.95. Borderline → 0.6.
|
|
95
|
+
- reasoning is ONE sentence written for an on-call engineer. Lead with the impact metric.
|
|
96
|
+
- ruleMatched is which numbered rule fired. Helps operators tune the rubric over time.
|
|
97
|
+
- Be consistent: same issue twice should always get the same severity.
|
|
98
|
+
- Temperature 0. Classification, not creative writing.
|
|
99
|
+
|
|
100
|
+
# Do NOT
|
|
101
|
+
|
|
102
|
+
- Classify more issues than appear in the array below.
|
|
103
|
+
- Skip issues — every issue in the array must appear in the output (NOISE included).
|
|
104
|
+
- Use any severity outside NOISE|LOW|MEDIUM|HIGH|CRITICAL.
|
|
105
|
+
- Call any tools.`;
|
|
106
|
+
|
|
107
|
+
const CLASSIFY_PROMPT = (state = {}) => {
|
|
108
|
+
const issues = state?.fetch_issues?.issues || [];
|
|
109
|
+
return `${RUBRIC}
|
|
110
|
+
|
|
111
|
+
## Issues to classify
|
|
112
|
+
|
|
113
|
+
\`\`\`json
|
|
114
|
+
${JSON.stringify(issues, null, 2)}
|
|
115
|
+
\`\`\`
|
|
116
|
+
`;
|
|
117
|
+
};
|
|
118
|
+
|
|
30
119
|
export const classifyNode = {
|
|
31
120
|
name: 'classify',
|
|
32
|
-
// NO skills — this is a pure reasoning step; the LLM has all data
|
|
33
|
-
// it needs in state.filter_noise.kept. Adding skills would let the
|
|
34
|
-
// LLM call Sentry tools for "more context", which we don't want
|
|
35
|
-
// (rubric is supposed to be deterministic).
|
|
36
121
|
outputSchema: ClassifyOutputSchema,
|
|
37
|
-
|
|
122
|
+
prompt: CLASSIFY_PROMPT,
|
|
38
123
|
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch_alerts node — LLM-driven dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* The agent sees ALL classified issues + their full data and makes
|
|
5
|
+
* judgment calls before calling the chat tool:
|
|
6
|
+
* - Bulk related issues into ONE message (5 errors in /checkout/ →
|
|
7
|
+
* "⚠️ Checkout spike: 5 errors, top: ...").
|
|
8
|
+
* - De-dupe near-duplicates ("seen 3 times, same culprit").
|
|
9
|
+
* - Honor SEVERITY_THRESHOLD (skip anything below).
|
|
10
|
+
* - Attach mentions only on CRITICAL.
|
|
11
|
+
*
|
|
12
|
+
* Provider routing: chatNotifySkill.resolve() picks the slack OR lark
|
|
13
|
+
* MCP server based on which ENV var is set, so the LLM only ever sees
|
|
14
|
+
* ONE provider's tools (slack_* or lark_*) — it can't accidentally
|
|
15
|
+
* call the wrong one.
|
|
16
|
+
*
|
|
17
|
+
* Reliability: outputSchema enforces a `dispatched` record per
|
|
18
|
+
* group + summary counts. A malformed LLM response triggers a retry
|
|
19
|
+
* with the schema embedded.
|
|
20
|
+
*
|
|
21
|
+
* ENV tab config:
|
|
22
|
+
* SLACK_CHANNEL OR LARK_RECEIVE_ID — required, pick one
|
|
23
|
+
* SEVERITY_THRESHOLD — NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
|
|
24
|
+
* SLACK_MENTIONS OR LARK_MENTIONS — JSON array, optional, CRITICAL only
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { z, SKILLS } from '@zibby/core';
|
|
28
|
+
import { SEVERITY_LEVELS } from '../state.js';
|
|
29
|
+
|
|
30
|
+
const DispatchAlertsOutputSchema = z.object({
|
|
31
|
+
dispatched: z.array(z.object({
|
|
32
|
+
issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
|
|
33
|
+
severity: z.enum(SEVERITY_LEVELS),
|
|
34
|
+
status: z.enum(['sent', 'skipped', 'failed']),
|
|
35
|
+
messageTs: z.string().optional(), // Slack
|
|
36
|
+
messageId: z.string().optional(), // Lark
|
|
37
|
+
detail: z.string().optional(),
|
|
38
|
+
})),
|
|
39
|
+
summary: z.object({
|
|
40
|
+
total: z.number().describe('Number of messages POSTED (not issues — batched groups count as 1).'),
|
|
41
|
+
sent: z.number(),
|
|
42
|
+
skipped: z.number(),
|
|
43
|
+
failed: z.number(),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const DISPATCH_PROMPT = (state = {}) => {
|
|
48
|
+
const issues = state?.fetch_issues?.issues || [];
|
|
49
|
+
const classifications = state?.classify?.classifications || [];
|
|
50
|
+
|
|
51
|
+
const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
|
|
52
|
+
const slackChannel = process.env.SLACK_CHANNEL || '';
|
|
53
|
+
const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
|
|
54
|
+
|
|
55
|
+
let provider, toolName, recipientLine, mentionsRaw;
|
|
56
|
+
if (slackChannel) {
|
|
57
|
+
provider = 'slack';
|
|
58
|
+
toolName = 'slack_post_message';
|
|
59
|
+
recipientLine = `Post every message to Slack channel: ${JSON.stringify(slackChannel)}\nCall: slack_post_message({ channel: "${slackChannel}", text: "…" })`;
|
|
60
|
+
mentionsRaw = process.env.SLACK_MENTIONS || '[]';
|
|
61
|
+
} else if (larkReceiveId) {
|
|
62
|
+
provider = 'lark';
|
|
63
|
+
toolName = 'lark_send_message';
|
|
64
|
+
recipientLine = `Post every message to Lark receive_id: ${JSON.stringify(larkReceiveId)}\nCall: lark_send_message({ receive_id: "${larkReceiveId}", text: "…" })`;
|
|
65
|
+
mentionsRaw = process.env.LARK_MENTIONS || '[]';
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error('sentry-triage: configure SLACK_CHANNEL (for Slack) or LARK_RECEIVE_ID (for Lark) in the ENV tab.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let mentions;
|
|
71
|
+
try { mentions = JSON.parse(mentionsRaw); } catch { mentions = []; }
|
|
72
|
+
if (!Array.isArray(mentions)) mentions = [];
|
|
73
|
+
|
|
74
|
+
return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${toolName}** tool.
|
|
75
|
+
|
|
76
|
+
# Recipient
|
|
77
|
+
${recipientLine}
|
|
78
|
+
|
|
79
|
+
# Severity threshold
|
|
80
|
+
Skip any issue below: ${threshold}
|
|
81
|
+
(Severity order, low → high: ${SEVERITY_LEVELS.join(' < ')})
|
|
82
|
+
|
|
83
|
+
# Mentions
|
|
84
|
+
CRITICAL messages only — prepend: ${JSON.stringify(mentions.join(' '))}
|
|
85
|
+
HIGH/MEDIUM/LOW — no mentions.
|
|
86
|
+
|
|
87
|
+
# Your judgment
|
|
88
|
+
- Batch issues with the same culprit / metadata.filename into ONE message.
|
|
89
|
+
- De-dupe near-duplicates (e.g. same error text in different paths). Mention "seen N times".
|
|
90
|
+
- Keep each message short. Lead with severity in *[BRACKETS]*. Include the Sentry permalink so the on-call can click through.
|
|
91
|
+
|
|
92
|
+
# Message format (template, adapt as needed)
|
|
93
|
+
\`\`\`
|
|
94
|
+
*[CRITICAL]* TypeError: Cannot read 'id' of undefined
|
|
95
|
+
12 users hit /checkout — likely regression on r1234.
|
|
96
|
+
📍 handleCheckout(checkout.ts) · 47 events
|
|
97
|
+
https://sentry.io/.../1234/
|
|
98
|
+
\`\`\`
|
|
99
|
+
|
|
100
|
+
# Output (outputSchema-enforced)
|
|
101
|
+
|
|
102
|
+
Return ONE record per ${toolName} call you actually made (or skipped/failed).
|
|
103
|
+
\`issueIds\` is an array — for batched messages it carries every issue in the group.
|
|
104
|
+
\`severity\` is the highest severity in the group.
|
|
105
|
+
|
|
106
|
+
\`\`\`json
|
|
107
|
+
{
|
|
108
|
+
"dispatched": [
|
|
109
|
+
{ "issueIds": ["1", "5", "7"], "severity": "CRITICAL", "status": "sent"${provider === 'slack' ? ', "messageTs": "1716109330.555"' : ', "messageId": "om_xxxxx"'} }
|
|
110
|
+
],
|
|
111
|
+
"summary": { "total": 1, "sent": 1, "skipped": 0, "failed": 0 }
|
|
112
|
+
}
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
# Issues + classifications
|
|
116
|
+
|
|
117
|
+
Each entry below has the Sentry issue plus the classifier's verdict + reasoning. Use both.
|
|
118
|
+
|
|
119
|
+
\`\`\`json
|
|
120
|
+
${JSON.stringify(
|
|
121
|
+
issues.map((issue) => {
|
|
122
|
+
const c = classifications.find((x) => String(x.issueId) === String(issue.id));
|
|
123
|
+
return { ...issue, classification: c || { severity: 'LOW' } };
|
|
124
|
+
}),
|
|
125
|
+
null,
|
|
126
|
+
2,
|
|
127
|
+
)}
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
# Rules
|
|
131
|
+
- Skip below-threshold issues silently (just include them in dispatched with status="skipped"; no chat call).
|
|
132
|
+
- DON'T invent severities or issue IDs. Use what's given.
|
|
133
|
+
- DON'T post more messages than necessary. If 5 issues are clearly one bug, post 1 message.
|
|
134
|
+
- DO post if in doubt — under-paging is worse than over-paging for triage.
|
|
135
|
+
`;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const dispatchNode = {
|
|
139
|
+
name: 'dispatch_alerts',
|
|
140
|
+
skills: [SKILLS.CHAT_NOTIFY],
|
|
141
|
+
outputSchema: DispatchAlertsOutputSchema,
|
|
142
|
+
prompt: DISPATCH_PROMPT,
|
|
143
|
+
};
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fetch_issues
|
|
2
|
+
* fetch_issues — DETERMINISTIC. Calls Sentry's REST API directly via
|
|
3
|
+
* the @zibby/skills client. No LLM, no MCP tool round-trip.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Why deterministic: the previous LLM-driven version hard-coded the
|
|
6
|
+
* query string and explicitly forbade filtering or follow-up calls.
|
|
7
|
+
* The LLM added zero judgment — just one round-trip of latency and
|
|
8
|
+
* ~$0.01-0.05 of token cost per run. At hourly cadence across many
|
|
9
|
+
* customers, that compounds; deterministic also removes the "LLM
|
|
10
|
+
* hallucinated query string" failure mode.
|
|
7
11
|
*
|
|
8
|
-
* Why
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Why still declare `skills: [SKILLS.SENTRY]`: the backend bundler
|
|
13
|
+
* reads this to build `workflow.requiredIntegrations`, which the
|
|
14
|
+
* marketplace deploy modal uses to gate install until Sentry is
|
|
15
|
+
* connected. Without it, users could install with no Sentry token
|
|
16
|
+
* wired up and the first run would 401. The skill's runtime tool
|
|
17
|
+
* injection is a no-op here (no prompt for an LLM to call them), but
|
|
18
|
+
* the integration-requirement signal still matters — same pattern as
|
|
19
|
+
* ai-spend-weekly-digest's fetch-spending-node.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Auth: sentryListIssues uses resolveIntegrationToken('sentry') which
|
|
22
|
+
* hits the backend's project-scoped resolver via PROJECT_API_TOKEN +
|
|
23
|
+
* PROGRESS_API_URL env vars (set on every Fargate task by
|
|
24
|
+
* workflow-executor.js).
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
|
-
import { z
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { SKILLS } from '@zibby/core';
|
|
29
|
+
import { sentryListIssues } from '@zibby/skills/sentry';
|
|
19
30
|
|
|
20
31
|
const IssueShape = z.object({
|
|
21
32
|
id: z.string(),
|
|
@@ -38,15 +49,37 @@ const IssueShape = z.object({
|
|
|
38
49
|
|
|
39
50
|
const FetchIssuesOutputSchema = z.object({
|
|
40
51
|
issues: z.array(IssueShape),
|
|
41
|
-
fetchedAt: z.string()
|
|
52
|
+
fetchedAt: z.string(),
|
|
42
53
|
});
|
|
43
54
|
|
|
44
55
|
export const fetchIssuesNode = {
|
|
45
56
|
name: 'fetch_issues',
|
|
46
57
|
skills: [SKILLS.SENTRY],
|
|
47
58
|
outputSchema: FetchIssuesOutputSchema,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
execute: async (context) => {
|
|
60
|
+
// State access pattern mirrors fetch-spending-node — the framework
|
|
61
|
+
// passes a context whose `.state.getAll()` returns the flat state,
|
|
62
|
+
// but tests sometimes pass the state object directly as context.
|
|
63
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
64
|
+
? context.state.getAll()
|
|
65
|
+
: context;
|
|
66
|
+
|
|
67
|
+
const sinceMinutes = Number(state?.sinceMinutes) || 60;
|
|
68
|
+
|
|
69
|
+
const issues = await sentryListIssues({
|
|
70
|
+
query: `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`,
|
|
71
|
+
sort: 'created',
|
|
72
|
+
// 100 issues is the practical ceiling for a triage notification.
|
|
73
|
+
// Beyond that, classify+dispatch lose signal — a "deluge" digest
|
|
74
|
+
// tells the user nothing actionable. If a customer regularly
|
|
75
|
+
// exceeds 100/hour they need to tighten the Sentry filters
|
|
76
|
+
// upstream, not raise this cap.
|
|
77
|
+
limit: 100,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
issues,
|
|
82
|
+
fetchedAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
},
|
|
52
85
|
};
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Hourly Sentry issue triage bot —
|
|
6
|
+
"description": "Hourly Sentry issue triage bot — LLM-classifies new issues by severity and pings Slack OR Lark for anything ≥ threshold.",
|
|
7
7
|
"main": "graph.mjs",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"test": "vitest run"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@zibby/core": "^0.5.1",
|
|
13
|
+
"@zibby/skills": "^0.1.25",
|
|
13
14
|
"zod": "^3.23.0"
|
|
14
15
|
},
|
|
15
16
|
"devDependencies": {
|
package/sentry-triage/state.js
CHANGED
|
@@ -1,76 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sentry-triage —
|
|
2
|
+
* sentry-triage — input + context schemas.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* issues from Sentry
|
|
7
|
-
* 2. filter_noise (custom execute) — drop known-noise patterns
|
|
8
|
-
* (browser-extension URLs, ResizeObserver loops, etc.) WITHOUT
|
|
9
|
-
* paying an LLM call per issue
|
|
10
|
-
* 3. classify (LLM) — classify the survivors as
|
|
11
|
-
* NOISE / LOW / MEDIUM / HIGH / CRITICAL with reasoning
|
|
12
|
-
* 4. dispatch_alerts (custom execute) — sub-graph dispatch to
|
|
13
|
-
* notify-slack OR notify-lark for issues above severityThreshold
|
|
4
|
+
* Trigger payload (inputSchema) is intentionally tiny: just sinceMinutes,
|
|
5
|
+
* the one per-run dial. Everything else is deploy-time ENV-tab config:
|
|
14
6
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
7
|
+
* Required (set ONE — at least one chat target):
|
|
8
|
+
* SLACK_CHANNEL channel id "C012345" or "#name"
|
|
9
|
+
* LARK_RECEIVE_ID oc_… chat id, ou_… open id, or email
|
|
10
|
+
*
|
|
11
|
+
* Optional:
|
|
12
|
+
* SEVERITY_THRESHOLD NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
|
|
13
|
+
* SLACK_MENTIONS JSON array — appended to CRITICAL Slack alerts only
|
|
14
|
+
* LARK_MENTIONS JSON array — appended to CRITICAL Lark alerts only
|
|
22
15
|
*/
|
|
23
16
|
|
|
24
17
|
import { z } from 'zod';
|
|
25
18
|
|
|
19
|
+
// Ordered low → high. Index doubles as severity rank.
|
|
26
20
|
export const SEVERITY_LEVELS = /** @type {const} */ (['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
|
27
21
|
|
|
22
|
+
/** True iff severity is at or above threshold per SEVERITY_LEVELS order. */
|
|
23
|
+
export function meetsSeverityThreshold(severity, threshold) {
|
|
24
|
+
const s = SEVERITY_LEVELS.indexOf(severity);
|
|
25
|
+
const t = SEVERITY_LEVELS.indexOf(threshold);
|
|
26
|
+
return (s === -1 ? 0 : s) >= (t === -1 ? SEVERITY_LEVELS.indexOf('MEDIUM') : t);
|
|
27
|
+
}
|
|
28
|
+
|
|
28
29
|
export const sentryTriageInputSchema = z.object({
|
|
29
|
-
// ── Sentry source ────────────────────────────────────────────────
|
|
30
|
-
organizationSlug: z.string().min(1)
|
|
31
|
-
.describe('Sentry organization slug (the URL segment after sentry.io/organizations/).'),
|
|
32
|
-
projectSlug: z.string().min(1)
|
|
33
|
-
.describe('Sentry project slug — limits triage to a single project.'),
|
|
34
|
-
environment: z.string().default('production')
|
|
35
|
-
.describe('Sentry environment tag to filter by (defaults to production).'),
|
|
36
30
|
sinceMinutes: z.number().int().min(5).max(1440).default(60)
|
|
37
|
-
.describe('
|
|
38
|
-
|
|
39
|
-
// ── Triage thresholds ────────────────────────────────────────────
|
|
40
|
-
severityThreshold: z.enum(SEVERITY_LEVELS).default('MEDIUM')
|
|
41
|
-
.describe('Only dispatch alerts for issues at or above this severity. Drop the rest.'),
|
|
42
|
-
maxIssues: z.number().int().min(1).max(100).default(20)
|
|
43
|
-
.describe('Cap issues processed per run. Protects against an unexpected error storm.'),
|
|
44
|
-
|
|
45
|
-
// ── Where to send alerts ────────────────────────────────────────
|
|
46
|
-
notifyWorker: z.enum(['notify-slack', 'notify-lark']).default('notify-slack')
|
|
47
|
-
.describe(
|
|
48
|
-
'Which child workflow to dispatch alerts to. Both must be deployed in the same project ' +
|
|
49
|
-
'as this triage workflow. Pick whichever messaging platform your team uses.',
|
|
50
|
-
),
|
|
51
|
-
|
|
52
|
-
// For notify-slack
|
|
53
|
-
slackChannel: z.string().min(1).max(120).optional()
|
|
54
|
-
.describe('Slack channel id (C012345) or #name. Required when notifyWorker=notify-slack.'),
|
|
55
|
-
slackMentions: z.array(z.string().max(60)).max(10).optional()
|
|
56
|
-
.describe('Mentions to append on CRITICAL alerts only, e.g. ["<!subteam^S0ONCALL>"].'),
|
|
57
|
-
|
|
58
|
-
// For notify-lark
|
|
59
|
-
larkReceiveId: z.string().min(1).max(120).optional()
|
|
60
|
-
.describe('Lark chat id (oc_…), open id (ou_…), or email. Required when notifyWorker=notify-lark.'),
|
|
61
|
-
larkMentions: z.array(z.string().max(200)).max(10).optional()
|
|
62
|
-
.describe('Lark @-mention strings for CRITICAL alerts.'),
|
|
63
|
-
|
|
64
|
-
model: z.string().default('auto')
|
|
65
|
-
.describe('LLM model override for classify_issues. Default auto-selects.'),
|
|
31
|
+
.describe('Lookback minutes (5–1440)'),
|
|
66
32
|
});
|
|
67
33
|
|
|
68
34
|
export const sentryTriageContextSchema = z.object({
|
|
69
|
-
// Runner-injected
|
|
70
35
|
workspace: z.string().optional()
|
|
71
|
-
.describe('Workspace path —
|
|
36
|
+
.describe('Workspace path — runner-injected; triage doesn\'t need it but graph.run requires it.'),
|
|
72
37
|
|
|
73
|
-
// Node outputs (mid-graph, keyed by node name)
|
|
74
38
|
fetch_issues: z.object({
|
|
75
39
|
issues: z.array(z.object({
|
|
76
40
|
id: z.string(),
|
|
@@ -93,14 +57,6 @@ export const sentryTriageContextSchema = z.object({
|
|
|
93
57
|
fetchedAt: z.string().optional(),
|
|
94
58
|
}).optional(),
|
|
95
59
|
|
|
96
|
-
filter_noise: z.object({
|
|
97
|
-
kept: z.array(z.any()),
|
|
98
|
-
dropped: z.array(z.object({
|
|
99
|
-
id: z.string(),
|
|
100
|
-
reason: z.string(),
|
|
101
|
-
})),
|
|
102
|
-
}).optional(),
|
|
103
|
-
|
|
104
60
|
classify: z.object({
|
|
105
61
|
classifications: z.array(z.object({
|
|
106
62
|
issueId: z.string(),
|
|
@@ -114,7 +70,11 @@ export const sentryTriageContextSchema = z.object({
|
|
|
114
70
|
|
|
115
71
|
dispatch_alerts: z.object({
|
|
116
72
|
dispatched: z.array(z.object({
|
|
117
|
-
issueId
|
|
73
|
+
// Deterministic dispatcher emits issueId; LLM batcher emits issueIds[].
|
|
74
|
+
// messageTs (Slack) and messageId (Lark) are both optional — only the
|
|
75
|
+
// variant that ran will populate one of them.
|
|
76
|
+
issueId: z.string().optional(),
|
|
77
|
+
issueIds: z.array(z.string()).optional(),
|
|
118
78
|
severity: z.enum(SEVERITY_LEVELS),
|
|
119
79
|
status: z.enum(['sent', 'skipped', 'failed']),
|
|
120
80
|
detail: z.string().optional(),
|
|
@@ -129,6 +89,3 @@ export const sentryTriageContextSchema = z.object({
|
|
|
129
89
|
}),
|
|
130
90
|
}).optional(),
|
|
131
91
|
});
|
|
132
|
-
|
|
133
|
-
export const sentryTriageStateSchema =
|
|
134
|
-
sentryTriageInputSchema.merge(sentryTriageContextSchema);
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dispatch-alerts node — sub-graph fan-out to notify-slack/notify-lark.
|
|
3
|
-
*
|
|
4
|
-
* For each classified issue at or above severityThreshold:
|
|
5
|
-
* - Build a provider-neutral notification payload (severity, title,
|
|
6
|
-
* body, sentryLink, etc.) from the merged issue + classification
|
|
7
|
-
* records.
|
|
8
|
-
* - Add caller-supplied per-provider config (channel for Slack,
|
|
9
|
-
* receiveId for Lark, severity-conditional mentions).
|
|
10
|
-
* - dispatchSubgraph(state.notifyWorker, { input }) — SYNC mode so we
|
|
11
|
-
* get back the messageTs/messageId for the summary.
|
|
12
|
-
* - Continue on per-issue failure (notify failure shouldn't kill the
|
|
13
|
-
* whole triage run; we report `status: 'failed'` and move on).
|
|
14
|
-
*
|
|
15
|
-
* Sub-graph dispatch goes via in-process executor when the child is
|
|
16
|
-
* bundled in the same Fargate task. The runtime threshold for severity
|
|
17
|
-
* is enforced HERE, not in the LLM classifier, so an operator can
|
|
18
|
-
* raise/lower the bar at deploy time without redeploying.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { z } from 'zod';
|
|
22
|
-
import { dispatchSubgraph } from '@zibby/agent-workflow';
|
|
23
|
-
import { SEVERITY_LEVELS } from '../state.js';
|
|
24
|
-
|
|
25
|
-
const DispatchAlertsOutputSchema = z.object({
|
|
26
|
-
dispatched: z.array(z.object({
|
|
27
|
-
issueId: z.string(),
|
|
28
|
-
severity: z.enum(SEVERITY_LEVELS),
|
|
29
|
-
status: z.enum(['sent', 'skipped', 'failed']),
|
|
30
|
-
detail: z.string().optional(),
|
|
31
|
-
messageTs: z.string().optional(),
|
|
32
|
-
messageId: z.string().optional(),
|
|
33
|
-
})),
|
|
34
|
-
summary: z.object({
|
|
35
|
-
total: z.number(),
|
|
36
|
-
sent: z.number(),
|
|
37
|
-
skipped: z.number(),
|
|
38
|
-
failed: z.number(),
|
|
39
|
-
}),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const SEVERITY_RANK = Object.freeze({
|
|
43
|
-
NOISE: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Build the provider-neutral notification payload from issue + classification.
|
|
48
|
-
* Pure function — exposed for tests so we can pin the wire shape.
|
|
49
|
-
*/
|
|
50
|
-
export function buildNotifyPayload({ issue, classification, state }) {
|
|
51
|
-
const severityRaw = classification?.severity || 'LOW';
|
|
52
|
-
// notify-* workflows want lowercase severity (their inputSchema enum).
|
|
53
|
-
const severity = severityRaw.toLowerCase();
|
|
54
|
-
const reason = classification?.reasoning || '';
|
|
55
|
-
const userCount = typeof issue.userCount === 'number' ? issue.userCount : undefined;
|
|
56
|
-
const events = typeof issue.count === 'number' ? issue.count
|
|
57
|
-
: (typeof issue.count === 'string' && /^\d+$/.test(issue.count)) ? Number(issue.count)
|
|
58
|
-
: undefined;
|
|
59
|
-
const release = issue?.metadata?.release
|
|
60
|
-
|| (issue.tags || []).find?.((t) => t.key === 'release')?.value
|
|
61
|
-
|| undefined;
|
|
62
|
-
const firstSeen = issue.firstSeen || undefined;
|
|
63
|
-
|
|
64
|
-
const culpritLine = issue.culprit ? `\n📍 ${issue.culprit}` : '';
|
|
65
|
-
const reasonLine = reason ? `\n${reason}` : '';
|
|
66
|
-
const body = `${classification?.suggestedAction ? `*Action:* ${classification.suggestedAction}\n` : ''}` +
|
|
67
|
-
`${reasonLine}${culpritLine}`.trim();
|
|
68
|
-
|
|
69
|
-
// Code snippet: when fetch_issues populated metadata.filename, use it
|
|
70
|
-
// as a one-line hint. v2 will pull the actual context lines.
|
|
71
|
-
const codeSnippet = issue?.metadata?.filename
|
|
72
|
-
? `${issue.metadata.filename}\n// ${issue.metadata.value || issue.title}`
|
|
73
|
-
: undefined;
|
|
74
|
-
|
|
75
|
-
// Mentions: only attach on CRITICAL. Lower severities get no @-blast.
|
|
76
|
-
const isCritical = severityRaw === 'CRITICAL';
|
|
77
|
-
const slackMentions = isCritical && Array.isArray(state.slackMentions) ? state.slackMentions : undefined;
|
|
78
|
-
const larkMentions = isCritical && Array.isArray(state.larkMentions) ? state.larkMentions : undefined;
|
|
79
|
-
|
|
80
|
-
// Common fields both providers accept.
|
|
81
|
-
const common = {
|
|
82
|
-
severity,
|
|
83
|
-
title: issue.title || issue.shortId || `Sentry ${issue.id}`,
|
|
84
|
-
body: body || undefined,
|
|
85
|
-
sentryLink: issue.permalink || undefined,
|
|
86
|
-
affectedUsers: userCount,
|
|
87
|
-
events,
|
|
88
|
-
release,
|
|
89
|
-
firstSeen,
|
|
90
|
-
codeSnippet,
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
if (state.notifyWorker === 'notify-lark') {
|
|
94
|
-
return {
|
|
95
|
-
...common,
|
|
96
|
-
receiveId: state.larkReceiveId,
|
|
97
|
-
...(larkMentions ? { mentions: larkMentions } : {}),
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
// default → notify-slack
|
|
101
|
-
return {
|
|
102
|
-
...common,
|
|
103
|
-
channel: state.slackChannel,
|
|
104
|
-
...(slackMentions ? { mentions: slackMentions } : {}),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Convenience for unit tests + the node body. Decides whether a given
|
|
110
|
-
* classification meets the threshold.
|
|
111
|
-
*/
|
|
112
|
-
export function meetsSeverityThreshold(severity, threshold) {
|
|
113
|
-
const s = SEVERITY_RANK[severity] ?? 0;
|
|
114
|
-
const t = SEVERITY_RANK[threshold] ?? SEVERITY_RANK.MEDIUM;
|
|
115
|
-
return s >= t;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export const dispatchAlertsNode = {
|
|
119
|
-
name: 'dispatch_alerts',
|
|
120
|
-
outputSchema: DispatchAlertsOutputSchema,
|
|
121
|
-
timeout: 5 * 60 * 1000, // 5min — generous for fan-out across 20 children
|
|
122
|
-
execute: async (context) => {
|
|
123
|
-
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
124
|
-
? context.state.getAll()
|
|
125
|
-
: context;
|
|
126
|
-
|
|
127
|
-
const issues = state?.filter_noise?.kept || state?.fetch_issues?.issues || [];
|
|
128
|
-
const classifications = state?.classify?.classifications || [];
|
|
129
|
-
const classMap = new Map(classifications.map((c) => [c.issueId, c]));
|
|
130
|
-
const threshold = state.severityThreshold || 'MEDIUM';
|
|
131
|
-
const worker = state.notifyWorker || 'notify-slack';
|
|
132
|
-
|
|
133
|
-
// Validate the right per-provider config field is set.
|
|
134
|
-
if (worker === 'notify-slack' && !state.slackChannel) {
|
|
135
|
-
throw new Error('sentry-triage: slackChannel is required when notifyWorker=notify-slack');
|
|
136
|
-
}
|
|
137
|
-
if (worker === 'notify-lark' && !state.larkReceiveId) {
|
|
138
|
-
throw new Error('sentry-triage: larkReceiveId is required when notifyWorker=notify-lark');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const dispatched = [];
|
|
142
|
-
for (const issue of issues) {
|
|
143
|
-
const classification = classMap.get(String(issue.id));
|
|
144
|
-
const severity = classification?.severity || 'LOW';
|
|
145
|
-
|
|
146
|
-
if (!meetsSeverityThreshold(severity, threshold)) {
|
|
147
|
-
dispatched.push({
|
|
148
|
-
issueId: String(issue.id),
|
|
149
|
-
severity,
|
|
150
|
-
status: 'skipped',
|
|
151
|
-
detail: `severity ${severity} below threshold ${threshold}`,
|
|
152
|
-
});
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const payload = buildNotifyPayload({ issue, classification, state });
|
|
157
|
-
try {
|
|
158
|
-
// Sync sub-graph dispatch. The in-process executor returns the
|
|
159
|
-
// child's finalState (or the extracted `output`). We pull
|
|
160
|
-
// messageTs / messageId by checking both shapes since the parent
|
|
161
|
-
// is provider-agnostic at this layer.
|
|
162
|
-
const result = await dispatchSubgraph(worker, {
|
|
163
|
-
input: payload,
|
|
164
|
-
async: false,
|
|
165
|
-
});
|
|
166
|
-
dispatched.push({
|
|
167
|
-
issueId: String(issue.id),
|
|
168
|
-
severity,
|
|
169
|
-
status: 'sent',
|
|
170
|
-
messageTs: result?.notify_slack?.messageTs || result?.messageTs,
|
|
171
|
-
messageId: result?.notify_lark?.messageId || result?.messageId,
|
|
172
|
-
});
|
|
173
|
-
} catch (err) {
|
|
174
|
-
dispatched.push({
|
|
175
|
-
issueId: String(issue.id),
|
|
176
|
-
severity,
|
|
177
|
-
status: 'failed',
|
|
178
|
-
detail: err?.message || String(err),
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const summary = {
|
|
184
|
-
total: dispatched.length,
|
|
185
|
-
sent: dispatched.filter((d) => d.status === 'sent').length,
|
|
186
|
-
skipped: dispatched.filter((d) => d.status === 'skipped').length,
|
|
187
|
-
failed: dispatched.filter((d) => d.status === 'failed').length,
|
|
188
|
-
};
|
|
189
|
-
return { dispatched, summary };
|
|
190
|
-
},
|
|
191
|
-
};
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* filter-noise node — deterministic regex-based pre-LLM filter.
|
|
3
|
-
*
|
|
4
|
-
* Cuts LLM cost ~80% on a typical Sentry stream by dropping issues
|
|
5
|
-
* that are obviously noise BEFORE we pay for classification. The
|
|
6
|
-
* patterns are deliberately conservative — anything ambiguous goes
|
|
7
|
-
* through to the LLM classifier rather than being killed here.
|
|
8
|
-
*
|
|
9
|
-
* Noise categories (matched in order; first hit wins):
|
|
10
|
-
*
|
|
11
|
-
* 1. Cross-origin / opaque errors:
|
|
12
|
-
* - "Script error." (literal, with period) — useless without CORS
|
|
13
|
-
* - "Non-Error promise rejection captured"
|
|
14
|
-
*
|
|
15
|
-
* 2. Browser-internal benign loops:
|
|
16
|
-
* - "ResizeObserver loop limit exceeded"
|
|
17
|
-
* - "ResizeObserver loop completed with undelivered notifications"
|
|
18
|
-
*
|
|
19
|
-
* 3. Browser-extension noise — any frame URL with extension scheme:
|
|
20
|
-
* - chrome-extension://, safari-extension://, moz-extension://
|
|
21
|
-
*
|
|
22
|
-
* 4. Cancelled/aborted requests (user navigated away):
|
|
23
|
-
* - "AbortError", title containing "cancelled" / "Failed to fetch"
|
|
24
|
-
* AND empty stack frames
|
|
25
|
-
*
|
|
26
|
-
* 5. Bot/crawler traffic — usually surfaces via specific tag patterns;
|
|
27
|
-
* v1 doesn't have the tags in our payload shape so we skip. Add
|
|
28
|
-
* when the event-detail path is wired in.
|
|
29
|
-
*
|
|
30
|
-
* Anything not matching falls through to the LLM. The output carries
|
|
31
|
-
* BOTH the kept list and the dropped list (with reason) so the
|
|
32
|
-
* downstream dispatcher can report on filter activity ("auto-ignored
|
|
33
|
-
* 14 noise issues this hour").
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import { z } from 'zod';
|
|
37
|
-
|
|
38
|
-
const FilterNoiseOutputSchema = z.object({
|
|
39
|
-
kept: z.array(z.any()).describe('Issues that survive the noise filter — passed to LLM classifier.'),
|
|
40
|
-
dropped: z.array(z.object({
|
|
41
|
-
id: z.string(),
|
|
42
|
-
reason: z.string(),
|
|
43
|
-
})).describe('Issues filtered out and why — surfaced in the final summary.'),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/** Test hook — exposes the rule table so unit tests can assert
|
|
47
|
-
* per-pattern matching without re-typing them. */
|
|
48
|
-
export const NOISE_RULES = Object.freeze([
|
|
49
|
-
{ reason: 'cross-origin opaque error', test: (issue) => /^Script error\.?$/i.test((issue.title || '').trim()) },
|
|
50
|
-
{ reason: 'non-Error promise rejection', test: (issue) => /Non-Error promise rejection captured/i.test(issue.title || '') },
|
|
51
|
-
{ reason: 'ResizeObserver loop', test: (issue) => /ResizeObserver loop (limit exceeded|completed)/i.test(issue.title || '') },
|
|
52
|
-
{ reason: 'browser-extension frame', test: (issue) => isExtensionUrl(issue?.metadata?.filename) || isExtensionUrl(issue?.culprit) },
|
|
53
|
-
{ reason: 'analytics SDK', test: (issue) => /\b(gtag|fbq|_paq|dataLayer|googletagmanager|piwik)\b/i.test(`${issue.title || ''} ${issue.culprit || ''}`) },
|
|
54
|
-
{ reason: 'aborted/cancelled request', test: (issue) => /AbortError|cancelled|Load failed/i.test(issue.title || '') && (!issue.userCount || issue.userCount < 3) },
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
function isExtensionUrl(url) {
|
|
58
|
-
if (!url || typeof url !== 'string') return false;
|
|
59
|
-
return /^(?:chrome-extension|safari-extension|moz-extension|webkit-masked-url):\/\//.test(url)
|
|
60
|
-
|| /chrome-extension:\/\//.test(url)
|
|
61
|
-
|| /safari-extension:\/\//.test(url);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Run the noise filter on an array of issues. Returns
|
|
66
|
-
* `{ kept, dropped }`. Pure function — exposed so tests can call it
|
|
67
|
-
* without going through the node's execute wrapper.
|
|
68
|
-
*/
|
|
69
|
-
export function filterNoiseIssues(issues) {
|
|
70
|
-
const kept = [];
|
|
71
|
-
const dropped = [];
|
|
72
|
-
for (const issue of issues || []) {
|
|
73
|
-
if (!issue || typeof issue !== 'object' || !issue.id) {
|
|
74
|
-
// Defensive: malformed entries go to dropped rather than crashing
|
|
75
|
-
// the whole filter run.
|
|
76
|
-
dropped.push({ id: String(issue?.id || '<unknown>'), reason: 'malformed-issue' });
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
let matched = null;
|
|
80
|
-
for (const rule of NOISE_RULES) {
|
|
81
|
-
try {
|
|
82
|
-
if (rule.test(issue)) {
|
|
83
|
-
matched = rule.reason;
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
// A buggy rule shouldn't break the whole filter; just skip it.
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (matched) {
|
|
91
|
-
dropped.push({ id: String(issue.id), reason: matched });
|
|
92
|
-
} else {
|
|
93
|
-
kept.push(issue);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return { kept, dropped };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export const filterNoiseNode = {
|
|
100
|
-
name: 'filter_noise',
|
|
101
|
-
outputSchema: FilterNoiseOutputSchema,
|
|
102
|
-
// 5s is generous for a pure-CPU filter even on 100 issues — the
|
|
103
|
-
// input is already bounded by maxIssues (default 20).
|
|
104
|
-
timeout: 5 * 1000,
|
|
105
|
-
execute: async (context) => {
|
|
106
|
-
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
107
|
-
? context.state.getAll()
|
|
108
|
-
: context;
|
|
109
|
-
const issues = state?.fetch_issues?.issues || [];
|
|
110
|
-
return filterNoiseIssues(issues);
|
|
111
|
-
},
|
|
112
|
-
};
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
You are the **classify** node of a Sentry triage workflow. Your job is to classify each kept issue (after the `filter_noise` node removed obvious garbage) into a severity bucket and explain WHY.
|
|
2
|
-
|
|
3
|
-
## Inputs (read from `state`)
|
|
4
|
-
|
|
5
|
-
- `state.filter_noise.kept` — array of Sentry issue objects that passed the regex noise filter
|
|
6
|
-
- `state.fetch_issues.issues` — (alias) the original list, for cross-referencing if needed
|
|
7
|
-
|
|
8
|
-
## Severity rubric (apply IN ORDER, stop at first match)
|
|
9
|
-
|
|
10
|
-
1. **CRITICAL** if ANY of:
|
|
11
|
-
- `userCount >= 20` (≥ 20 users affected — real prod impact)
|
|
12
|
-
- `culprit` or `metadata.filename` matches `/payment|billing|checkout|auth|login|signup|session/i` (security/revenue path)
|
|
13
|
-
- `level === "fatal"` and `count >= 10`
|
|
14
|
-
- `count >= 100` AND the first-seen-to-last-seen window is < 30 min (active spike)
|
|
15
|
-
|
|
16
|
-
2. **HIGH** if ANY of:
|
|
17
|
-
- `userCount >= 5` AND `count >= 50`
|
|
18
|
-
- `level === "fatal"` (any count)
|
|
19
|
-
- `level === "error"` AND `userCount >= 3` AND `count >= 20`
|
|
20
|
-
- Errors in non-critical-but-important paths: settings, profile, search, dashboard, admin
|
|
21
|
-
|
|
22
|
-
3. **MEDIUM** if ANY of:
|
|
23
|
-
- `count >= 20` AND `userCount >= 2`
|
|
24
|
-
- `count >= 50` regardless of userCount
|
|
25
|
-
- `level === "error"` AND `count >= 10`
|
|
26
|
-
|
|
27
|
-
4. **LOW** if ANY of:
|
|
28
|
-
- `count < 20` AND `userCount < 5`
|
|
29
|
-
- `level === "warning"` or `level === "info"`
|
|
30
|
-
|
|
31
|
-
5. **NOISE** — only if the issue clearly slipped past the regex filter. Reasons:
|
|
32
|
-
- Title says "Test ", "Demo ", "[STAGING]" — wrong environment
|
|
33
|
-
- Stack trace has zero `inApp:true` frames (3rd-party only)
|
|
34
|
-
- User-agent string indicates a bot (Googlebot, AhrefsBot, etc.) — though typically the issue has no user_count if it's bot traffic
|
|
35
|
-
|
|
36
|
-
## Recommended action per severity
|
|
37
|
-
|
|
38
|
-
- **CRITICAL** → `page_oncall` (always notify, always mention rotation)
|
|
39
|
-
- **HIGH** → `notify_channel` (notify, no @ unless deploy author known)
|
|
40
|
-
- **MEDIUM** → `notify_channel`
|
|
41
|
-
- **LOW** → `digest_only` (rolled into a daily summary — not real-time noise)
|
|
42
|
-
- **NOISE** → `ignore`
|
|
43
|
-
|
|
44
|
-
## Output shape
|
|
45
|
-
|
|
46
|
-
For EACH issue in `state.filter_noise.kept`, emit ONE classification record. Order doesn't matter.
|
|
47
|
-
|
|
48
|
-
```json
|
|
49
|
-
{
|
|
50
|
-
"classifications": [
|
|
51
|
-
{
|
|
52
|
-
"issueId": "1234567890",
|
|
53
|
-
"severity": "CRITICAL",
|
|
54
|
-
"confidence": 0.95,
|
|
55
|
-
"reasoning": "12 users affected, culprit handleCheckout (payment path). Likely regression after recent deploy.",
|
|
56
|
-
"suggestedAction": "page_oncall",
|
|
57
|
-
"ruleMatched": "rule 1 (culprit matches /checkout/)"
|
|
58
|
-
}
|
|
59
|
-
]
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
## Rules of engagement
|
|
64
|
-
|
|
65
|
-
- `confidence` reflects how cleanly the issue matched the rubric. CRITICAL with userCount=50 in /payment/ → 0.95. LOW vs MEDIUM borderline → 0.6.
|
|
66
|
-
- `reasoning` is ONE sentence, written for an on-call engineer who sees it in Slack. Avoid academic prose; lead with the impact metric.
|
|
67
|
-
- `ruleMatched` is which numbered rule fired. Helps operators tune the rubric over time.
|
|
68
|
-
- Be consistent: same issue twice should always get the same severity.
|
|
69
|
-
- Temperature should be 0 — this is a classification task, not creative writing.
|
|
70
|
-
|
|
71
|
-
## Do NOT
|
|
72
|
-
|
|
73
|
-
- Do NOT classify more issues than were in `state.filter_noise.kept`.
|
|
74
|
-
- Do NOT skip issues — every kept issue must appear in the output.
|
|
75
|
-
- Do NOT use any severity outside `NOISE|LOW|MEDIUM|HIGH|CRITICAL`.
|
|
76
|
-
- Do NOT call any tools. This is a pure-classification node — the inputs are already in state.
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
You are the **fetch_issues** node of a Sentry triage workflow.
|
|
2
|
-
|
|
3
|
-
## Your job
|
|
4
|
-
|
|
5
|
-
Pull the list of recently-firstSeen, unresolved, unassigned issues from the configured Sentry project, then return them in a structured JSON object matching the node's outputSchema.
|
|
6
|
-
|
|
7
|
-
## Inputs (read from `state`)
|
|
8
|
-
|
|
9
|
-
- `state.organizationSlug` — Sentry org slug
|
|
10
|
-
- `state.projectSlug` — project slug to scope the query to
|
|
11
|
-
- `state.environment` — environment tag to filter (e.g. `"production"`)
|
|
12
|
-
- `state.sinceMinutes` — look back this many minutes (default 60)
|
|
13
|
-
- `state.maxIssues` — cap returned issues (default 20)
|
|
14
|
-
|
|
15
|
-
## How to do it
|
|
16
|
-
|
|
17
|
-
1. Use the **`sentry_list_issues`** tool with:
|
|
18
|
-
```
|
|
19
|
-
project: <projectSlug>
|
|
20
|
-
query: "is:unresolved is:unassigned firstSeen:-<sinceMinutes>m environment:<environment>"
|
|
21
|
-
sort: "created"
|
|
22
|
-
limit: <maxIssues>
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
2. Each issue in the result has fields:
|
|
26
|
-
`id, shortId, title, culprit, level, status, count, userCount, firstSeen, lastSeen, permalink, metadata`.
|
|
27
|
-
|
|
28
|
-
3. Pass them through verbatim. Do NOT classify or filter here — that's the next two nodes' job. Your only job is to fetch.
|
|
29
|
-
|
|
30
|
-
4. If the Sentry API returns 0 issues for the window, return `{ issues: [], fetchedAt: <ISO timestamp> }`. The downstream nodes handle empty lists gracefully.
|
|
31
|
-
|
|
32
|
-
5. If the API errors (token missing, project not found, rate-limited), throw a clear error including the Sentry error code. The runner surfaces this on the workflow execution row.
|
|
33
|
-
|
|
34
|
-
## Output shape (strict — outputSchema-enforced)
|
|
35
|
-
|
|
36
|
-
```json
|
|
37
|
-
{
|
|
38
|
-
"issues": [
|
|
39
|
-
{
|
|
40
|
-
"id": "1234567890",
|
|
41
|
-
"shortId": "ZIBBY-API-42K",
|
|
42
|
-
"title": "TypeError: Cannot read properties of undefined (reading 'id')",
|
|
43
|
-
"culprit": "handleCheckout(checkout.ts)",
|
|
44
|
-
"level": "error",
|
|
45
|
-
"status": "unresolved",
|
|
46
|
-
"count": 47,
|
|
47
|
-
"userCount": 12,
|
|
48
|
-
"firstSeen": "2026-05-19T08:14:22Z",
|
|
49
|
-
"lastSeen": "2026-05-19T09:02:11Z",
|
|
50
|
-
"permalink": "https://sentry.io/organizations/zibby/issues/1234567890/",
|
|
51
|
-
"metadata": {
|
|
52
|
-
"type": "TypeError",
|
|
53
|
-
"value": "Cannot read properties of undefined (reading 'id')",
|
|
54
|
-
"filename": "src/handlers/checkout.ts"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
],
|
|
58
|
-
"fetchedAt": "2026-05-19T09:00:00Z"
|
|
59
|
-
}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
## Do NOT
|
|
63
|
-
|
|
64
|
-
- Do NOT filter "obvious noise" issues here. The next node (`filter_noise`) does that with cheap regex rules — your job is the unfiltered firehose.
|
|
65
|
-
- Do NOT call `sentry_get_issue` per issue. The list endpoint returns everything we need for triage; per-issue detail fetch is wasteful.
|
|
66
|
-
- Do NOT invent fields. If `userCount` is missing on an issue, omit it; don't fill in 0.
|