@zibby/workflow-templates 0.4.1 → 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 +117 -31
- package/notify-lark/nodes/notify-lark-node.js +14 -1
- package/notify-lark/state.js +12 -2
- package/notify-notion/README.md +71 -0
- package/notify-notion/brand/notion-logo.svg +4 -0
- package/notify-notion/graph.mjs +64 -0
- package/notify-notion/icon.png +0 -0
- package/notify-notion/nodes/notify-notion-node.js +342 -0
- package/notify-notion/package.json +19 -0
- package/notify-notion/state.js +110 -0
- package/notify-slack/nodes/notify-slack-node.js +35 -5
- package/notify-slack/state.js +21 -2
- package/package.json +8 -2
- 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/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);
|