@cronwatch/sdk 0.12.5 → 0.13.1
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/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.cjs +7 -0
- package/dist/bugsnag.cjs.map +1 -1
- package/dist/bugsnag.d.cts +1 -1
- package/dist/bugsnag.d.ts +1 -1
- package/dist/bugsnag.js +7 -0
- package/dist/bugsnag.js.map +1 -1
- package/dist/{client-CFJUFEOg.d.cts → client-DAJH7YiG.d.ts} +28 -7
- package/dist/{client-k5UVIH38.d.ts → client-z1LdR_m2.d.cts} +28 -7
- package/dist/d1.d.cts +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/datadog.cjs +7 -0
- package/dist/datadog.cjs.map +1 -1
- package/dist/datadog.d.cts +1 -1
- package/dist/datadog.d.ts +1 -1
- package/dist/datadog.js +7 -0
- package/dist/datadog.js.map +1 -1
- package/dist/discord.cjs +25 -1
- 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 +25 -1
- package/dist/discord.js.map +1 -1
- package/dist/{email-U0hYRnPa.d.cts → email-CCUzlLe9.d.cts} +1 -1
- package/dist/{email-CPoxD8--.d.ts → email-CFtDqlvZ.d.ts} +1 -1
- package/dist/honeybadger.cjs +7 -0
- package/dist/honeybadger.cjs.map +1 -1
- package/dist/honeybadger.d.cts +1 -1
- package/dist/honeybadger.d.ts +1 -1
- package/dist/honeybadger.js +7 -0
- package/dist/honeybadger.js.map +1 -1
- package/dist/index.cjs +191 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +191 -47
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +7 -0
- package/dist/mailgun.cjs.map +1 -1
- package/dist/mailgun.d.cts +2 -2
- package/dist/mailgun.d.ts +2 -2
- package/dist/mailgun.js +7 -0
- package/dist/mailgun.js.map +1 -1
- package/dist/newrelic.cjs +7 -0
- package/dist/newrelic.cjs.map +1 -1
- package/dist/newrelic.d.cts +1 -1
- package/dist/newrelic.d.ts +1 -1
- package/dist/newrelic.js +7 -0
- package/dist/newrelic.js.map +1 -1
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +2 -2
- package/dist/pg-cron.d.ts +2 -2
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +7 -2
- 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 +7 -2
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs +7 -0
- package/dist/postmark.cjs.map +1 -1
- package/dist/postmark.d.cts +2 -2
- package/dist/postmark.d.ts +2 -2
- package/dist/postmark.js +7 -0
- package/dist/postmark.js.map +1 -1
- package/dist/resend.cjs +7 -0
- package/dist/resend.cjs.map +1 -1
- package/dist/resend.d.cts +2 -2
- package/dist/resend.d.ts +2 -2
- package/dist/resend.js +7 -0
- package/dist/resend.js.map +1 -1
- package/dist/rollbar.cjs +7 -0
- package/dist/rollbar.cjs.map +1 -1
- package/dist/rollbar.d.cts +1 -1
- package/dist/rollbar.d.ts +1 -1
- package/dist/rollbar.js +7 -0
- package/dist/rollbar.js.map +1 -1
- package/dist/sendgrid.cjs +7 -0
- package/dist/sendgrid.cjs.map +1 -1
- package/dist/sendgrid.d.cts +2 -2
- package/dist/sendgrid.d.ts +2 -2
- package/dist/sendgrid.js +7 -0
- package/dist/sendgrid.js.map +1 -1
- package/dist/sentry.cjs +7 -0
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.d.cts +1 -1
- package/dist/sentry.d.ts +1 -1
- package/dist/sentry.js +7 -0
- package/dist/sentry.js.map +1 -1
- package/dist/ses.cjs +7 -0
- package/dist/ses.cjs.map +1 -1
- package/dist/ses.d.cts +2 -2
- package/dist/ses.d.ts +2 -2
- package/dist/ses.js +7 -0
- package/dist/ses.js.map +1 -1
- package/dist/slack.cjs +32 -1
- 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 +32 -1
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/twilio.cjs +7 -0
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.d.cts +1 -1
- package/dist/twilio.d.ts +1 -1
- package/dist/twilio.js +7 -0
- package/dist/twilio.js.map +1 -1
- package/dist/{types-V8SN54o9.d.cts → types-D6t0Z846.d.cts} +8 -0
- package/dist/{types-V8SN54o9.d.ts → types-D6t0Z846.d.ts} +8 -0
- package/dist/webhook.cjs +11 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +3 -2
- package/dist/webhook.d.ts +3 -2
- package/dist/webhook.js +11 -1
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/anthropic.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import { o as TriageFn } from './types-
|
|
2
|
+
import { o as TriageFn } from './types-D6t0Z846.cjs';
|
|
3
3
|
|
|
4
4
|
interface AnthropicTriageOptions {
|
|
5
5
|
/** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */
|
package/dist/anthropic.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import { o as TriageFn } from './types-
|
|
2
|
+
import { o as TriageFn } from './types-D6t0Z846.js';
|
|
3
3
|
|
|
4
4
|
interface AnthropicTriageOptions {
|
|
5
5
|
/** Defaults to what the Anthropic SDK resolves: ANTHROPIC_API_KEY, or an `ant auth login` profile. */
|
package/dist/bugsnag.cjs
CHANGED
|
@@ -42,8 +42,15 @@ async function post(provider, url, init, secrets = []) {
|
|
|
42
42
|
}
|
|
43
43
|
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
44
44
|
}
|
|
45
|
+
await drain(response);
|
|
45
46
|
return response;
|
|
46
47
|
}
|
|
48
|
+
async function drain(response) {
|
|
49
|
+
try {
|
|
50
|
+
await response.body?.cancel();
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
47
54
|
function errorBody(text, secrets = []) {
|
|
48
55
|
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
49
56
|
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
package/dist/bugsnag.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/bugsnag.ts"],"names":[],"mappings":";;;AAsFO,IAAM,aAAA,GAAgB,YAAA;AAEtB,IAAM,YAAA,GAAe,eAAA;AAQrB,SAAS,QAAQ,EAAA,EAA2B;AACjD,EAAA,OAAO,EAAA,IAAM,iBAAiB,EAAA,IAAM,YAAA,GAAe,IAAI,IAAA,CAAK,EAAE,CAAA,CAAE,WAAA,EAAY,GAAI,IAAA;AAClF;;;AC1FO,IAAM,UAAA,GAAa,GAAA;AAKnB,SAAS,SAAS,IAAA,EAA+B;AACtD,EAAA,IAAI,IAAA,KAAS,aAAa,OAAO,MAAA;AACjC,EAAA,IAAI,SAAS,MAAA,IAAU,IAAA,KAAS,aAAA,IAAiB,IAAA,KAAS,eAAe,OAAO,SAAA;AAChF,EAAA,OAAO,OAAA;AACT;AAGO,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;AAOO,SAAS,SAAS,GAAA,EAAqB;AAC5C,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI;AACF,IAAA,QAAA,GAAW,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,QAAA;AAAA,EAC1B,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,yDAAyD,CAAA;AAAA,EAC3E;AACA,EAAA,IAAI,QAAA,KAAa,WAAW,QAAA,KAAa,QAAA,QAAgB,IAAI,KAAA,CAAM,CAAA,+CAAA,EAAkD,QAAQ,CAAA,CAAE,CAAA;AAC/H,EAAA,OAAO,GAAA;AACT;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,QAAA,CAAS,GAAG,CAAA,EAAG,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,IAAA,CAAK,SAAS,IAAA,EAAM,IAAA,CAAK,MAAM,QAAA,EAAU,OAAA,EAAS,QAAQ,WAAA,CAAY,OAAA,CAAQ,UAAU,CAAA,EAAG,CAAA;AAClK,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,EAAA,EAAI,GAAA,CAAI,EAAA,EAAI,MAAA,EAAQ,IAAI,MAAA,EAAQ,SAAA,EAAW,OAAA,CAAQ,GAAA,CAAI,SAAS,CAAA,EAAG,UAAA,EAAY,IAAI,UAAA,EAAY,OAAA,EAAS,IAAI,OAAA,EAAQ;AAC/H;;;AC3GO,SAAS,QAAQ,OAAA,EAAuC;AAE7D,EAAA,MAAM,MAAA,GAAS,OAAA,CAAQ,OAAA,CAAQ,MAAM,CAAA;AACrC,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,2BAA2B,CAAA;AACxD,EAAA,MAAM,GAAA,GAAM,QAAQ,QAAA,IAAY,6BAAA;AAChC,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,IAAO,IAAA,CAAK,GAAA;AAChC,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;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,OAAA,GAAU;AAAA,QACd,MAAA;AAAA,QACA,cAAA,EAAgB,GAAA;AAAA;AAAA,QAEhB,UAAU,EAAE,IAAA,EAAM,aAAa,OAAA,EAAS,OAAA,EAAS,KAAK,uBAAA,EAAwB;AAAA,QAC9E,MAAA,EAAQ;AAAA,UACN;AAAA,YACE,UAAA,EAAY,CAAC,EAAE,UAAA,EAAY,CAAA,UAAA,EAAa,KAAA,CAAM,IAAI,CAAA,CAAA,EAAI,OAAA,EAAS,GAAA,CAAI,CAAA,EAAG,KAAA,CAAM,KAAK;AAAA,EAAK,KAAA,CAAM,OAAO,CAAA,CAAA,EAAI,GAAI,CAAA,EAAG,YAAY,EAAC,EAAG,IAAA,EAAM,QAAA,EAAU,CAAA;AAAA,YAC9I,QAAA,EAAU,QAAA,CAAS,KAAA,CAAM,IAAI,CAAA;AAAA,YAC7B,SAAA,EAAW,KAAA;AAAA,YACX,cAAA,EAAgB,EAAE,IAAA,EAAM,kBAAA,EAAmB;AAAA,YAC3C,SAAS,KAAA,CAAM,GAAA;AAAA,YACf,cAAc,CAAA,UAAA,EAAa,KAAA,CAAM,GAAG,CAAA,CAAA,EAAI,MAAM,IAAI,CAAA,CAAA;AAAA,YAClD,QAAA,EAAU;AAAA,cACR,SAAA,EAAW;AAAA,gBACT,KAAK,KAAA,CAAM,GAAA;AAAA,gBACX,MAAM,KAAA,CAAM,IAAA;AAAA,gBACZ,GAAI,MAAM,MAAA,GAAS,EAAE,QAAQ,KAAA,CAAM,MAAA,KAAW,EAAC;AAAA,gBAC/C,GAAI,IAAA,GAAO,EAAE,IAAA,KAAS,EAAC;AAAA,gBACvB,SAAS,KAAA,CAAM,OAAA;AAAA,gBACf,GAAA,EAAK,WAAW,KAAK;AAAA;AACvB,aACF;AAAA,YACA,GAAA,EAAK,EAAE,YAAA,EAAc,OAAA,CAAQ,gBAAgB,YAAA,EAAa;AAAA,YAC1D,MAAA,EAAQ,EAAE,IAAA,EAAM,IAAI,KAAK,KAAA,CAAM,EAAE,CAAA,CAAE,WAAA,EAAY;AAAE;AACnD;AACF,OACF;AACA,MAAA,MAAM,IAAA,CAAK,WAAW,GAAA,EAAK;AAAA,QACzB,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,kBAAA;AAAA,UAChB,iBAAA,EAAmB,MAAA;AAAA,UACnB,yBAAA,EAA2B,GAAA;AAAA,UAC3B,mBAAmB,IAAI,IAAA,CAAK,GAAA,EAAK,EAAE,WAAA;AAAY,SACjD;AAAA,QACA,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,OAAO;AAAA,OAC9B,EAAG,CAAC,MAAM,CAAC,CAAA;AAAA,IACb;AAAA,GACF;AACF","file":"bugsnag.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 * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\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 if (value.length > MAX_LENGTH) tooLong(value, label);\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/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\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\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 * Bugsnag, Error Reporting API, payload version 5.\n * API reference: https://developer.smartbear.com/bugsnag/docs/reporting-events-and-sessions\n * (formerly https://bugsnagerrorreportingapi.docs.apiary.io/)\n * POST https://notify.bugsnag.com/ with Bugsnag-Api-Key.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface BugsnagOptions {\n /** The project's notifier API key. */\n apiKey: string;\n /** Defaults to \"production\". */\n releaseStage?: string;\n /** Another notify endpoint, for on-premise installs. Defaults to https://notify.bugsnag.com/. */\n endpoint?: string;\n /** Also send recoveries, as info events. Defaults to false, since each one is an event on an error. */\n recovered?: boolean;\n /** The clock for the Bugsnag-Sent-At header, in epoch milliseconds. For tests. */\n now?: () => number;\n link?: (alert: Alert) => string;\n}\n\n/** Reports alerts to Bugsnag, grouped per job and alert type. */\nexport function bugsnag(options: BugsnagOptions): 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(\"bugsnag() needs an apiKey\");\n const url = options.endpoint ?? \"https://notify.bugsnag.com/\";\n const now = options.now ?? Date.now;\n return {\n name: \"bugsnag\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const payload = {\n apiKey,\n payloadVersion: \"5\",\n // The notifier's own version, not the SDK's; Bugsnag asks for one.\n notifier: { name: \"cronwatch\", version: \"1.0.0\", url: \"https://cronwatch.dev\" },\n events: [\n {\n exceptions: [{ errorClass: `CronWatch ${alert.type}`, message: cut(`${alert.title}\\n${alert.message}`, 8000), stacktrace: [], type: \"nodejs\" }],\n severity: severity(alert.type),\n unhandled: false,\n severityReason: { type: \"handledException\" },\n context: alert.job,\n groupingHash: `cronwatch:${alert.job}:${alert.type}`,\n metaData: {\n cronwatch: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n app: { releaseStage: options.releaseStage ?? \"production\" },\n device: { time: new Date(alert.at).toISOString() },\n },\n ],\n };\n await post(\"Bugsnag\", url, {\n headers: {\n \"content-type\": \"application/json\",\n \"bugsnag-api-key\": apiKey,\n \"bugsnag-payload-version\": \"5\",\n \"bugsnag-sent-at\": new Date(now()).toISOString(),\n },\n body: JSON.stringify(payload),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/bugsnag.ts"],"names":[],"mappings":"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","file":"bugsnag.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 * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\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 if (value.length > MAX_LENGTH) tooLong(value, label);\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/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\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\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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 await drain(response);\n return response;\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 * Bugsnag, Error Reporting API, payload version 5.\n * API reference: https://developer.smartbear.com/bugsnag/docs/reporting-events-and-sessions\n * (formerly https://bugsnagerrorreportingapi.docs.apiary.io/)\n * POST https://notify.bugsnag.com/ with Bugsnag-Api-Key.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface BugsnagOptions {\n /** The project's notifier API key. */\n apiKey: string;\n /** Defaults to \"production\". */\n releaseStage?: string;\n /** Another notify endpoint, for on-premise installs. Defaults to https://notify.bugsnag.com/. */\n endpoint?: string;\n /** Also send recoveries, as info events. Defaults to false, since each one is an event on an error. */\n recovered?: boolean;\n /** The clock for the Bugsnag-Sent-At header, in epoch milliseconds. For tests. */\n now?: () => number;\n link?: (alert: Alert) => string;\n}\n\n/** Reports alerts to Bugsnag, grouped per job and alert type. */\nexport function bugsnag(options: BugsnagOptions): 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(\"bugsnag() needs an apiKey\");\n const url = options.endpoint ?? \"https://notify.bugsnag.com/\";\n const now = options.now ?? Date.now;\n return {\n name: \"bugsnag\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const payload = {\n apiKey,\n payloadVersion: \"5\",\n // The notifier's own version, not the SDK's; Bugsnag asks for one.\n notifier: { name: \"cronwatch\", version: \"1.0.0\", url: \"https://cronwatch.dev\" },\n events: [\n {\n exceptions: [{ errorClass: `CronWatch ${alert.type}`, message: cut(`${alert.title}\\n${alert.message}`, 8000), stacktrace: [], type: \"nodejs\" }],\n severity: severity(alert.type),\n unhandled: false,\n severityReason: { type: \"handledException\" },\n context: alert.job,\n groupingHash: `cronwatch:${alert.job}:${alert.type}`,\n metaData: {\n cronwatch: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n app: { releaseStage: options.releaseStage ?? \"production\" },\n device: { time: new Date(alert.at).toISOString() },\n },\n ],\n };\n await post(\"Bugsnag\", url, {\n headers: {\n \"content-type\": \"application/json\",\n \"bugsnag-api-key\": apiKey,\n \"bugsnag-payload-version\": \"5\",\n \"bugsnag-sent-at\": new Date(now()).toISOString(),\n },\n body: JSON.stringify(payload),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/bugsnag.d.cts
CHANGED
package/dist/bugsnag.d.ts
CHANGED
package/dist/bugsnag.js
CHANGED
|
@@ -40,8 +40,15 @@ async function post(provider, url, init, secrets = []) {
|
|
|
40
40
|
}
|
|
41
41
|
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
42
42
|
}
|
|
43
|
+
await drain(response);
|
|
43
44
|
return response;
|
|
44
45
|
}
|
|
46
|
+
async function drain(response) {
|
|
47
|
+
try {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
45
52
|
function errorBody(text, secrets = []) {
|
|
46
53
|
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
47
54
|
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
package/dist/bugsnag.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/bugsnag.ts"],"names":[],"mappings":"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","file":"bugsnag.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 * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\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 if (value.length > MAX_LENGTH) tooLong(value, label);\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/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\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\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 * Bugsnag, Error Reporting API, payload version 5.\n * API reference: https://developer.smartbear.com/bugsnag/docs/reporting-events-and-sessions\n * (formerly https://bugsnagerrorreportingapi.docs.apiary.io/)\n * POST https://notify.bugsnag.com/ with Bugsnag-Api-Key.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface BugsnagOptions {\n /** The project's notifier API key. */\n apiKey: string;\n /** Defaults to \"production\". */\n releaseStage?: string;\n /** Another notify endpoint, for on-premise installs. Defaults to https://notify.bugsnag.com/. */\n endpoint?: string;\n /** Also send recoveries, as info events. Defaults to false, since each one is an event on an error. */\n recovered?: boolean;\n /** The clock for the Bugsnag-Sent-At header, in epoch milliseconds. For tests. */\n now?: () => number;\n link?: (alert: Alert) => string;\n}\n\n/** Reports alerts to Bugsnag, grouped per job and alert type. */\nexport function bugsnag(options: BugsnagOptions): 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(\"bugsnag() needs an apiKey\");\n const url = options.endpoint ?? \"https://notify.bugsnag.com/\";\n const now = options.now ?? Date.now;\n return {\n name: \"bugsnag\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const payload = {\n apiKey,\n payloadVersion: \"5\",\n // The notifier's own version, not the SDK's; Bugsnag asks for one.\n notifier: { name: \"cronwatch\", version: \"1.0.0\", url: \"https://cronwatch.dev\" },\n events: [\n {\n exceptions: [{ errorClass: `CronWatch ${alert.type}`, message: cut(`${alert.title}\\n${alert.message}`, 8000), stacktrace: [], type: \"nodejs\" }],\n severity: severity(alert.type),\n unhandled: false,\n severityReason: { type: \"handledException\" },\n context: alert.job,\n groupingHash: `cronwatch:${alert.job}:${alert.type}`,\n metaData: {\n cronwatch: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n app: { releaseStage: options.releaseStage ?? \"production\" },\n device: { time: new Date(alert.at).toISOString() },\n },\n ],\n };\n await post(\"Bugsnag\", url, {\n headers: {\n \"content-type\": \"application/json\",\n \"bugsnag-api-key\": apiKey,\n \"bugsnag-payload-version\": \"5\",\n \"bugsnag-sent-at\": new Date(now()).toISOString(),\n },\n body: JSON.stringify(payload),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/bugsnag.ts"],"names":[],"mappings":"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","file":"bugsnag.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 * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\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 if (value.length > MAX_LENGTH) tooLong(value, label);\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/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\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\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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 await drain(response);\n return response;\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 * Bugsnag, Error Reporting API, payload version 5.\n * API reference: https://developer.smartbear.com/bugsnag/docs/reporting-events-and-sessions\n * (formerly https://bugsnagerrorreportingapi.docs.apiary.io/)\n * POST https://notify.bugsnag.com/ with Bugsnag-Api-Key.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface BugsnagOptions {\n /** The project's notifier API key. */\n apiKey: string;\n /** Defaults to \"production\". */\n releaseStage?: string;\n /** Another notify endpoint, for on-premise installs. Defaults to https://notify.bugsnag.com/. */\n endpoint?: string;\n /** Also send recoveries, as info events. Defaults to false, since each one is an event on an error. */\n recovered?: boolean;\n /** The clock for the Bugsnag-Sent-At header, in epoch milliseconds. For tests. */\n now?: () => number;\n link?: (alert: Alert) => string;\n}\n\n/** Reports alerts to Bugsnag, grouped per job and alert type. */\nexport function bugsnag(options: BugsnagOptions): 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(\"bugsnag() needs an apiKey\");\n const url = options.endpoint ?? \"https://notify.bugsnag.com/\";\n const now = options.now ?? Date.now;\n return {\n name: \"bugsnag\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const payload = {\n apiKey,\n payloadVersion: \"5\",\n // The notifier's own version, not the SDK's; Bugsnag asks for one.\n notifier: { name: \"cronwatch\", version: \"1.0.0\", url: \"https://cronwatch.dev\" },\n events: [\n {\n exceptions: [{ errorClass: `CronWatch ${alert.type}`, message: cut(`${alert.title}\\n${alert.message}`, 8000), stacktrace: [], type: \"nodejs\" }],\n severity: severity(alert.type),\n unhandled: false,\n severityReason: { type: \"handledException\" },\n context: alert.job,\n groupingHash: `cronwatch:${alert.job}:${alert.type}`,\n metaData: {\n cronwatch: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n app: { releaseStage: options.releaseStage ?? \"production\" },\n device: { time: new Date(alert.at).toISOString() },\n },\n ],\n };\n await post(\"Bugsnag\", url, {\n headers: {\n \"content-type\": \"application/json\",\n \"bugsnag-api-key\": apiKey,\n \"bugsnag-payload-version\": \"5\",\n \"bugsnag-sent-at\": new Date(now()).toISOString(),\n },\n body: JSON.stringify(payload),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobOptions, R as Run, A as Alert, S as Store, a as AlertChannel, o as TriageFn, D as Duration, h as JobDefinition, f as CheckResult, k as JobSummary, j as JobState } from './types-
|
|
1
|
+
import { J as JobOptions, R as Run, A as Alert, S as Store, a as AlertChannel, o as TriageFn, D as Duration, h as JobDefinition, f as CheckResult, k as JobSummary, j as JobState } from './types-D6t0Z846.js';
|
|
2
2
|
|
|
3
3
|
/** What a job function receives. */
|
|
4
4
|
interface JobContext {
|
|
@@ -267,6 +267,11 @@ declare class Cronwatch {
|
|
|
267
267
|
private readonly deferDelivery;
|
|
268
268
|
private readonly defaults;
|
|
269
269
|
private readonly definitions;
|
|
270
|
+
/**
|
|
271
|
+
* Declarations forgotten here (forget()), kept so that run() or resumeRun()
|
|
272
|
+
* by name declares the job again as it was written, not bare.
|
|
273
|
+
*/
|
|
274
|
+
private readonly forgotten;
|
|
270
275
|
private readonly synced;
|
|
271
276
|
/** The tail of each job's queue of state updates. See serial(). */
|
|
272
277
|
private readonly queues;
|
|
@@ -287,6 +292,8 @@ declare class Cronwatch {
|
|
|
287
292
|
constructor(options?: CronwatchOptions);
|
|
288
293
|
/** Declare a job. Call it once, at module level, and keep the handle. */
|
|
289
294
|
job(name: string, options?: JobOptions): JobHandle;
|
|
295
|
+
/** The declaration of `name`: the standing one, else one forgotten here, declared again as it was. */
|
|
296
|
+
private declared;
|
|
290
297
|
/** Run a job by name without keeping a handle. Defines it on first use. */
|
|
291
298
|
run<T>(name: string, fn: JobFn<T>): Promise<T>;
|
|
292
299
|
run<T>(name: string, options: JobOptions, fn: JobFn<T>): Promise<T>;
|
|
@@ -447,6 +454,16 @@ declare class Cronwatch {
|
|
|
447
454
|
*/
|
|
448
455
|
check(): Promise<CheckResult>;
|
|
449
456
|
private runCheck;
|
|
457
|
+
/**
|
|
458
|
+
* Runs the job's state still holds as pending (holdRun) whose finish was
|
|
459
|
+
* written but never evaluated: the process that finished them stopped, or
|
|
460
|
+
* its state write failed, between the two writes. Each one finished more
|
|
461
|
+
* than SEND_LEASE_MS ago is evaluated now, as its finish would have been;
|
|
462
|
+
* one still running is left to the stuck check, and one the store no
|
|
463
|
+
* longer has (given back, or pruned) is let go. The wait leaves a finish
|
|
464
|
+
* under way in another process to write its own evaluation.
|
|
465
|
+
*/
|
|
466
|
+
private evaluatePending;
|
|
450
467
|
/** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
|
|
451
468
|
private snapshot;
|
|
452
469
|
/** The summary of a job whose evaluation failed, from whatever can still be read. */
|
|
@@ -473,8 +490,10 @@ declare class Cronwatch {
|
|
|
473
490
|
private patchState;
|
|
474
491
|
/**
|
|
475
492
|
* Remove a job and its runs from the store. A job still declared in code
|
|
476
|
-
* comes back: here on its next run,
|
|
477
|
-
*
|
|
493
|
+
* comes back, with no history: here on its next run, declared as it was
|
|
494
|
+
* written (cw.run() and cw.resumeRun() by name included), and in any other
|
|
495
|
+
* process that declares it on its next run there, or at that process's
|
|
496
|
+
* next check or dashboard read.
|
|
478
497
|
*/
|
|
479
498
|
forget(name: string): Promise<void>;
|
|
480
499
|
/**
|
|
@@ -518,10 +537,12 @@ declare class Cronwatch {
|
|
|
518
537
|
private dispatch;
|
|
519
538
|
/**
|
|
520
539
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
521
|
-
* first.
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
540
|
+
* first. They are claimed first (claimUndelivered): moved into `sending`
|
|
541
|
+
* with a lease, in one conditional write, so when several processes check
|
|
542
|
+
* at once only the one whose write lands sends them. An alert that no
|
|
543
|
+
* longer describes the job (staleAlert) is dropped instead. Retries across
|
|
544
|
+
* a check share RETRY_BUDGET_MS of wall-clock time; claimed alerts it has
|
|
545
|
+
* no time left for go back to the queue for the next check.
|
|
525
546
|
*/
|
|
526
547
|
private retryUndelivered;
|
|
527
548
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobOptions, R as Run, A as Alert, S as Store, a as AlertChannel, o as TriageFn, D as Duration, h as JobDefinition, f as CheckResult, k as JobSummary, j as JobState } from './types-
|
|
1
|
+
import { J as JobOptions, R as Run, A as Alert, S as Store, a as AlertChannel, o as TriageFn, D as Duration, h as JobDefinition, f as CheckResult, k as JobSummary, j as JobState } from './types-D6t0Z846.cjs';
|
|
2
2
|
|
|
3
3
|
/** What a job function receives. */
|
|
4
4
|
interface JobContext {
|
|
@@ -267,6 +267,11 @@ declare class Cronwatch {
|
|
|
267
267
|
private readonly deferDelivery;
|
|
268
268
|
private readonly defaults;
|
|
269
269
|
private readonly definitions;
|
|
270
|
+
/**
|
|
271
|
+
* Declarations forgotten here (forget()), kept so that run() or resumeRun()
|
|
272
|
+
* by name declares the job again as it was written, not bare.
|
|
273
|
+
*/
|
|
274
|
+
private readonly forgotten;
|
|
270
275
|
private readonly synced;
|
|
271
276
|
/** The tail of each job's queue of state updates. See serial(). */
|
|
272
277
|
private readonly queues;
|
|
@@ -287,6 +292,8 @@ declare class Cronwatch {
|
|
|
287
292
|
constructor(options?: CronwatchOptions);
|
|
288
293
|
/** Declare a job. Call it once, at module level, and keep the handle. */
|
|
289
294
|
job(name: string, options?: JobOptions): JobHandle;
|
|
295
|
+
/** The declaration of `name`: the standing one, else one forgotten here, declared again as it was. */
|
|
296
|
+
private declared;
|
|
290
297
|
/** Run a job by name without keeping a handle. Defines it on first use. */
|
|
291
298
|
run<T>(name: string, fn: JobFn<T>): Promise<T>;
|
|
292
299
|
run<T>(name: string, options: JobOptions, fn: JobFn<T>): Promise<T>;
|
|
@@ -447,6 +454,16 @@ declare class Cronwatch {
|
|
|
447
454
|
*/
|
|
448
455
|
check(): Promise<CheckResult>;
|
|
449
456
|
private runCheck;
|
|
457
|
+
/**
|
|
458
|
+
* Runs the job's state still holds as pending (holdRun) whose finish was
|
|
459
|
+
* written but never evaluated: the process that finished them stopped, or
|
|
460
|
+
* its state write failed, between the two writes. Each one finished more
|
|
461
|
+
* than SEND_LEASE_MS ago is evaluated now, as its finish would have been;
|
|
462
|
+
* one still running is left to the stuck check, and one the store no
|
|
463
|
+
* longer has (given back, or pruned) is let go. The wait leaves a finish
|
|
464
|
+
* under way in another process to write its own evaluation.
|
|
465
|
+
*/
|
|
466
|
+
private evaluatePending;
|
|
450
467
|
/** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
|
|
451
468
|
private snapshot;
|
|
452
469
|
/** The summary of a job whose evaluation failed, from whatever can still be read. */
|
|
@@ -473,8 +490,10 @@ declare class Cronwatch {
|
|
|
473
490
|
private patchState;
|
|
474
491
|
/**
|
|
475
492
|
* Remove a job and its runs from the store. A job still declared in code
|
|
476
|
-
* comes back: here on its next run,
|
|
477
|
-
*
|
|
493
|
+
* comes back, with no history: here on its next run, declared as it was
|
|
494
|
+
* written (cw.run() and cw.resumeRun() by name included), and in any other
|
|
495
|
+
* process that declares it on its next run there, or at that process's
|
|
496
|
+
* next check or dashboard read.
|
|
478
497
|
*/
|
|
479
498
|
forget(name: string): Promise<void>;
|
|
480
499
|
/**
|
|
@@ -518,10 +537,12 @@ declare class Cronwatch {
|
|
|
518
537
|
private dispatch;
|
|
519
538
|
/**
|
|
520
539
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
521
|
-
* first.
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
540
|
+
* first. They are claimed first (claimUndelivered): moved into `sending`
|
|
541
|
+
* with a lease, in one conditional write, so when several processes check
|
|
542
|
+
* at once only the one whose write lands sends them. An alert that no
|
|
543
|
+
* longer describes the job (staleAlert) is dropped instead. Retries across
|
|
544
|
+
* a check share RETRY_BUDGET_MS of wall-clock time; claimed alerts it has
|
|
545
|
+
* no time left for go back to the queue for the next check.
|
|
525
546
|
*/
|
|
526
547
|
private retryUndelivered;
|
|
527
548
|
/**
|
package/dist/d1.d.cts
CHANGED
package/dist/d1.d.ts
CHANGED
package/dist/datadog.cjs
CHANGED
|
@@ -30,8 +30,15 @@ async function post(provider, url, init, secrets = []) {
|
|
|
30
30
|
}
|
|
31
31
|
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
32
32
|
}
|
|
33
|
+
await drain(response);
|
|
33
34
|
return response;
|
|
34
35
|
}
|
|
36
|
+
async function drain(response) {
|
|
37
|
+
try {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
35
42
|
function errorBody(text, secrets = []) {
|
|
36
43
|
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
37
44
|
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
package/dist/datadog.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/datadog.ts"],"names":[],"mappings":"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","file":"datadog.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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 under_floor: \"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"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/datadog.ts"],"names":[],"mappings":"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","file":"datadog.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 { isoTime } from \"../duration.js\";\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\" || type === \"under_floor\") 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/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\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(postable(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 await drain(response);\n return response;\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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. A start before the year 1 or after 9999 is null. */\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: isoTime(run.startedAt), 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 under_floor: \"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/datadog.d.cts
CHANGED
package/dist/datadog.d.ts
CHANGED
package/dist/datadog.js
CHANGED
|
@@ -28,8 +28,15 @@ async function post(provider, url, init, secrets = []) {
|
|
|
28
28
|
}
|
|
29
29
|
throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
|
|
30
30
|
}
|
|
31
|
+
await drain(response);
|
|
31
32
|
return response;
|
|
32
33
|
}
|
|
34
|
+
async function drain(response) {
|
|
35
|
+
try {
|
|
36
|
+
await response.body?.cancel();
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
33
40
|
function errorBody(text, secrets = []) {
|
|
34
41
|
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
35
42
|
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|