@cronwatch/sdk 0.1.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/README.md +69 -0
- package/dist/anthropic.cjs +84 -0
- package/dist/anthropic.cjs.map +1 -0
- package/dist/anthropic.d.cts +35 -0
- package/dist/anthropic.d.ts +35 -0
- package/dist/anthropic.js +78 -0
- package/dist/anthropic.js.map +1 -0
- package/dist/discord.cjs +42 -0
- package/dist/discord.cjs.map +1 -0
- package/dist/discord.d.cts +11 -0
- package/dist/discord.d.ts +11 -0
- package/dist/discord.js +40 -0
- package/dist/discord.js.map +1 -0
- package/dist/index.cjs +1317 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +219 -0
- package/dist/index.d.ts +219 -0
- package/dist/index.js +1302 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.cjs +142 -0
- package/dist/postgres.cjs.map +1 -0
- package/dist/postgres.d.cts +19 -0
- package/dist/postgres.d.ts +19 -0
- package/dist/postgres.js +136 -0
- package/dist/postgres.js.map +1 -0
- package/dist/slack.cjs +44 -0
- package/dist/slack.cjs.map +1 -0
- package/dist/slack.d.cts +12 -0
- package/dist/slack.d.ts +12 -0
- package/dist/slack.js +42 -0
- package/dist/slack.js.map +1 -0
- package/dist/sqlite.cjs +151 -0
- package/dist/sqlite.cjs.map +1 -0
- package/dist/sqlite.d.cts +17 -0
- package/dist/sqlite.d.ts +17 -0
- package/dist/sqlite.js +144 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/types-BQ1P8z55.d.cts +159 -0
- package/dist/types-BQ1P8z55.d.ts +159 -0
- package/dist/webhook.cjs +24 -0
- package/dist/webhook.cjs.map +1 -0
- package/dist/webhook.d.cts +20 -0
- package/dist/webhook.d.ts +20 -0
- package/dist/webhook.js +22 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +114 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @cronwatch/sdk
|
|
2
|
+
|
|
3
|
+
Cron and scheduled-job monitoring that lives inside your app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow or goes over budget. No server to run, no account to make.
|
|
4
|
+
|
|
5
|
+
Docs: [cronwatch.dev/docs](https://cronwatch.dev/docs/)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @cronwatch/sdk
|
|
9
|
+
npm install better-sqlite3 # or pg
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { cronwatch } from "@cronwatch/sdk";
|
|
14
|
+
import { sqlite } from "@cronwatch/sdk/sqlite";
|
|
15
|
+
import { slack } from "@cronwatch/sdk/slack";
|
|
16
|
+
|
|
17
|
+
export const cw = cronwatch({
|
|
18
|
+
store: sqlite({ path: "./data/cronwatch.db" }),
|
|
19
|
+
alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const nightlyReport = cw.job("nightly-report", {
|
|
23
|
+
schedule: "0 2 * * *", // cron, "@hourly", or "every 15m"
|
|
24
|
+
timezone: "UTC",
|
|
25
|
+
grace: "15m", // how late a start may be before it is missed
|
|
26
|
+
timeout: "30m", // a run still going after this is stuck
|
|
27
|
+
expect: "Report written", // output must contain this, or the run failed
|
|
28
|
+
budget: { cost: 2 }, // cost above 2 is over budget
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// A fetch-style handler (Next.js route, Hono, Bun): checks Authorization: Bearer CRON_SECRET
|
|
32
|
+
export const GET = nightlyReport.handler(async (job) => {
|
|
33
|
+
const report = await buildReport();
|
|
34
|
+
job.log("Report written:", report.path);
|
|
35
|
+
job.metric("cost", report.usdCost);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Or a plain function
|
|
39
|
+
await nightlyReport.run(async (job) => { /* ... */ });
|
|
40
|
+
|
|
41
|
+
// Dashboard and JSON API, behind CRONWATCH_TOKEN
|
|
42
|
+
export const { GET, POST, DELETE } = cw.routes();
|
|
43
|
+
|
|
44
|
+
// Missed and stuck runs are found by the check: on an interval, or a cron hitting /cronwatch/api/check
|
|
45
|
+
cw.start();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## What it catches
|
|
49
|
+
|
|
50
|
+
- **missed**: the schedule said a run was due and none started within the grace period
|
|
51
|
+
- **failed**: the function threw, the handler returned 4xx or 5xx, or the output did not satisfy `expect`
|
|
52
|
+
- **stuck**: a run started and never reported finishing within `timeout`
|
|
53
|
+
- **slow**: a successful run took longer than `maxDuration`, or twice the job's recent p95
|
|
54
|
+
- **over_budget**: a metric went above its `budget` ceiling, or three times its usual median
|
|
55
|
+
- **recovered**: a run succeeded after any of the above
|
|
56
|
+
|
|
57
|
+
Each condition alerts once when it opens and once when it clears.
|
|
58
|
+
|
|
59
|
+
## Entry points
|
|
60
|
+
|
|
61
|
+
| Import | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `@cronwatch/sdk` | `cronwatch`, `memory`, `custom`, `consoleChannel`, `createRoutes`, types |
|
|
64
|
+
| `@cronwatch/sdk/sqlite` | `sqlite({ path })`, needs `better-sqlite3` |
|
|
65
|
+
| `@cronwatch/sdk/postgres` | `postgres({ connectionString })`, needs `pg` |
|
|
66
|
+
| `@cronwatch/sdk/slack`, `/discord`, `/webhook` | alert channels |
|
|
67
|
+
| `@cronwatch/sdk/anthropic` | `anthropic()` triage: a short diagnosis on every failure alert, needs `@anthropic-ai/sdk` |
|
|
68
|
+
|
|
69
|
+
Node 20 or newer; `better-sqlite3` 13 needs Node 22. MIT.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var Anthropic = require('@anthropic-ai/sdk');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var Anthropic__default = /*#__PURE__*/_interopDefault(Anthropic);
|
|
8
|
+
|
|
9
|
+
// src/triage/anthropic.ts
|
|
10
|
+
|
|
11
|
+
// src/duration.ts
|
|
12
|
+
function formatDuration(ms) {
|
|
13
|
+
if (!Number.isFinite(ms)) return "?";
|
|
14
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
15
|
+
const parts = [];
|
|
16
|
+
let rest = Math.round(ms / 1e3);
|
|
17
|
+
const units = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];
|
|
18
|
+
for (const [unit, size] of units) {
|
|
19
|
+
if (rest >= size) {
|
|
20
|
+
const n = Math.floor(rest / size);
|
|
21
|
+
rest -= n * size;
|
|
22
|
+
parts.push(`${n}${unit}`);
|
|
23
|
+
}
|
|
24
|
+
if (parts.length === 2) break;
|
|
25
|
+
}
|
|
26
|
+
return parts.join(" ") || "0s";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/triage/anthropic.ts
|
|
30
|
+
var SYSTEM = `You help an engineer understand why a scheduled job misbehaved. You are given the alert, the job's definition, the run that triggered it and a few earlier runs.
|
|
31
|
+
|
|
32
|
+
Reply with two to four sentences of plain prose: the most likely cause, and the first concrete thing to check or change. Be specific to the evidence given; if the evidence is thin, say what is missing rather than guessing. No headings, no lists, no preamble, no restating the error verbatim.`;
|
|
33
|
+
function describe(ctx) {
|
|
34
|
+
const { alert, recentRuns } = ctx;
|
|
35
|
+
const run = alert.run;
|
|
36
|
+
const lines = [];
|
|
37
|
+
lines.push(`Alert: ${alert.type}. ${alert.title}`);
|
|
38
|
+
lines.push(alert.message);
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(`Job definition: ${JSON.stringify(alert.definition)}`);
|
|
41
|
+
if (run) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push(`Triggering run: status ${run.status}, started ${new Date(run.startedAt).toISOString()}, duration ${run.durationMs === null ? "unknown" : formatDuration(run.durationMs)}, trigger ${run.trigger}`);
|
|
44
|
+
if (Object.keys(run.metrics).length) lines.push(`Metrics: ${JSON.stringify(run.metrics)}`);
|
|
45
|
+
if (run.error) lines.push(`Error:
|
|
46
|
+
${run.error.slice(0, 3e3)}`);
|
|
47
|
+
if (run.output) lines.push(`Output (tail):
|
|
48
|
+
${run.output.slice(-3e3)}`);
|
|
49
|
+
}
|
|
50
|
+
const earlier = recentRuns.filter((r) => r.id !== run?.id).slice(0, 5);
|
|
51
|
+
if (earlier.length) {
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push("Earlier runs, newest first:");
|
|
54
|
+
for (const r of earlier) {
|
|
55
|
+
lines.push(`- ${r.status}, ${new Date(r.startedAt).toISOString()}, ${r.durationMs === null ? "unknown" : formatDuration(r.durationMs)}${r.error ? `, error: ${r.error.split("\n")[0].slice(0, 160)}` : ""}${Object.keys(r.metrics).length ? `, metrics ${JSON.stringify(r.metrics)}` : ""}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
function anthropic(options = {}) {
|
|
61
|
+
const client = options.client ?? new Anthropic__default.default(options.apiKey ? { apiKey: options.apiKey } : {});
|
|
62
|
+
const model = options.model ?? "claude-opus-5";
|
|
63
|
+
const useFallbacks = options.fallbacks ?? true;
|
|
64
|
+
return async (ctx) => {
|
|
65
|
+
const user = (options.context ? `About this app: ${options.context}
|
|
66
|
+
|
|
67
|
+
` : "") + describe(ctx);
|
|
68
|
+
const response = await client.beta.messages.create({
|
|
69
|
+
model,
|
|
70
|
+
max_tokens: options.maxTokens ?? 800,
|
|
71
|
+
system: SYSTEM,
|
|
72
|
+
output_config: { effort: options.effort ?? "medium" },
|
|
73
|
+
messages: [{ role: "user", content: user }],
|
|
74
|
+
...useFallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}
|
|
75
|
+
});
|
|
76
|
+
if (response.stop_reason === "refusal") return null;
|
|
77
|
+
const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
78
|
+
return text || null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
exports.anthropic = anthropic;
|
|
83
|
+
//# sourceMappingURL=anthropic.cjs.map
|
|
84
|
+
//# sourceMappingURL=anthropic.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/triage/anthropic.ts"],"names":["Anthropic"],"mappings":"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","file":"anthropic.cjs","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n","import Anthropic from \"@anthropic-ai/sdk\";\nimport { formatDuration } from \"../duration.js\";\nimport type { TriageContext, TriageFn } from \"../types.js\";\n\nexport interface AnthropicTriageOptions {\n /** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */\n apiKey?: string;\n /** Bring a configured client instead. */\n client?: Anthropic;\n /** Default \"claude-opus-5\". */\n model?: string;\n /** How hard the model thinks. Default \"medium\"; a stack trace rarely needs more. */\n effort?: \"low\" | \"medium\" | \"high\";\n /** Default 800. A diagnosis is a paragraph. */\n maxTokens?: number;\n /**\n * Route a policy refusal to Anthropic's default fallback model inside the\n * same request, so a diagnosis still comes back. On by default; turn off if\n * your account or gateway rejects the beta.\n */\n fallbacks?: boolean;\n /** Anything the model should know about this app: \"A Next.js app on Vercel with a Neon database.\" */\n context?: string;\n}\n\nconst SYSTEM = `You help an engineer understand why a scheduled job misbehaved. You are given the alert, the job's definition, the run that triggered it and a few earlier runs.\n\nReply with two to four sentences of plain prose: the most likely cause, and the first concrete thing to check or change. Be specific to the evidence given; if the evidence is thin, say what is missing rather than guessing. No headings, no lists, no preamble, no restating the error verbatim.`;\n\nfunction describe(ctx: TriageContext): string {\n const { alert, recentRuns } = ctx;\n const run = alert.run;\n const lines: string[] = [];\n lines.push(`Alert: ${alert.type}. ${alert.title}`);\n lines.push(alert.message);\n lines.push(\"\");\n lines.push(`Job definition: ${JSON.stringify(alert.definition)}`);\n if (run) {\n lines.push(\"\");\n lines.push(`Triggering run: status ${run.status}, started ${new Date(run.startedAt).toISOString()}, duration ${run.durationMs === null ? \"unknown\" : formatDuration(run.durationMs)}, trigger ${run.trigger}`);\n if (Object.keys(run.metrics).length) lines.push(`Metrics: ${JSON.stringify(run.metrics)}`);\n if (run.error) lines.push(`Error:\\n${run.error.slice(0, 3000)}`);\n if (run.output) lines.push(`Output (tail):\\n${run.output.slice(-3000)}`);\n }\n const earlier = recentRuns.filter((r) => r.id !== run?.id).slice(0, 5);\n if (earlier.length) {\n lines.push(\"\");\n lines.push(\"Earlier runs, newest first:\");\n for (const r of earlier) {\n lines.push(`- ${r.status}, ${new Date(r.startedAt).toISOString()}, ${r.durationMs === null ? \"unknown\" : formatDuration(r.durationMs)}${r.error ? `, error: ${r.error.split(\"\\n\")[0]!.slice(0, 160)}` : \"\"}${Object.keys(r.metrics).length ? `, metrics ${JSON.stringify(r.metrics)}` : \"\"}`);\n }\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * A triage function backed by Claude. Pass it as `triage` to cronwatch():\n *\n * cronwatch({ triage: anthropic({ context: \"A Next.js app on Vercel.\" }) })\n *\n * It runs only when an alert is sent (never per run), so cost is bounded by\n * how often things go wrong, and it never blocks an alert: the client gives\n * it 25 seconds and moves on without a diagnosis if it takes longer.\n */\nexport function anthropic(options: AnthropicTriageOptions = {}): TriageFn {\n const client = options.client ?? new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\n const model = options.model ?? \"claude-opus-5\";\n const useFallbacks = options.fallbacks ?? true;\n\n return async (ctx) => {\n const user = (options.context ? `About this app: ${options.context}\\n\\n` : \"\") + describe(ctx);\n const response = await client.beta.messages.create({\n model,\n max_tokens: options.maxTokens ?? 800,\n system: SYSTEM,\n output_config: { effort: options.effort ?? \"medium\" },\n messages: [{ role: \"user\", content: user }],\n ...(useFallbacks ? { betas: [\"server-side-fallback-2026-07-01\"], fallbacks: \"default\" } : {}),\n });\n if (response.stop_reason === \"refusal\") return null;\n const text = response.content\n .filter((block): block is Anthropic.Beta.BetaTextBlock => block.type === \"text\")\n .map((block) => block.text)\n .join(\"\\n\")\n .trim();\n return text || null;\n };\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { T as TriageFn } from './types-BQ1P8z55.cjs';
|
|
3
|
+
|
|
4
|
+
interface AnthropicTriageOptions {
|
|
5
|
+
/** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Bring a configured client instead. */
|
|
8
|
+
client?: Anthropic;
|
|
9
|
+
/** Default "claude-opus-5". */
|
|
10
|
+
model?: string;
|
|
11
|
+
/** How hard the model thinks. Default "medium"; a stack trace rarely needs more. */
|
|
12
|
+
effort?: "low" | "medium" | "high";
|
|
13
|
+
/** Default 800. A diagnosis is a paragraph. */
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Route a policy refusal to Anthropic's default fallback model inside the
|
|
17
|
+
* same request, so a diagnosis still comes back. On by default; turn off if
|
|
18
|
+
* your account or gateway rejects the beta.
|
|
19
|
+
*/
|
|
20
|
+
fallbacks?: boolean;
|
|
21
|
+
/** Anything the model should know about this app: "A Next.js app on Vercel with a Neon database." */
|
|
22
|
+
context?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A triage function backed by Claude. Pass it as `triage` to cronwatch():
|
|
26
|
+
*
|
|
27
|
+
* cronwatch({ triage: anthropic({ context: "A Next.js app on Vercel." }) })
|
|
28
|
+
*
|
|
29
|
+
* It runs only when an alert is sent (never per run), so cost is bounded by
|
|
30
|
+
* how often things go wrong, and it never blocks an alert: the client gives
|
|
31
|
+
* it 25 seconds and moves on without a diagnosis if it takes longer.
|
|
32
|
+
*/
|
|
33
|
+
declare function anthropic(options?: AnthropicTriageOptions): TriageFn;
|
|
34
|
+
|
|
35
|
+
export { type AnthropicTriageOptions, anthropic };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { T as TriageFn } from './types-BQ1P8z55.js';
|
|
3
|
+
|
|
4
|
+
interface AnthropicTriageOptions {
|
|
5
|
+
/** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Bring a configured client instead. */
|
|
8
|
+
client?: Anthropic;
|
|
9
|
+
/** Default "claude-opus-5". */
|
|
10
|
+
model?: string;
|
|
11
|
+
/** How hard the model thinks. Default "medium"; a stack trace rarely needs more. */
|
|
12
|
+
effort?: "low" | "medium" | "high";
|
|
13
|
+
/** Default 800. A diagnosis is a paragraph. */
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Route a policy refusal to Anthropic's default fallback model inside the
|
|
17
|
+
* same request, so a diagnosis still comes back. On by default; turn off if
|
|
18
|
+
* your account or gateway rejects the beta.
|
|
19
|
+
*/
|
|
20
|
+
fallbacks?: boolean;
|
|
21
|
+
/** Anything the model should know about this app: "A Next.js app on Vercel with a Neon database." */
|
|
22
|
+
context?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A triage function backed by Claude. Pass it as `triage` to cronwatch():
|
|
26
|
+
*
|
|
27
|
+
* cronwatch({ triage: anthropic({ context: "A Next.js app on Vercel." }) })
|
|
28
|
+
*
|
|
29
|
+
* It runs only when an alert is sent (never per run), so cost is bounded by
|
|
30
|
+
* how often things go wrong, and it never blocks an alert: the client gives
|
|
31
|
+
* it 25 seconds and moves on without a diagnosis if it takes longer.
|
|
32
|
+
*/
|
|
33
|
+
declare function anthropic(options?: AnthropicTriageOptions): TriageFn;
|
|
34
|
+
|
|
35
|
+
export { type AnthropicTriageOptions, anthropic };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
|
|
3
|
+
// src/triage/anthropic.ts
|
|
4
|
+
|
|
5
|
+
// src/duration.ts
|
|
6
|
+
function formatDuration(ms) {
|
|
7
|
+
if (!Number.isFinite(ms)) return "?";
|
|
8
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
9
|
+
const parts = [];
|
|
10
|
+
let rest = Math.round(ms / 1e3);
|
|
11
|
+
const units = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];
|
|
12
|
+
for (const [unit, size] of units) {
|
|
13
|
+
if (rest >= size) {
|
|
14
|
+
const n = Math.floor(rest / size);
|
|
15
|
+
rest -= n * size;
|
|
16
|
+
parts.push(`${n}${unit}`);
|
|
17
|
+
}
|
|
18
|
+
if (parts.length === 2) break;
|
|
19
|
+
}
|
|
20
|
+
return parts.join(" ") || "0s";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/triage/anthropic.ts
|
|
24
|
+
var SYSTEM = `You help an engineer understand why a scheduled job misbehaved. You are given the alert, the job's definition, the run that triggered it and a few earlier runs.
|
|
25
|
+
|
|
26
|
+
Reply with two to four sentences of plain prose: the most likely cause, and the first concrete thing to check or change. Be specific to the evidence given; if the evidence is thin, say what is missing rather than guessing. No headings, no lists, no preamble, no restating the error verbatim.`;
|
|
27
|
+
function describe(ctx) {
|
|
28
|
+
const { alert, recentRuns } = ctx;
|
|
29
|
+
const run = alert.run;
|
|
30
|
+
const lines = [];
|
|
31
|
+
lines.push(`Alert: ${alert.type}. ${alert.title}`);
|
|
32
|
+
lines.push(alert.message);
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(`Job definition: ${JSON.stringify(alert.definition)}`);
|
|
35
|
+
if (run) {
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(`Triggering run: status ${run.status}, started ${new Date(run.startedAt).toISOString()}, duration ${run.durationMs === null ? "unknown" : formatDuration(run.durationMs)}, trigger ${run.trigger}`);
|
|
38
|
+
if (Object.keys(run.metrics).length) lines.push(`Metrics: ${JSON.stringify(run.metrics)}`);
|
|
39
|
+
if (run.error) lines.push(`Error:
|
|
40
|
+
${run.error.slice(0, 3e3)}`);
|
|
41
|
+
if (run.output) lines.push(`Output (tail):
|
|
42
|
+
${run.output.slice(-3e3)}`);
|
|
43
|
+
}
|
|
44
|
+
const earlier = recentRuns.filter((r) => r.id !== run?.id).slice(0, 5);
|
|
45
|
+
if (earlier.length) {
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push("Earlier runs, newest first:");
|
|
48
|
+
for (const r of earlier) {
|
|
49
|
+
lines.push(`- ${r.status}, ${new Date(r.startedAt).toISOString()}, ${r.durationMs === null ? "unknown" : formatDuration(r.durationMs)}${r.error ? `, error: ${r.error.split("\n")[0].slice(0, 160)}` : ""}${Object.keys(r.metrics).length ? `, metrics ${JSON.stringify(r.metrics)}` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function anthropic(options = {}) {
|
|
55
|
+
const client = options.client ?? new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});
|
|
56
|
+
const model = options.model ?? "claude-opus-5";
|
|
57
|
+
const useFallbacks = options.fallbacks ?? true;
|
|
58
|
+
return async (ctx) => {
|
|
59
|
+
const user = (options.context ? `About this app: ${options.context}
|
|
60
|
+
|
|
61
|
+
` : "") + describe(ctx);
|
|
62
|
+
const response = await client.beta.messages.create({
|
|
63
|
+
model,
|
|
64
|
+
max_tokens: options.maxTokens ?? 800,
|
|
65
|
+
system: SYSTEM,
|
|
66
|
+
output_config: { effort: options.effort ?? "medium" },
|
|
67
|
+
messages: [{ role: "user", content: user }],
|
|
68
|
+
...useFallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}
|
|
69
|
+
});
|
|
70
|
+
if (response.stop_reason === "refusal") return null;
|
|
71
|
+
const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
72
|
+
return text || null;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { anthropic };
|
|
77
|
+
//# sourceMappingURL=anthropic.js.map
|
|
78
|
+
//# sourceMappingURL=anthropic.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/triage/anthropic.ts"],"names":[],"mappings":"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","file":"anthropic.js","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n","import Anthropic from \"@anthropic-ai/sdk\";\nimport { formatDuration } from \"../duration.js\";\nimport type { TriageContext, TriageFn } from \"../types.js\";\n\nexport interface AnthropicTriageOptions {\n /** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */\n apiKey?: string;\n /** Bring a configured client instead. */\n client?: Anthropic;\n /** Default \"claude-opus-5\". */\n model?: string;\n /** How hard the model thinks. Default \"medium\"; a stack trace rarely needs more. */\n effort?: \"low\" | \"medium\" | \"high\";\n /** Default 800. A diagnosis is a paragraph. */\n maxTokens?: number;\n /**\n * Route a policy refusal to Anthropic's default fallback model inside the\n * same request, so a diagnosis still comes back. On by default; turn off if\n * your account or gateway rejects the beta.\n */\n fallbacks?: boolean;\n /** Anything the model should know about this app: \"A Next.js app on Vercel with a Neon database.\" */\n context?: string;\n}\n\nconst SYSTEM = `You help an engineer understand why a scheduled job misbehaved. You are given the alert, the job's definition, the run that triggered it and a few earlier runs.\n\nReply with two to four sentences of plain prose: the most likely cause, and the first concrete thing to check or change. Be specific to the evidence given; if the evidence is thin, say what is missing rather than guessing. No headings, no lists, no preamble, no restating the error verbatim.`;\n\nfunction describe(ctx: TriageContext): string {\n const { alert, recentRuns } = ctx;\n const run = alert.run;\n const lines: string[] = [];\n lines.push(`Alert: ${alert.type}. ${alert.title}`);\n lines.push(alert.message);\n lines.push(\"\");\n lines.push(`Job definition: ${JSON.stringify(alert.definition)}`);\n if (run) {\n lines.push(\"\");\n lines.push(`Triggering run: status ${run.status}, started ${new Date(run.startedAt).toISOString()}, duration ${run.durationMs === null ? \"unknown\" : formatDuration(run.durationMs)}, trigger ${run.trigger}`);\n if (Object.keys(run.metrics).length) lines.push(`Metrics: ${JSON.stringify(run.metrics)}`);\n if (run.error) lines.push(`Error:\\n${run.error.slice(0, 3000)}`);\n if (run.output) lines.push(`Output (tail):\\n${run.output.slice(-3000)}`);\n }\n const earlier = recentRuns.filter((r) => r.id !== run?.id).slice(0, 5);\n if (earlier.length) {\n lines.push(\"\");\n lines.push(\"Earlier runs, newest first:\");\n for (const r of earlier) {\n lines.push(`- ${r.status}, ${new Date(r.startedAt).toISOString()}, ${r.durationMs === null ? \"unknown\" : formatDuration(r.durationMs)}${r.error ? `, error: ${r.error.split(\"\\n\")[0]!.slice(0, 160)}` : \"\"}${Object.keys(r.metrics).length ? `, metrics ${JSON.stringify(r.metrics)}` : \"\"}`);\n }\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * A triage function backed by Claude. Pass it as `triage` to cronwatch():\n *\n * cronwatch({ triage: anthropic({ context: \"A Next.js app on Vercel.\" }) })\n *\n * It runs only when an alert is sent (never per run), so cost is bounded by\n * how often things go wrong, and it never blocks an alert: the client gives\n * it 25 seconds and moves on without a diagnosis if it takes longer.\n */\nexport function anthropic(options: AnthropicTriageOptions = {}): TriageFn {\n const client = options.client ?? new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\n const model = options.model ?? \"claude-opus-5\";\n const useFallbacks = options.fallbacks ?? true;\n\n return async (ctx) => {\n const user = (options.context ? `About this app: ${options.context}\\n\\n` : \"\") + describe(ctx);\n const response = await client.beta.messages.create({\n model,\n max_tokens: options.maxTokens ?? 800,\n system: SYSTEM,\n output_config: { effort: options.effort ?? \"medium\" },\n messages: [{ role: \"user\", content: user }],\n ...(useFallbacks ? { betas: [\"server-side-fallback-2026-07-01\"], fallbacks: \"default\" } : {}),\n });\n if (response.stop_reason === \"refusal\") return null;\n const text = response.content\n .filter((block): block is Anthropic.Beta.BetaTextBlock => block.type === \"text\")\n .map((block) => block.text)\n .join(\"\\n\")\n .trim();\n return text || null;\n };\n}\n"]}
|
package/dist/discord.cjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/alerts/discord.ts
|
|
4
|
+
var COLOR = {
|
|
5
|
+
missed: 12024095,
|
|
6
|
+
failed: 12986408,
|
|
7
|
+
stuck: 12986408,
|
|
8
|
+
slow: 12024095,
|
|
9
|
+
over_budget: 12024095,
|
|
10
|
+
recovered: 2067020
|
|
11
|
+
};
|
|
12
|
+
function discord(options) {
|
|
13
|
+
if (!options.webhookUrl) throw new Error("discord() needs a webhookUrl");
|
|
14
|
+
return {
|
|
15
|
+
name: "discord",
|
|
16
|
+
async send(alert) {
|
|
17
|
+
const url = options.link?.(alert);
|
|
18
|
+
const response = await fetch(options.webhookUrl, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
content: alert.title,
|
|
23
|
+
embeds: [
|
|
24
|
+
{
|
|
25
|
+
title: alert.title,
|
|
26
|
+
...url ? { url } : {},
|
|
27
|
+
description: "```\n" + alert.message.slice(0, 3800) + "\n```" + (alert.triage ? `
|
|
28
|
+
**Triage:** ${alert.triage.slice(0, 1e3)}` : ""),
|
|
29
|
+
color: COLOR[alert.type],
|
|
30
|
+
timestamp: new Date(alert.at).toISOString()
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
exports.discord = discord;
|
|
41
|
+
//# sourceMappingURL=discord.cjs.map
|
|
42
|
+
//# sourceMappingURL=discord.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/discord.ts"],"names":[],"mappings":";;;AAQA,IAAM,KAAA,GAAuC;AAAA,EAC3C,MAAA,EAAQ,QAAA;AAAA,EACR,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,QAAA;AAAA,EACP,IAAA,EAAM,QAAA;AAAA,EACN,WAAA,EAAa,QAAA;AAAA,EACb,SAAA,EAAW;AACb,CAAA;AAGO,SAAS,QAAQ,OAAA,EAAuC;AAC7D,EAAA,IAAI,CAAC,OAAA,CAAQ,UAAA,EAAY,MAAM,IAAI,MAAM,8BAA8B,CAAA;AACvE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,IAAA,GAAO,KAAK,CAAA;AAChC,MAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,OAAA,CAAQ,UAAA,EAAY;AAAA,QAC/C,MAAA,EAAQ,MAAA;AAAA,QACR,OAAA,EAAS,EAAE,cAAA,EAAgB,kBAAA,EAAmB;AAAA,QAC9C,IAAA,EAAM,KAAK,SAAA,CAAU;AAAA,UACnB,SAAS,KAAA,CAAM,KAAA;AAAA,UACf,MAAA,EAAQ;AAAA,YACN;AAAA,cACE,OAAO,KAAA,CAAM,KAAA;AAAA,cACb,GAAI,GAAA,GAAM,EAAE,GAAA,KAAQ,EAAC;AAAA,cACrB,WAAA,EAAa,OAAA,GAAU,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,IAAI,CAAA,GAAI,OAAA,IAAW,KAAA,CAAM,MAAA,GAAS;AAAA,YAAA,EAAiB,MAAM,MAAA,CAAO,KAAA,CAAM,CAAA,EAAG,GAAI,CAAC,CAAA,CAAA,GAAK,EAAA,CAAA;AAAA,cACjI,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,IAAI,CAAA;AAAA,cACvB,WAAW,IAAI,IAAA,CAAK,KAAA,CAAM,EAAE,EAAE,WAAA;AAAY;AAC5C;AACF,SACD;AAAA,OACF,CAAA;AACD,MAAA,IAAI,CAAC,QAAA,CAAS,EAAA,QAAU,IAAI,KAAA,CAAM,4BAA4B,QAAA,CAAS,MAAM,CAAA,EAAA,EAAA,CAAM,MAAM,SAAS,IAAA,EAAK,EAAG,MAAM,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,CAAA;AAAA,IAC3H;AAAA,GACF;AACF","file":"discord.cjs","sourcesContent":["import type { Alert, AlertChannel } from \"../types.js\";\n\nexport interface DiscordOptions {\n /** A channel webhook URL from Server Settings, Integrations, Webhooks. */\n webhookUrl: string;\n link?: (alert: Alert) => string;\n}\n\nconst COLOR: Record<Alert[\"type\"], number> = {\n missed: 0xb7791f,\n failed: 0xc62828,\n stuck: 0xc62828,\n slow: 0xb7791f,\n over_budget: 0xb7791f,\n recovered: 0x1f8a4c,\n};\n\n/** Sends alerts to a Discord channel through a webhook. */\nexport function discord(options: DiscordOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"discord() needs a webhookUrl\");\n return {\n name: \"discord\",\n async send(alert) {\n const url = options.link?.(alert);\n const response = await fetch(options.webhookUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n content: alert.title,\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: \"```\\n\" + alert.message.slice(0, 3800) + \"\\n```\" + (alert.triage ? `\\n**Triage:** ${alert.triage.slice(0, 1000)}` : \"\"),\n color: COLOR[alert.type],\n timestamp: new Date(alert.at).toISOString(),\n },\n ],\n }),\n });\n if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { A as Alert, a as AlertChannel } from './types-BQ1P8z55.cjs';
|
|
2
|
+
|
|
3
|
+
interface DiscordOptions {
|
|
4
|
+
/** A channel webhook URL from Server Settings, Integrations, Webhooks. */
|
|
5
|
+
webhookUrl: string;
|
|
6
|
+
link?: (alert: Alert) => string;
|
|
7
|
+
}
|
|
8
|
+
/** Sends alerts to a Discord channel through a webhook. */
|
|
9
|
+
declare function discord(options: DiscordOptions): AlertChannel;
|
|
10
|
+
|
|
11
|
+
export { type DiscordOptions, discord };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { A as Alert, a as AlertChannel } from './types-BQ1P8z55.js';
|
|
2
|
+
|
|
3
|
+
interface DiscordOptions {
|
|
4
|
+
/** A channel webhook URL from Server Settings, Integrations, Webhooks. */
|
|
5
|
+
webhookUrl: string;
|
|
6
|
+
link?: (alert: Alert) => string;
|
|
7
|
+
}
|
|
8
|
+
/** Sends alerts to a Discord channel through a webhook. */
|
|
9
|
+
declare function discord(options: DiscordOptions): AlertChannel;
|
|
10
|
+
|
|
11
|
+
export { type DiscordOptions, discord };
|
package/dist/discord.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/alerts/discord.ts
|
|
2
|
+
var COLOR = {
|
|
3
|
+
missed: 12024095,
|
|
4
|
+
failed: 12986408,
|
|
5
|
+
stuck: 12986408,
|
|
6
|
+
slow: 12024095,
|
|
7
|
+
over_budget: 12024095,
|
|
8
|
+
recovered: 2067020
|
|
9
|
+
};
|
|
10
|
+
function discord(options) {
|
|
11
|
+
if (!options.webhookUrl) throw new Error("discord() needs a webhookUrl");
|
|
12
|
+
return {
|
|
13
|
+
name: "discord",
|
|
14
|
+
async send(alert) {
|
|
15
|
+
const url = options.link?.(alert);
|
|
16
|
+
const response = await fetch(options.webhookUrl, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/json" },
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
content: alert.title,
|
|
21
|
+
embeds: [
|
|
22
|
+
{
|
|
23
|
+
title: alert.title,
|
|
24
|
+
...url ? { url } : {},
|
|
25
|
+
description: "```\n" + alert.message.slice(0, 3800) + "\n```" + (alert.triage ? `
|
|
26
|
+
**Triage:** ${alert.triage.slice(0, 1e3)}` : ""),
|
|
27
|
+
color: COLOR[alert.type],
|
|
28
|
+
timestamp: new Date(alert.at).toISOString()
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
})
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { discord };
|
|
39
|
+
//# sourceMappingURL=discord.js.map
|
|
40
|
+
//# sourceMappingURL=discord.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/discord.ts"],"names":[],"mappings":";AAQA,IAAM,KAAA,GAAuC;AAAA,EAC3C,MAAA,EAAQ,QAAA;AAAA,EACR,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,QAAA;AAAA,EACP,IAAA,EAAM,QAAA;AAAA,EACN,WAAA,EAAa,QAAA;AAAA,EACb,SAAA,EAAW;AACb,CAAA;AAGO,SAAS,QAAQ,OAAA,EAAuC;AAC7D,EAAA,IAAI,CAAC,OAAA,CAAQ,UAAA,EAAY,MAAM,IAAI,MAAM,8BAA8B,CAAA;AACvE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,IAAA,GAAO,KAAK,CAAA;AAChC,MAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,OAAA,CAAQ,UAAA,EAAY;AAAA,QAC/C,MAAA,EAAQ,MAAA;AAAA,QACR,OAAA,EAAS,EAAE,cAAA,EAAgB,kBAAA,EAAmB;AAAA,QAC9C,IAAA,EAAM,KAAK,SAAA,CAAU;AAAA,UACnB,SAAS,KAAA,CAAM,KAAA;AAAA,UACf,MAAA,EAAQ;AAAA,YACN;AAAA,cACE,OAAO,KAAA,CAAM,KAAA;AAAA,cACb,GAAI,GAAA,GAAM,EAAE,GAAA,KAAQ,EAAC;AAAA,cACrB,WAAA,EAAa,OAAA,GAAU,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,IAAI,CAAA,GAAI,OAAA,IAAW,KAAA,CAAM,MAAA,GAAS;AAAA,YAAA,EAAiB,MAAM,MAAA,CAAO,KAAA,CAAM,CAAA,EAAG,GAAI,CAAC,CAAA,CAAA,GAAK,EAAA,CAAA;AAAA,cACjI,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,IAAI,CAAA;AAAA,cACvB,WAAW,IAAI,IAAA,CAAK,KAAA,CAAM,EAAE,EAAE,WAAA;AAAY;AAC5C;AACF,SACD;AAAA,OACF,CAAA;AACD,MAAA,IAAI,CAAC,QAAA,CAAS,EAAA,QAAU,IAAI,KAAA,CAAM,4BAA4B,QAAA,CAAS,MAAM,CAAA,EAAA,EAAA,CAAM,MAAM,SAAS,IAAA,EAAK,EAAG,MAAM,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,CAAA;AAAA,IAC3H;AAAA,GACF;AACF","file":"discord.js","sourcesContent":["import type { Alert, AlertChannel } from \"../types.js\";\n\nexport interface DiscordOptions {\n /** A channel webhook URL from Server Settings, Integrations, Webhooks. */\n webhookUrl: string;\n link?: (alert: Alert) => string;\n}\n\nconst COLOR: Record<Alert[\"type\"], number> = {\n missed: 0xb7791f,\n failed: 0xc62828,\n stuck: 0xc62828,\n slow: 0xb7791f,\n over_budget: 0xb7791f,\n recovered: 0x1f8a4c,\n};\n\n/** Sends alerts to a Discord channel through a webhook. */\nexport function discord(options: DiscordOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"discord() needs a webhookUrl\");\n return {\n name: \"discord\",\n async send(alert) {\n const url = options.link?.(alert);\n const response = await fetch(options.webhookUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n content: alert.title,\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: \"```\\n\" + alert.message.slice(0, 3800) + \"\\n```\" + (alert.triage ? `\\n**Triage:** ${alert.triage.slice(0, 1000)}` : \"\"),\n color: COLOR[alert.type],\n timestamp: new Date(alert.at).toISOString(),\n },\n ],\n }),\n });\n if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n"]}
|