@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
|
@@ -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.
|