@cronwatch/sdk 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/anthropic.cjs +13 -2
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/anthropic.js +13 -2
- package/dist/anthropic.js.map +1 -1
- package/dist/bugsnag.cjs +8 -1
- 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 +8 -1
- package/dist/bugsnag.js.map +1 -1
- package/dist/{client-B7bqnkI5.d.cts → client-B5hM4l-D.d.ts} +5 -2
- package/dist/{client-pmqH3apS.d.ts → client-Di5HAWQz.d.cts} +5 -2
- package/dist/d1.cjs +4 -1
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.d.cts +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/d1.js +4 -1
- package/dist/d1.js.map +1 -1
- 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.map +1 -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.map +1 -1
- package/dist/{email-_H0faCh3.d.ts → email-Ciq7GRn4.d.ts} +1 -1
- package/dist/{email-BJ-z_epD.d.cts → email-DX6gkd5H.d.cts} +1 -1
- package/dist/honeybadger.cjs +8 -1
- 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 +8 -1
- package/dist/honeybadger.js.map +1 -1
- package/dist/index.cjs +98 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +98 -22
- package/dist/index.js.map +1 -1
- 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.map +1 -1
- 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.map +1 -1
- package/dist/pg-cron.cjs +8 -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 +8 -1
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +6 -1
- 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 +6 -1
- package/dist/postgres.js.map +1 -1
- 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.map +1 -1
- 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.map +1 -1
- package/dist/rollbar.cjs +8 -1
- 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 +8 -1
- package/dist/rollbar.js.map +1 -1
- 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.map +1 -1
- package/dist/sentry.cjs +8 -1
- 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 +8 -1
- package/dist/sentry.js.map +1 -1
- 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.map +1 -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.map +1 -1
- package/dist/sqlite.cjs +4 -1
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js +4 -1
- package/dist/sqlite.js.map +1 -1
- 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.map +1 -1
- package/dist/{types-Ddq1MUPL.d.cts → types-BjBbCGeJ.d.cts} +4 -1
- package/dist/{types-Ddq1MUPL.d.ts → types-BjBbCGeJ.d.ts} +4 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/honeybadger.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":"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","file":"honeybadger.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\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. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":"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","file":"honeybadger.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\") 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 * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/honeybadger.d.cts
CHANGED
package/dist/honeybadger.d.ts
CHANGED
package/dist/honeybadger.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// src/duration.ts
|
|
2
|
+
var FIRST_DATE_MS = -621355968e5;
|
|
3
|
+
var LAST_DATE_MS = 253402300799999;
|
|
4
|
+
function isoTime(at) {
|
|
5
|
+
return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
// src/alerts/shared.ts
|
|
2
9
|
var TIMEOUT_MS = 1e4;
|
|
3
10
|
function origin(url) {
|
|
@@ -50,7 +57,7 @@ function cut(text, max) {
|
|
|
50
57
|
function runSummary(alert) {
|
|
51
58
|
const run = alert.run;
|
|
52
59
|
if (!run) return null;
|
|
53
|
-
return { id: run.id, status: run.status, startedAt:
|
|
60
|
+
return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
// src/alerts/honeybadger.ts
|
package/dist/honeybadger.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":"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","file":"honeybadger.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\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. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/honeybadger.ts"],"names":[],"mappings":"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","file":"honeybadger.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\") 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 * Honeybadger, as error notices (not Check-ins, a separate product).\n * API reference: https://docs.honeybadger.io/api/reporting-exceptions/\n * POST https://api.honeybadger.io/v1/notices with X-API-Key. Answers 201.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, runSummary, trimmed } from \"./shared.js\";\n\nexport interface HoneybadgerOptions {\n /** The project API key, from Project Settings. */\n apiKey: string;\n /** Defaults to \"production\". */\n environment?: string;\n /** Another API host, \"https://eu-api.honeybadger.io\" say. Defaults to https://api.honeybadger.io. */\n endpoint?: string;\n /** Also send recoveries. Defaults to false: Honeybadger has no levels, so a recovery would read as an error. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\nconst CLASS: Record<Alert[\"type\"], string> = {\n missed: \"CronWatch::Missed\",\n failed: \"CronWatch::Failed\",\n stuck: \"CronWatch::Stuck\",\n slow: \"CronWatch::Slow\",\n over_budget: \"CronWatch::OverBudget\",\n recovered: \"CronWatch::Recovered\",\n};\n\n/** Reports alerts to Honeybadger as notices, one error per job and alert type. */\nexport function honeybadger(options: HoneybadgerOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"honeybadger() needs an apiKey\");\n const url = `${(options.endpoint ?? \"https://api.honeybadger.io\").replace(/\\/+$/, \"\")}/v1/notices`;\n return {\n name: \"honeybadger\",\n async send(alert) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const link = options.link?.(alert);\n const notice = {\n notifier: { name: \"cronwatch\", url: \"https://cronwatch.dev\" },\n error: {\n class: CLASS[alert.type],\n message: cut(`${alert.title}\\n${alert.message}`, 8000),\n // No code ran here; one frame naming the job keeps the notice well formed.\n backtrace: [{ number: \"0\", file: `cronwatch/${alert.job}`, method: alert.type }],\n fingerprint: `cronwatch:${alert.job}:${alert.type}`,\n tags: [\"cronwatch\", alert.type],\n },\n request: {\n component: \"cronwatch\",\n action: alert.job,\n ...(link ? { url: link } : {}),\n context: {\n job: alert.job,\n type: alert.type,\n ...(alert.triage ? { triage: alert.triage } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n },\n server: { environment_name: options.environment ?? \"production\" },\n };\n await post(\"Honeybadger\", url, {\n headers: { \"content-type\": \"application/json\", accept: \"application/json\", \"x-api-key\": apiKey },\n body: JSON.stringify(notice),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/index.cjs
CHANGED
|
@@ -11,11 +11,14 @@ var UNIT_MS = {
|
|
|
11
11
|
d: 864e5,
|
|
12
12
|
w: 6048e5
|
|
13
13
|
};
|
|
14
|
+
var MAX_LENGTH = 64;
|
|
15
|
+
var QUOTED = 32;
|
|
14
16
|
function parseDuration(value, label = "duration") {
|
|
15
17
|
if (typeof value === "number") {
|
|
16
18
|
if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);
|
|
17
19
|
return value;
|
|
18
20
|
}
|
|
21
|
+
if (value.length > MAX_LENGTH) tooLong(value, label);
|
|
19
22
|
const text = value.trim().toLowerCase();
|
|
20
23
|
if (text === "") throw new Error(`${label} is empty`);
|
|
21
24
|
const re = /(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)/g;
|
|
@@ -31,6 +34,16 @@ function parseDuration(value, label = "duration") {
|
|
|
31
34
|
}
|
|
32
35
|
return Math.round(total);
|
|
33
36
|
}
|
|
37
|
+
function tooLong(value, label) {
|
|
38
|
+
let count = 0;
|
|
39
|
+
let head = "";
|
|
40
|
+
for (const char of value) {
|
|
41
|
+
if (count < QUOTED) head += char;
|
|
42
|
+
if (++count > MAX_LENGTH) {
|
|
43
|
+
throw new Error(`${label} "${head}..." is too long for a duration (more than ${MAX_LENGTH} characters)`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
34
47
|
function formatDuration(ms) {
|
|
35
48
|
if (!Number.isFinite(ms)) return "?";
|
|
36
49
|
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
@@ -54,6 +67,14 @@ function formatRelative(at, now) {
|
|
|
54
67
|
if (abs < 5e3) return text;
|
|
55
68
|
return diff < 0 ? `${text} ago` : `in ${text}`;
|
|
56
69
|
}
|
|
70
|
+
var FIRST_DATE_MS = -621355968e5;
|
|
71
|
+
var LAST_DATE_MS = 253402300799999;
|
|
72
|
+
function isoTime(at) {
|
|
73
|
+
return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;
|
|
74
|
+
}
|
|
75
|
+
function beyondDates(at) {
|
|
76
|
+
return at > LAST_DATE_MS ? "after 9999-12-31 23:59:59 UTC" : "before 0001-01-01 00:00:00 UTC";
|
|
77
|
+
}
|
|
57
78
|
|
|
58
79
|
// src/env.ts
|
|
59
80
|
function readEnv(name) {
|
|
@@ -91,15 +112,36 @@ function parseSchedule(schedule, timezone) {
|
|
|
91
112
|
cache.set(key, parsed);
|
|
92
113
|
return parsed;
|
|
93
114
|
}
|
|
115
|
+
var CYCLE_MS = 146097 * 864e5;
|
|
116
|
+
var CRONER_FIRST_MS = Date.UTC(400, 0, 1);
|
|
117
|
+
var CRONER_LAST_MS = Date.UTC(2800, 0, 1);
|
|
118
|
+
function runsAfter(cron, n, from) {
|
|
119
|
+
let shift = 0;
|
|
120
|
+
if (from < CRONER_FIRST_MS) shift = Math.ceil((CRONER_FIRST_MS - from) / CYCLE_MS) * CYCLE_MS;
|
|
121
|
+
else if (from >= CRONER_LAST_MS) shift = -(Math.floor((from - CRONER_LAST_MS) / CYCLE_MS) + 1) * CYCLE_MS;
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const date of cron.nextRuns(n, new Date(from + shift))) {
|
|
124
|
+
const t = date.getTime() - shift;
|
|
125
|
+
if (t > LAST_DATE_MS) break;
|
|
126
|
+
out.push(t);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function countFrom(from) {
|
|
131
|
+
if (from >= LAST_DATE_MS) return null;
|
|
132
|
+
return from >= FIRST_DATE_MS ? from : FIRST_DATE_MS - 1;
|
|
133
|
+
}
|
|
94
134
|
function fireAfter(parsed, from) {
|
|
95
135
|
const cron = crons.get(parsed);
|
|
96
136
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
97
|
-
|
|
137
|
+
const start = countFrom(from);
|
|
138
|
+
if (start === null) return null;
|
|
139
|
+
let probe = start;
|
|
98
140
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
99
|
-
const runs = cron
|
|
141
|
+
const runs = runsAfter(cron, 8, probe);
|
|
100
142
|
if (runs.length === 0) return null;
|
|
101
|
-
const found = runs.find((
|
|
102
|
-
if (found) return found
|
|
143
|
+
const found = runs.find((t) => t > start);
|
|
144
|
+
if (found !== void 0) return found;
|
|
103
145
|
probe += 36e5;
|
|
104
146
|
}
|
|
105
147
|
return null;
|
|
@@ -108,20 +150,21 @@ function firesBetween(parsed, from, to, limit) {
|
|
|
108
150
|
const cron = crons.get(parsed);
|
|
109
151
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
110
152
|
const out = [];
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
const start = countFrom(from);
|
|
154
|
+
if (start === null) return out;
|
|
155
|
+
let probe = start;
|
|
156
|
+
let last = start;
|
|
113
157
|
for (let guard = 0; guard < 1e3; guard++) {
|
|
114
|
-
const batch = cron
|
|
158
|
+
const batch = runsAfter(cron, Math.min(limit + 1 - out.length, 24), probe);
|
|
115
159
|
if (batch.length === 0) return out;
|
|
116
|
-
for (const
|
|
117
|
-
const t = date.getTime();
|
|
160
|
+
for (const t of batch) {
|
|
118
161
|
if (t <= last) continue;
|
|
119
162
|
if (t > to) return out;
|
|
120
163
|
out.push(t);
|
|
121
164
|
last = t;
|
|
122
165
|
if (out.length > limit) return null;
|
|
123
166
|
}
|
|
124
|
-
const end = batch[batch.length - 1]
|
|
167
|
+
const end = batch[batch.length - 1];
|
|
125
168
|
probe = end > probe ? end : probe + 36e5;
|
|
126
169
|
}
|
|
127
170
|
return out;
|
|
@@ -157,6 +200,7 @@ function runCovers(startedAt, dueAt, followingAt = null) {
|
|
|
157
200
|
}
|
|
158
201
|
function inSpringForwardGap(parsed, startedAt, fireAt) {
|
|
159
202
|
const LOOKBACK = 3 * 36e5;
|
|
203
|
+
if (fireAt - LOOKBACK < FIRST_DATE_MS) return false;
|
|
160
204
|
const after2 = utcOffset(fireAt, parsed.timezone);
|
|
161
205
|
const before = utcOffset(fireAt - LOOKBACK, parsed.timezone);
|
|
162
206
|
const gap = after2 - before;
|
|
@@ -193,8 +237,10 @@ function utcOffset(at, timezone) {
|
|
|
193
237
|
for (const part of format.formatToParts(new Date(at))) {
|
|
194
238
|
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
195
239
|
}
|
|
196
|
-
const wall =
|
|
197
|
-
|
|
240
|
+
const wall = /* @__PURE__ */ new Date(0);
|
|
241
|
+
wall.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
|
242
|
+
wall.setUTCHours(parts.hour, parts.minute, parts.second);
|
|
243
|
+
return wall.getTime() - Math.floor(at / 1e3) * 1e3;
|
|
198
244
|
}
|
|
199
245
|
|
|
200
246
|
// src/stats.ts
|
|
@@ -217,6 +263,15 @@ var DEFAULT_TIMEOUT_MS = 60 * 6e4;
|
|
|
217
263
|
var SLOW_FLOOR_MS = 1e4;
|
|
218
264
|
var BASELINE_MIN_RUNS = 5;
|
|
219
265
|
var BASELINE_WINDOW = 20;
|
|
266
|
+
var MAX_DURATION_MS = Number.MAX_SAFE_INTEGER;
|
|
267
|
+
function runDuration(startedAt, finishedAt) {
|
|
268
|
+
const ms = finishedAt - startedAt;
|
|
269
|
+
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
270
|
+
}
|
|
271
|
+
function stateVersion(state) {
|
|
272
|
+
const version = state?.version;
|
|
273
|
+
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
|
|
274
|
+
}
|
|
220
275
|
function emptyState(job) {
|
|
221
276
|
return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
|
|
222
277
|
}
|
|
@@ -427,7 +482,9 @@ function summary(stored, recent, state, nextExpectedAt, health) {
|
|
|
427
482
|
// src/format.ts
|
|
428
483
|
function when(at, now) {
|
|
429
484
|
if (at === null || at === void 0) return "never";
|
|
430
|
-
|
|
485
|
+
const iso = isoTime(at);
|
|
486
|
+
if (iso === null) return beyondDates(at);
|
|
487
|
+
return `${iso.replace("T", " ").slice(0, 19)} UTC (${formatRelative(at, now)})`;
|
|
431
488
|
}
|
|
432
489
|
function firstLines(text, n) {
|
|
433
490
|
if (!text) return "";
|
|
@@ -823,9 +880,13 @@ function dayLabel(t) {
|
|
|
823
880
|
return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
|
|
824
881
|
}
|
|
825
882
|
function when2(t, now) {
|
|
883
|
+
if (t > LAST_DATE_MS) return "after 31 Dec 9999 23:59";
|
|
884
|
+
if (!(t >= FIRST_DATE_MS)) return "before 1 Jan 0001 00:00";
|
|
826
885
|
if (Math.floor(t / DAY) === Math.floor(now / DAY)) return clock(t);
|
|
827
886
|
const d = new Date(t);
|
|
828
|
-
|
|
887
|
+
const year = d.getUTCFullYear();
|
|
888
|
+
const other = year === new Date(now).getUTCFullYear() ? "" : ` ${String(year).padStart(4, "0")}`;
|
|
889
|
+
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}${other} ${clock(t)}`;
|
|
829
890
|
}
|
|
830
891
|
function parsedSchedule(job) {
|
|
831
892
|
if (!job.definition.schedule) return null;
|
|
@@ -1316,7 +1377,8 @@ function sparkline(runs) {
|
|
|
1316
1377
|
}
|
|
1317
1378
|
function stamp(at, now) {
|
|
1318
1379
|
if (at === null) return `<span class="muted">never</span>`;
|
|
1319
|
-
const iso =
|
|
1380
|
+
const iso = isoTime(at);
|
|
1381
|
+
if (iso === null) return `<span class="nowrap">${beyondDates(at)}</span>`;
|
|
1320
1382
|
return `<time class="nowrap" datetime="${iso}" title="${iso.replace("T", " ").slice(0, 19)} UTC">${h(formatRelative(at, now))}</time>`;
|
|
1321
1383
|
}
|
|
1322
1384
|
function healthFigures(jobs) {
|
|
@@ -1482,7 +1544,20 @@ function developmentToken() {
|
|
|
1482
1544
|
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1483
1545
|
}
|
|
1484
1546
|
function developmentSignInLine(origin, base, token) {
|
|
1485
|
-
|
|
1547
|
+
const intro = "[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ";
|
|
1548
|
+
return origin === null ? `${intro}${base}/?token=${token} on this server (the first request's host is not local, so the link leaves it out)` : `${intro}${origin}${base}/?token=${token}`;
|
|
1549
|
+
}
|
|
1550
|
+
function isLoopbackOrigin(origin) {
|
|
1551
|
+
let host;
|
|
1552
|
+
try {
|
|
1553
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
1554
|
+
} catch {
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1557
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
1558
|
+
if (host === "[::1]") return true;
|
|
1559
|
+
const octets = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
1560
|
+
return octets !== null && octets.slice(1).every((o) => Number(o) <= 255);
|
|
1486
1561
|
}
|
|
1487
1562
|
function safeDecode(value) {
|
|
1488
1563
|
try {
|
|
@@ -1587,7 +1662,8 @@ function createRoutes(cw, options = {}) {
|
|
|
1587
1662
|
const publicOrigin = originOf(request, url);
|
|
1588
1663
|
if (generated && !announced) {
|
|
1589
1664
|
announced = true;
|
|
1590
|
-
|
|
1665
|
+
const shown = fixedOrigin ?? (isLoopbackOrigin(publicOrigin) ? publicOrigin : null);
|
|
1666
|
+
console.info(developmentSignInLine(shown, base, token));
|
|
1591
1667
|
}
|
|
1592
1668
|
if (method === "GET" || method === "HEAD") {
|
|
1593
1669
|
if (path === "/offline") {
|
|
@@ -1835,7 +1911,7 @@ function memory() {
|
|
|
1835
1911
|
states.set(state.job, clone(state));
|
|
1836
1912
|
},
|
|
1837
1913
|
async compareAndSetState(state, expectedVersion) {
|
|
1838
|
-
if ((states.get(state.job)
|
|
1914
|
+
if (stateVersion(states.get(state.job)) !== expectedVersion) return false;
|
|
1839
1915
|
states.set(state.job, clone(state));
|
|
1840
1916
|
return true;
|
|
1841
1917
|
},
|
|
@@ -2105,7 +2181,7 @@ var CronWatch = class {
|
|
|
2105
2181
|
const current = await this.readState(job);
|
|
2106
2182
|
const { state, result } = await change(current);
|
|
2107
2183
|
if (sameState(state, current)) return { state: current, result };
|
|
2108
|
-
const version = current
|
|
2184
|
+
const version = stateVersion(current);
|
|
2109
2185
|
const next = { ...state, version: version + 1 };
|
|
2110
2186
|
if (await this.writeState(next, version)) return { state: next, result };
|
|
2111
2187
|
if (attempt >= STATE_ATTEMPTS) {
|
|
@@ -2165,7 +2241,7 @@ var CronWatch = class {
|
|
|
2165
2241
|
}
|
|
2166
2242
|
const finishedAt = this.now();
|
|
2167
2243
|
run.finishedAt = finishedAt;
|
|
2168
|
-
run.durationMs =
|
|
2244
|
+
run.durationMs = runDuration(startedAt, finishedAt);
|
|
2169
2245
|
run.metrics = recorder.metrics();
|
|
2170
2246
|
run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
|
|
2171
2247
|
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
@@ -2399,7 +2475,7 @@ ${id}`;
|
|
|
2399
2475
|
...from,
|
|
2400
2476
|
status: "running",
|
|
2401
2477
|
finishedAt,
|
|
2402
|
-
durationMs:
|
|
2478
|
+
durationMs: runDuration(from.startedAt, finishedAt),
|
|
2403
2479
|
error: null,
|
|
2404
2480
|
output: joinOutput(from.output, added),
|
|
2405
2481
|
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
@@ -2588,7 +2664,7 @@ ${id}`;
|
|
|
2588
2664
|
const timeout = timeoutMs(definition);
|
|
2589
2665
|
run.status = "timeout";
|
|
2590
2666
|
run.finishedAt = now;
|
|
2591
|
-
run.durationMs =
|
|
2667
|
+
run.durationMs = runDuration(run.startedAt, now);
|
|
2592
2668
|
run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
|
|
2593
2669
|
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
2594
2670
|
alerts.push(...await this.finishRun(definition, run, now));
|