@cronwatch/sdk 0.3.1 → 0.5.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 +8 -2
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.cjs +108 -0
- package/dist/bugsnag.cjs.map +1 -0
- package/dist/bugsnag.d.cts +26 -0
- package/dist/bugsnag.d.ts +26 -0
- package/dist/bugsnag.js +106 -0
- package/dist/bugsnag.js.map +1 -0
- package/dist/client-B7bqnkI5.d.cts +450 -0
- package/dist/client-pmqH3apS.d.ts +450 -0
- package/dist/d1.cjs +214 -0
- package/dist/d1.cjs.map +1 -0
- package/dist/d1.d.cts +42 -0
- package/dist/d1.d.ts +42 -0
- package/dist/d1.js +212 -0
- package/dist/d1.js.map +1 -0
- package/dist/datadog.cjs +95 -0
- package/dist/datadog.cjs.map +1 -0
- package/dist/datadog.d.cts +24 -0
- package/dist/datadog.d.ts +24 -0
- package/dist/datadog.js +93 -0
- package/dist/datadog.js.map +1 -0
- package/dist/discord.cjs +2 -0
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +1 -1
- package/dist/discord.d.ts +1 -1
- package/dist/discord.js +2 -0
- package/dist/discord.js.map +1 -1
- package/dist/email-BJ-z_epD.d.cts +21 -0
- package/dist/email-_H0faCh3.d.ts +21 -0
- package/dist/honeybadger.cjs +101 -0
- package/dist/honeybadger.cjs.map +1 -0
- package/dist/honeybadger.d.cts +23 -0
- package/dist/honeybadger.d.ts +23 -0
- package/dist/honeybadger.js +99 -0
- package/dist/honeybadger.js.map +1 -0
- package/dist/index.cjs +1238 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -260
- package/dist/index.d.ts +5 -260
- package/dist/index.js +1238 -179
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +115 -0
- package/dist/mailgun.cjs.map +1 -0
- package/dist/mailgun.d.cts +21 -0
- package/dist/mailgun.d.ts +21 -0
- package/dist/mailgun.js +113 -0
- package/dist/mailgun.js.map +1 -0
- package/dist/newrelic.cjs +88 -0
- package/dist/newrelic.cjs.map +1 -0
- package/dist/newrelic.d.cts +25 -0
- package/dist/newrelic.d.ts +25 -0
- package/dist/newrelic.js +86 -0
- package/dist/newrelic.js.map +1 -0
- package/dist/node.cjs +191 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +97 -0
- package/dist/node.d.ts +97 -0
- package/dist/node.js +186 -0
- package/dist/node.js.map +1 -0
- package/dist/pg-cron.cjs +286 -0
- package/dist/pg-cron.cjs.map +1 -0
- package/dist/pg-cron.d.cts +93 -0
- package/dist/pg-cron.d.ts +93 -0
- package/dist/pg-cron.js +280 -0
- package/dist/pg-cron.js.map +1 -0
- package/dist/postgres.cjs +20 -0
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.js +20 -0
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs +106 -0
- package/dist/postmark.cjs.map +1 -0
- package/dist/postmark.d.cts +18 -0
- package/dist/postmark.d.ts +18 -0
- package/dist/postmark.js +104 -0
- package/dist/postmark.js.map +1 -0
- package/dist/resend.cjs +114 -0
- package/dist/resend.cjs.map +1 -0
- package/dist/resend.d.cts +16 -0
- package/dist/resend.d.ts +16 -0
- package/dist/resend.js +112 -0
- package/dist/resend.js.map +1 -0
- package/dist/rollbar.cjs +108 -0
- package/dist/rollbar.cjs.map +1 -0
- package/dist/rollbar.d.cts +20 -0
- package/dist/rollbar.d.ts +20 -0
- package/dist/rollbar.js +106 -0
- package/dist/rollbar.js.map +1 -0
- package/dist/sendgrid.cjs +114 -0
- package/dist/sendgrid.cjs.map +1 -0
- package/dist/sendgrid.d.cts +19 -0
- package/dist/sendgrid.d.ts +19 -0
- package/dist/sendgrid.js +112 -0
- package/dist/sendgrid.js.map +1 -0
- package/dist/sentry.cjs +130 -0
- package/dist/sentry.cjs.map +1 -0
- package/dist/sentry.d.cts +28 -0
- package/dist/sentry.d.ts +28 -0
- package/dist/sentry.js +127 -0
- package/dist/sentry.js.map +1 -0
- package/dist/ses.cjs +170 -0
- package/dist/ses.cjs.map +1 -0
- package/dist/ses.d.cts +25 -0
- package/dist/ses.d.ts +25 -0
- package/dist/ses.js +168 -0
- package/dist/ses.js.map +1 -0
- package/dist/slack.cjs +2 -0
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +2 -0
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.cjs +18 -0
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js +18 -0
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +168 -0
- package/dist/twilio.cjs.map +1 -0
- package/dist/twilio.d.cts +52 -0
- package/dist/twilio.d.ts +52 -0
- package/dist/twilio.js +162 -0
- package/dist/twilio.js.map +1 -0
- package/dist/{types-DUt2apl0.d.cts → types-Ddq1MUPL.d.cts} +29 -2
- package/dist/{types-DUt2apl0.d.ts → types-Ddq1MUPL.d.ts} +29 -2
- package/dist/webhook.cjs +11 -5
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +6 -3
- package/dist/webhook.d.ts +6 -3
- package/dist/webhook.js +11 -6
- package/dist/webhook.js.map +1 -1
- package/package.json +208 -2
package/dist/datadog.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/alerts/shared.ts
|
|
2
|
+
var TIMEOUT_MS = 1e4;
|
|
3
|
+
function origin(url) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).origin;
|
|
6
|
+
} catch {
|
|
7
|
+
return "(invalid URL)";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
var ERROR_BODY_MAX = 200;
|
|
11
|
+
async function post(provider, url, init, secrets = []) {
|
|
12
|
+
const response = await fetch(url, { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
let text = "";
|
|
15
|
+
try {
|
|
16
|
+
text = await response.text();
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
20
|
+
}
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
function errorBody(text, secrets = []) {
|
|
24
|
+
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
25
|
+
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
|
26
|
+
let head = cut(text, ERROR_BODY_MAX + longest);
|
|
27
|
+
for (const secret of kept) head = head.split(secret).join("[redacted]");
|
|
28
|
+
return cut(head, ERROR_BODY_MAX);
|
|
29
|
+
}
|
|
30
|
+
function trimmed(value) {
|
|
31
|
+
return typeof value === "string" ? value.trim() : "";
|
|
32
|
+
}
|
|
33
|
+
function hex(bytes) {
|
|
34
|
+
return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
}
|
|
36
|
+
async function sha256Hex(text) {
|
|
37
|
+
return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
38
|
+
}
|
|
39
|
+
function cut(text, max) {
|
|
40
|
+
if (text.length <= max) return text;
|
|
41
|
+
let end = max;
|
|
42
|
+
const code = text.charCodeAt(end - 1);
|
|
43
|
+
if (code >= 55296 && code <= 56319) end -= 1;
|
|
44
|
+
return text.slice(0, end);
|
|
45
|
+
}
|
|
46
|
+
function plainText(alert, link) {
|
|
47
|
+
return [alert.title, "", alert.message, ...alert.triage ? ["", `Triage: ${alert.triage}`] : [], ...link ? ["", `Open: ${link}`] : []].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/alerts/datadog.ts
|
|
51
|
+
var ALERT_TYPE = {
|
|
52
|
+
missed: "error",
|
|
53
|
+
failed: "error",
|
|
54
|
+
stuck: "error",
|
|
55
|
+
slow: "warning",
|
|
56
|
+
over_budget: "warning",
|
|
57
|
+
recovered: "success"
|
|
58
|
+
};
|
|
59
|
+
function datadog(options) {
|
|
60
|
+
const apiKey = trimmed(options.apiKey);
|
|
61
|
+
if (!apiKey) throw new Error("datadog() needs an apiKey");
|
|
62
|
+
const site = (options.site ?? "datadoghq.com").replace(/^https?:\/\//, "").replace(/^(api|app)\./, "").replace(/\/+$/, "");
|
|
63
|
+
if (!/^[a-z0-9.-]+$/i.test(site)) throw new Error("datadog() needs a site like datadoghq.com");
|
|
64
|
+
const url = `https://api.${site}/api/v1/events`;
|
|
65
|
+
return {
|
|
66
|
+
name: "datadog",
|
|
67
|
+
async send(alert) {
|
|
68
|
+
const link = options.link?.(alert);
|
|
69
|
+
const event = {
|
|
70
|
+
title: cut(alert.title, 500),
|
|
71
|
+
text: cut(plainText(alert, link), 4e3),
|
|
72
|
+
alert_type: ALERT_TYPE[alert.type],
|
|
73
|
+
aggregation_key: await aggregationKey(alert),
|
|
74
|
+
date_happened: Math.floor(alert.at / 1e3),
|
|
75
|
+
priority: "normal",
|
|
76
|
+
tags: ["cronwatch", `job:${alert.job}`, `alert:${alert.type}`, ...options.tags ?? []],
|
|
77
|
+
...options.host ? { host: options.host } : {}
|
|
78
|
+
};
|
|
79
|
+
await post("Datadog", url, {
|
|
80
|
+
headers: { "content-type": "application/json", accept: "application/json", "dd-api-key": apiKey },
|
|
81
|
+
body: JSON.stringify(event)
|
|
82
|
+
}, [apiKey]);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function aggregationKey(alert) {
|
|
87
|
+
const key = `cronwatch:${alert.job}:${alert.type}`;
|
|
88
|
+
return key.length <= 100 ? key : `cronwatch:${(await sha256Hex(key)).slice(0, 40)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { datadog };
|
|
92
|
+
//# sourceMappingURL=datadog.js.map
|
|
93
|
+
//# sourceMappingURL=datadog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/datadog.ts"],"names":[],"mappings":"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","file":"datadog.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(url, { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * Datadog Events API v1.\n * API reference: https://docs.datadoghq.com/api/latest/events/ (Post an event)\n * Field limits: https://datadoghq.dev/datadog-api-client-typescript/classes/v1.EventCreateRequest.html\n * POST https://api.<site>/api/v1/events with DD-API-KEY. Answers 202.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, plainText, post, sha256Hex, trimmed } from \"./shared.js\";\n\nexport interface DatadogOptions {\n /** An API key (not an application key). */\n apiKey: string;\n /** Your Datadog site: \"datadoghq.com\" (the default), \"datadoghq.eu\", \"us3.datadoghq.com\", \"us5.datadoghq.com\", \"ap1.datadoghq.com\", \"ddog-gov.com\". */\n site?: string;\n /** Extra tags, \"env:prod\" say. Every event also has cronwatch, job:<name> and alert:<type>. */\n tags?: string[];\n /** Associates the event with a host and its tags. */\n host?: string;\n link?: (alert: Alert) => string;\n}\n\nconst ALERT_TYPE: Record<Alert[\"type\"], \"error\" | \"warning\" | \"success\"> = {\n missed: \"error\",\n failed: \"error\",\n stuck: \"error\",\n slow: \"warning\",\n over_budget: \"warning\",\n recovered: \"success\",\n};\n\n/** Posts alerts to the Datadog event stream, aggregated per job and alert type. */\nexport function datadog(options: DatadogOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"datadog() needs an apiKey\");\n const site = (options.site ?? \"datadoghq.com\").replace(/^https?:\\/\\//, \"\").replace(/^(api|app)\\./, \"\").replace(/\\/+$/, \"\");\n if (!/^[a-z0-9.-]+$/i.test(site)) throw new Error(\"datadog() needs a site like datadoghq.com\");\n const url = `https://api.${site}/api/v1/events`;\n return {\n name: \"datadog\",\n async send(alert) {\n const link = options.link?.(alert);\n const event = {\n title: cut(alert.title, 500),\n text: cut(plainText(alert, link), 4000),\n alert_type: ALERT_TYPE[alert.type],\n aggregation_key: await aggregationKey(alert),\n date_happened: Math.floor(alert.at / 1000),\n priority: \"normal\",\n tags: [\"cronwatch\", `job:${alert.job}`, `alert:${alert.type}`, ...(options.tags ?? [])],\n ...(options.host ? { host: options.host } : {}),\n };\n await post(\"Datadog\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"dd-api-key\": apiKey },\n body: JSON.stringify(event),\n }, [apiKey]);\n },\n };\n}\n\n/** \"cronwatch:<job>:<type>\", or a hash of it when that passes Datadog's 100 characters. */\nasync function aggregationKey(alert: Alert): Promise<string> {\n const key = `cronwatch:${alert.job}:${alert.type}`;\n return key.length <= 100 ? key : `cronwatch:${(await sha256Hex(key)).slice(0, 40)}`;\n}\n"]}
|
package/dist/discord.cjs
CHANGED
|
@@ -19,6 +19,8 @@ function discord(options) {
|
|
|
19
19
|
const response = await fetch(options.webhookUrl, {
|
|
20
20
|
method: "POST",
|
|
21
21
|
headers: { "content-type": "application/json" },
|
|
22
|
+
// Refused, not followed: a webhook URL is its own credential.
|
|
23
|
+
redirect: "error",
|
|
22
24
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
23
25
|
body: JSON.stringify({
|
|
24
26
|
content: alert.title,
|
package/dist/discord.cjs.map
CHANGED
|
@@ -1 +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;AAEA,IAAM,UAAA,GAAa,GAAA;AAGZ,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,
|
|
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;AAEA,IAAM,UAAA,GAAa,GAAA;AAGZ,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;AAAA,QAE9C,QAAA,EAAU,OAAA;AAAA,QACV,MAAA,EAAQ,WAAA,CAAY,OAAA,CAAQ,UAAU,CAAA;AAAA,QACtC,IAAA,EAAM,KAAK,SAAA,CAAU;AAAA,UACnB,SAAS,KAAA,CAAM,KAAA;AAAA;AAAA,UAEf,gBAAA,EAAkB,EAAE,KAAA,EAAO,EAAC,EAAE;AAAA,UAC9B,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,aAAA,CAAc,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,CAAA,EAAG,IAAI,CAAC,CAAA,GAAI,OAAA,IAAW,KAAA,CAAM,MAAA,GAAS;AAAA,YAAA,EAAiB,cAAA,CAAe,MAAM,MAAA,CAAO,KAAA,CAAM,GAAG,GAAI,CAAC,CAAC,CAAA,CAAA,GAAK,EAAA,CAAA;AAAA,cAChK,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;AAGO,SAAS,cAAc,IAAA,EAAsB;AAClD,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,MAAA,EAAQ,iBAAO,CAAA;AACrC;AAGO,SAAS,eAAe,IAAA,EAAsB;AACnD,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,mBAAA,EAAqB,MAAM,CAAA;AACjD","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\nconst TIMEOUT_MS = 10_000;\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 // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n content: alert.title,\n // Job output can hold anything, \"@everyone\" included; ping no one.\n allowed_mentions: { parse: [] },\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: \"```\\n\" + codeBlockSafe(alert.message.slice(0, 3800)) + \"\\n```\" + (alert.triage ? `\\n**Triage:** ${escapeMarkdown(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\n/** Breaks up ``` so text inside a code block cannot close it. */\nexport function codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n\n/** Escapes the characters Discord reads as markdown, links included. */\nexport function escapeMarkdown(text: string): string {\n return text.replace(/[\\\\`*_~|[\\]()<>]/g, \"\\\\$&\");\n}\n"]}
|
package/dist/discord.d.cts
CHANGED
package/dist/discord.d.ts
CHANGED
package/dist/discord.js
CHANGED
|
@@ -17,6 +17,8 @@ function discord(options) {
|
|
|
17
17
|
const response = await fetch(options.webhookUrl, {
|
|
18
18
|
method: "POST",
|
|
19
19
|
headers: { "content-type": "application/json" },
|
|
20
|
+
// Refused, not followed: a webhook URL is its own credential.
|
|
21
|
+
redirect: "error",
|
|
20
22
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
21
23
|
body: JSON.stringify({
|
|
22
24
|
content: alert.title,
|
package/dist/discord.js.map
CHANGED
|
@@ -1 +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;AAEA,IAAM,UAAA,GAAa,GAAA;AAGZ,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,
|
|
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;AAEA,IAAM,UAAA,GAAa,GAAA;AAGZ,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;AAAA,QAE9C,QAAA,EAAU,OAAA;AAAA,QACV,MAAA,EAAQ,WAAA,CAAY,OAAA,CAAQ,UAAU,CAAA;AAAA,QACtC,IAAA,EAAM,KAAK,SAAA,CAAU;AAAA,UACnB,SAAS,KAAA,CAAM,KAAA;AAAA;AAAA,UAEf,gBAAA,EAAkB,EAAE,KAAA,EAAO,EAAC,EAAE;AAAA,UAC9B,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,aAAA,CAAc,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,CAAA,EAAG,IAAI,CAAC,CAAA,GAAI,OAAA,IAAW,KAAA,CAAM,MAAA,GAAS;AAAA,YAAA,EAAiB,cAAA,CAAe,MAAM,MAAA,CAAO,KAAA,CAAM,GAAG,GAAI,CAAC,CAAC,CAAA,CAAA,GAAK,EAAA,CAAA;AAAA,cAChK,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;AAGO,SAAS,cAAc,IAAA,EAAsB;AAClD,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,MAAA,EAAQ,iBAAO,CAAA;AACrC;AAGO,SAAS,eAAe,IAAA,EAAsB;AACnD,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,mBAAA,EAAqB,MAAM,CAAA;AACjD","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\nconst TIMEOUT_MS = 10_000;\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 // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n content: alert.title,\n // Job output can hold anything, \"@everyone\" included; ping no one.\n allowed_mentions: { parse: [] },\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: \"```\\n\" + codeBlockSafe(alert.message.slice(0, 3800)) + \"\\n```\" + (alert.triage ? `\\n**Triage:** ${escapeMarkdown(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\n/** Breaks up ``` so text inside a code block cannot close it. */\nexport function codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n\n/** Escapes the characters Discord reads as markdown, links included. */\nexport function escapeMarkdown(text: string): string {\n return text.replace(/[\\\\`*_~|[\\]()<>]/g, \"\\\\$&\");\n}\n"]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { A as Alert } from './types-Ddq1MUPL.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every email channel sends: one subject, a plain text body and a small
|
|
5
|
+
* HTML body, so an alert reads the same whichever provider carries it. Not an
|
|
6
|
+
* entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The options every email channel takes. */
|
|
10
|
+
interface EmailOptions {
|
|
11
|
+
/** The sender, "alerts@example.com" or "CronWatch <alerts@example.com>". The provider must allow it. */
|
|
12
|
+
from: string;
|
|
13
|
+
/** One address or several. */
|
|
14
|
+
to: string | string[];
|
|
15
|
+
/** Put in front of the title in the subject, "[prod]" say. */
|
|
16
|
+
subjectPrefix?: string;
|
|
17
|
+
/** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */
|
|
18
|
+
link?: (alert: Alert) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { EmailOptions as E };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { A as Alert } from './types-Ddq1MUPL.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every email channel sends: one subject, a plain text body and a small
|
|
5
|
+
* HTML body, so an alert reads the same whichever provider carries it. Not an
|
|
6
|
+
* entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The options every email channel takes. */
|
|
10
|
+
interface EmailOptions {
|
|
11
|
+
/** The sender, "alerts@example.com" or "CronWatch <alerts@example.com>". The provider must allow it. */
|
|
12
|
+
from: string;
|
|
13
|
+
/** One address or several. */
|
|
14
|
+
to: string | string[];
|
|
15
|
+
/** Put in front of the title in the subject, "[prod]" say. */
|
|
16
|
+
subjectPrefix?: string;
|
|
17
|
+
/** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */
|
|
18
|
+
link?: (alert: Alert) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { EmailOptions as E };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/alerts/shared.ts
|
|
4
|
+
var TIMEOUT_MS = 1e4;
|
|
5
|
+
function origin(url) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(url).origin;
|
|
8
|
+
} catch {
|
|
9
|
+
return "(invalid URL)";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
var ERROR_BODY_MAX = 200;
|
|
13
|
+
async function post(provider, url, init, secrets = []) {
|
|
14
|
+
const response = await fetch(url, { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
let text = "";
|
|
17
|
+
try {
|
|
18
|
+
text = await response.text();
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
22
|
+
}
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
function errorBody(text, secrets = []) {
|
|
26
|
+
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
27
|
+
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
|
28
|
+
let head = cut(text, ERROR_BODY_MAX + longest);
|
|
29
|
+
for (const secret of kept) head = head.split(secret).join("[redacted]");
|
|
30
|
+
return cut(head, ERROR_BODY_MAX);
|
|
31
|
+
}
|
|
32
|
+
function trimmed(value) {
|
|
33
|
+
return typeof value === "string" ? value.trim() : "";
|
|
34
|
+
}
|
|
35
|
+
function cut(text, max) {
|
|
36
|
+
if (text.length <= max) return text;
|
|
37
|
+
let end = max;
|
|
38
|
+
const code = text.charCodeAt(end - 1);
|
|
39
|
+
if (code >= 55296 && code <= 56319) end -= 1;
|
|
40
|
+
return text.slice(0, end);
|
|
41
|
+
}
|
|
42
|
+
function runSummary(alert) {
|
|
43
|
+
const run = alert.run;
|
|
44
|
+
if (!run) return null;
|
|
45
|
+
return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/alerts/honeybadger.ts
|
|
49
|
+
var CLASS = {
|
|
50
|
+
missed: "CronWatch::Missed",
|
|
51
|
+
failed: "CronWatch::Failed",
|
|
52
|
+
stuck: "CronWatch::Stuck",
|
|
53
|
+
slow: "CronWatch::Slow",
|
|
54
|
+
over_budget: "CronWatch::OverBudget",
|
|
55
|
+
recovered: "CronWatch::Recovered"
|
|
56
|
+
};
|
|
57
|
+
function honeybadger(options) {
|
|
58
|
+
const apiKey = trimmed(options.apiKey);
|
|
59
|
+
if (!apiKey) throw new Error("honeybadger() needs an apiKey");
|
|
60
|
+
const url = `${(options.endpoint ?? "https://api.honeybadger.io").replace(/\/+$/, "")}/v1/notices`;
|
|
61
|
+
return {
|
|
62
|
+
name: "honeybadger",
|
|
63
|
+
async send(alert) {
|
|
64
|
+
if (alert.type === "recovered" && !options.recovered) return;
|
|
65
|
+
const link = options.link?.(alert);
|
|
66
|
+
const notice = {
|
|
67
|
+
notifier: { name: "cronwatch", url: "https://cronwatch.dev" },
|
|
68
|
+
error: {
|
|
69
|
+
class: CLASS[alert.type],
|
|
70
|
+
message: cut(`${alert.title}
|
|
71
|
+
${alert.message}`, 8e3),
|
|
72
|
+
// No code ran here; one frame naming the job keeps the notice well formed.
|
|
73
|
+
backtrace: [{ number: "0", file: `cronwatch/${alert.job}`, method: alert.type }],
|
|
74
|
+
fingerprint: `cronwatch:${alert.job}:${alert.type}`,
|
|
75
|
+
tags: ["cronwatch", alert.type]
|
|
76
|
+
},
|
|
77
|
+
request: {
|
|
78
|
+
component: "cronwatch",
|
|
79
|
+
action: alert.job,
|
|
80
|
+
...link ? { url: link } : {},
|
|
81
|
+
context: {
|
|
82
|
+
job: alert.job,
|
|
83
|
+
type: alert.type,
|
|
84
|
+
...alert.triage ? { triage: alert.triage } : {},
|
|
85
|
+
details: alert.details,
|
|
86
|
+
run: runSummary(alert)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
server: { environment_name: options.environment ?? "production" }
|
|
90
|
+
};
|
|
91
|
+
await post("Honeybadger", url, {
|
|
92
|
+
headers: { "content-type": "application/json", accept: "application/json", "x-api-key": apiKey },
|
|
93
|
+
body: JSON.stringify(notice)
|
|
94
|
+
}, [apiKey]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
exports.honeybadger = honeybadger;
|
|
100
|
+
//# sourceMappingURL=honeybadger.cjs.map
|
|
101
|
+
//# sourceMappingURL=honeybadger.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":"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","file":"honeybadger.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(url, { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { A as Alert, a as AlertChannel } from './types-Ddq1MUPL.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Honeybadger, as error notices (not Check-ins, a separate product).
|
|
5
|
+
* API reference: https://docs.honeybadger.io/api/reporting-exceptions/
|
|
6
|
+
* POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface HoneybadgerOptions {
|
|
10
|
+
/** The project API key, from Project Settings. */
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Defaults to "production". */
|
|
13
|
+
environment?: string;
|
|
14
|
+
/** Another API host, "https://eu-api.honeybadger.io" say. Defaults to https://api.honeybadger.io. */
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
/** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */
|
|
17
|
+
recovered?: boolean;
|
|
18
|
+
link?: (alert: Alert) => string;
|
|
19
|
+
}
|
|
20
|
+
/** Reports alerts to Honeybadger as notices, one error per job and alert type. */
|
|
21
|
+
declare function honeybadger(options: HoneybadgerOptions): AlertChannel;
|
|
22
|
+
|
|
23
|
+
export { type HoneybadgerOptions, honeybadger };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { A as Alert, a as AlertChannel } from './types-Ddq1MUPL.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Honeybadger, as error notices (not Check-ins, a separate product).
|
|
5
|
+
* API reference: https://docs.honeybadger.io/api/reporting-exceptions/
|
|
6
|
+
* POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface HoneybadgerOptions {
|
|
10
|
+
/** The project API key, from Project Settings. */
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Defaults to "production". */
|
|
13
|
+
environment?: string;
|
|
14
|
+
/** Another API host, "https://eu-api.honeybadger.io" say. Defaults to https://api.honeybadger.io. */
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
/** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */
|
|
17
|
+
recovered?: boolean;
|
|
18
|
+
link?: (alert: Alert) => string;
|
|
19
|
+
}
|
|
20
|
+
/** Reports alerts to Honeybadger as notices, one error per job and alert type. */
|
|
21
|
+
declare function honeybadger(options: HoneybadgerOptions): AlertChannel;
|
|
22
|
+
|
|
23
|
+
export { type HoneybadgerOptions, honeybadger };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/alerts/shared.ts
|
|
2
|
+
var TIMEOUT_MS = 1e4;
|
|
3
|
+
function origin(url) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).origin;
|
|
6
|
+
} catch {
|
|
7
|
+
return "(invalid URL)";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
var ERROR_BODY_MAX = 200;
|
|
11
|
+
async function post(provider, url, init, secrets = []) {
|
|
12
|
+
const response = await fetch(url, { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
let text = "";
|
|
15
|
+
try {
|
|
16
|
+
text = await response.text();
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
20
|
+
}
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
function errorBody(text, secrets = []) {
|
|
24
|
+
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
25
|
+
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
|
26
|
+
let head = cut(text, ERROR_BODY_MAX + longest);
|
|
27
|
+
for (const secret of kept) head = head.split(secret).join("[redacted]");
|
|
28
|
+
return cut(head, ERROR_BODY_MAX);
|
|
29
|
+
}
|
|
30
|
+
function trimmed(value) {
|
|
31
|
+
return typeof value === "string" ? value.trim() : "";
|
|
32
|
+
}
|
|
33
|
+
function cut(text, max) {
|
|
34
|
+
if (text.length <= max) return text;
|
|
35
|
+
let end = max;
|
|
36
|
+
const code = text.charCodeAt(end - 1);
|
|
37
|
+
if (code >= 55296 && code <= 56319) end -= 1;
|
|
38
|
+
return text.slice(0, end);
|
|
39
|
+
}
|
|
40
|
+
function runSummary(alert) {
|
|
41
|
+
const run = alert.run;
|
|
42
|
+
if (!run) return null;
|
|
43
|
+
return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/alerts/honeybadger.ts
|
|
47
|
+
var CLASS = {
|
|
48
|
+
missed: "CronWatch::Missed",
|
|
49
|
+
failed: "CronWatch::Failed",
|
|
50
|
+
stuck: "CronWatch::Stuck",
|
|
51
|
+
slow: "CronWatch::Slow",
|
|
52
|
+
over_budget: "CronWatch::OverBudget",
|
|
53
|
+
recovered: "CronWatch::Recovered"
|
|
54
|
+
};
|
|
55
|
+
function honeybadger(options) {
|
|
56
|
+
const apiKey = trimmed(options.apiKey);
|
|
57
|
+
if (!apiKey) throw new Error("honeybadger() needs an apiKey");
|
|
58
|
+
const url = `${(options.endpoint ?? "https://api.honeybadger.io").replace(/\/+$/, "")}/v1/notices`;
|
|
59
|
+
return {
|
|
60
|
+
name: "honeybadger",
|
|
61
|
+
async send(alert) {
|
|
62
|
+
if (alert.type === "recovered" && !options.recovered) return;
|
|
63
|
+
const link = options.link?.(alert);
|
|
64
|
+
const notice = {
|
|
65
|
+
notifier: { name: "cronwatch", url: "https://cronwatch.dev" },
|
|
66
|
+
error: {
|
|
67
|
+
class: CLASS[alert.type],
|
|
68
|
+
message: cut(`${alert.title}
|
|
69
|
+
${alert.message}`, 8e3),
|
|
70
|
+
// No code ran here; one frame naming the job keeps the notice well formed.
|
|
71
|
+
backtrace: [{ number: "0", file: `cronwatch/${alert.job}`, method: alert.type }],
|
|
72
|
+
fingerprint: `cronwatch:${alert.job}:${alert.type}`,
|
|
73
|
+
tags: ["cronwatch", alert.type]
|
|
74
|
+
},
|
|
75
|
+
request: {
|
|
76
|
+
component: "cronwatch",
|
|
77
|
+
action: alert.job,
|
|
78
|
+
...link ? { url: link } : {},
|
|
79
|
+
context: {
|
|
80
|
+
job: alert.job,
|
|
81
|
+
type: alert.type,
|
|
82
|
+
...alert.triage ? { triage: alert.triage } : {},
|
|
83
|
+
details: alert.details,
|
|
84
|
+
run: runSummary(alert)
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
server: { environment_name: options.environment ?? "production" }
|
|
88
|
+
};
|
|
89
|
+
await post("Honeybadger", url, {
|
|
90
|
+
headers: { "content-type": "application/json", accept: "application/json", "x-api-key": apiKey },
|
|
91
|
+
body: JSON.stringify(notice)
|
|
92
|
+
}, [apiKey]);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { honeybadger };
|
|
98
|
+
//# sourceMappingURL=honeybadger.js.map
|
|
99
|
+
//# sourceMappingURL=honeybadger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":";AAOO,IAAM,UAAA,GAAa,GAAA;AAYnB,SAAS,OAAO,GAAA,EAAqB;AAC1C,EAAA,IAAI;AACF,IAAA,OAAO,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,MAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,eAAA;AAAA,EACT;AACF;AAGO,IAAM,cAAA,GAAiB,GAAA;AAW9B,eAAsB,KAAK,QAAA,EAAkB,GAAA,EAAa,IAAA,EAAyD,OAAA,GAAkC,EAAC,EAAsB;AAC1K,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,GAAA,EAAK,EAAE,MAAA,EAAQ,MAAA,EAAQ,SAAS,IAAA,CAAK,OAAA,EAAS,MAAM,IAAA,CAAK,IAAA,EAAM,UAAU,OAAA,EAAS,MAAA,EAAQ,YAAY,OAAA,CAAQ,UAAU,GAAG,CAAA;AACxJ,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,IAAI,IAAA,GAAO,EAAA;AACX,IAAA,IAAI;AACF,MAAA,IAAA,GAAO,MAAM,SAAS,IAAA,EAAK;AAAA,IAC7B,CAAA,CAAA,MAAQ;AAAA,IAER;AACA,IAAA,MAAM,IAAI,MAAM,CAAA,EAAG,QAAQ,IAAI,MAAA,CAAO,GAAG,CAAC,CAAA,UAAA,EAAa,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,GAAO,KAAK,SAAA,CAAU,IAAA,EAAM,OAAO,CAAC,CAAA,CAAA,GAAK,EAAE,CAAA,CAAE,CAAA;AAAA,EACxH;AACA,EAAA,OAAO,QAAA;AACT;AAGO,SAAS,SAAA,CAAU,IAAA,EAAc,OAAA,GAAkC,EAAC,EAAW;AACpF,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAmB,OAAO,CAAA,KAAM,QAAA,IAAY,CAAA,CAAE,MAAA,IAAU,CAAC,CAAA;AACtF,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAG,CAAA,KAAM,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,CAAA,CAAE,MAAM,CAAA,EAAG,CAAC,CAAA;AAC9D,EAAA,IAAI,IAAA,GAAO,GAAA,CAAI,IAAA,EAAM,cAAA,GAAiB,OAAO,CAAA;AAC7C,EAAA,KAAA,MAAW,MAAA,IAAU,MAAM,IAAA,GAAO,IAAA,CAAK,MAAM,MAAM,CAAA,CAAE,KAAK,YAAY,CAAA;AACtE,EAAA,OAAO,GAAA,CAAI,MAAM,cAAc,CAAA;AACjC;AAGO,SAAS,QAAQ,KAAA,EAAwB;AAC9C,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,CAAM,MAAK,GAAI,EAAA;AACpD;AAoCO,SAAS,GAAA,CAAI,MAAc,GAAA,EAAqB;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,IAAU,GAAA,EAAK,OAAO,IAAA;AAC/B,EAAA,IAAI,GAAA,GAAM,GAAA;AACV,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,GAAM,CAAC,CAAA;AACpC,EAAA,IAAI,IAAA,IAAQ,KAAA,IAAU,IAAA,IAAQ,KAAA,EAAQ,GAAA,IAAO,CAAA;AAC7C,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAC1B;AAGO,SAAS,WAAW,KAAA,EAA8C;AACvE,EAAA,MAAM,MAAM,KAAA,CAAM,GAAA;AAClB,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,EAAA,OAAO,EAAE,IAAI,GAAA,CAAI,EAAA,EAAI,QAAQ,GAAA,CAAI,MAAA,EAAQ,WAAW,IAAI,IAAA,CAAK,IAAI,SAAS,CAAA,CAAE,aAAY,EAAG,UAAA,EAAY,IAAI,UAAA,EAAY,OAAA,EAAS,IAAI,OAAA,EAAQ;AAC9I;;;AC9FA,IAAM,KAAA,GAAuC;AAAA,EAC3C,MAAA,EAAQ,mBAAA;AAAA,EACR,MAAA,EAAQ,mBAAA;AAAA,EACR,KAAA,EAAO,kBAAA;AAAA,EACP,IAAA,EAAM,iBAAA;AAAA,EACN,WAAA,EAAa,uBAAA;AAAA,EACb,SAAA,EAAW;AACb,CAAA;AAGO,SAAS,YAAY,OAAA,EAA2C;AAErE,EAAA,MAAM,MAAA,GAAS,OAAA,CAAQ,OAAA,CAAQ,MAAM,CAAA;AACrC,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,+BAA+B,CAAA;AAC5D,EAAA,MAAM,GAAA,GAAM,IAAI,OAAA,CAAQ,QAAA,IAAY,8BAA8B,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAC,CAAA,WAAA,CAAA;AACrF,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,aAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,IAAI,KAAA,CAAM,IAAA,KAAS,WAAA,IAAe,CAAC,QAAQ,SAAA,EAAW;AACtD,MAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,IAAA,GAAO,KAAK,CAAA;AACjC,MAAA,MAAM,MAAA,GAAS;AAAA,QACb,QAAA,EAAU,EAAE,IAAA,EAAM,WAAA,EAAa,KAAK,uBAAA,EAAwB;AAAA,QAC5D,KAAA,EAAO;AAAA,UACL,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,IAAI,CAAA;AAAA,UACvB,OAAA,EAAS,GAAA,CAAI,CAAA,EAAG,KAAA,CAAM,KAAK;AAAA,EAAK,KAAA,CAAM,OAAO,CAAA,CAAA,EAAI,GAAI,CAAA;AAAA;AAAA,UAErD,SAAA,EAAW,CAAC,EAAE,MAAA,EAAQ,GAAA,EAAK,IAAA,EAAM,CAAA,UAAA,EAAa,KAAA,CAAM,GAAG,CAAA,CAAA,EAAI,MAAA,EAAQ,KAAA,CAAM,MAAM,CAAA;AAAA,UAC/E,aAAa,CAAA,UAAA,EAAa,KAAA,CAAM,GAAG,CAAA,CAAA,EAAI,MAAM,IAAI,CAAA,CAAA;AAAA,UACjD,IAAA,EAAM,CAAC,WAAA,EAAa,KAAA,CAAM,IAAI;AAAA,SAChC;AAAA,QACA,OAAA,EAAS;AAAA,UACP,SAAA,EAAW,WAAA;AAAA,UACX,QAAQ,KAAA,CAAM,GAAA;AAAA,UACd,GAAI,IAAA,GAAO,EAAE,GAAA,EAAK,IAAA,KAAS,EAAC;AAAA,UAC5B,OAAA,EAAS;AAAA,YACP,KAAK,KAAA,CAAM,GAAA;AAAA,YACX,MAAM,KAAA,CAAM,IAAA;AAAA,YACZ,GAAI,MAAM,MAAA,GAAS,EAAE,QAAQ,KAAA,CAAM,MAAA,KAAW,EAAC;AAAA,YAC/C,SAAS,KAAA,CAAM,OAAA;AAAA,YACf,GAAA,EAAK,WAAW,KAAK;AAAA;AACvB,SACF;AAAA,QACA,MAAA,EAAQ,EAAE,gBAAA,EAAkB,OAAA,CAAQ,eAAe,YAAA;AAAa,OAClE;AACA,MAAA,MAAM,IAAA,CAAK,eAAe,GAAA,EAAK;AAAA,QAC7B,SAAS,EAAE,cAAA,EAAgB,oBAAoB,MAAA,EAAQ,kBAAA,EAAoB,aAAa,MAAA,EAAO;AAAA,QAC/F,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,MAAM;AAAA,OAC7B,EAAG,CAAC,MAAM,CAAC,CAAA;AAAA,IACb;AAAA,GACF;AACF","file":"honeybadger.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(url, { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|