CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
observability.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 80bed05 | 1 | /** |
| 2 | * Minimal, dependency-free observability layer. | |
| 3 | * | |
| 4 | * - `reportError` NEVER throws. Production paths must not break when a webhook | |
| 5 | * is misconfigured or unreachable. | |
| 6 | * - Always logs to stderr with a `[error]` prefix. | |
| 7 | * - Optionally fans out to: | |
| 8 | * - `ERROR_WEBHOOK_URL` — generic JSON POST (fire-and-forget) | |
| 9 | * - `SENTRY_DSN` — Sentry envelope endpoint (fire-and-forget) | |
| 10 | * | |
| 11 | * No SDKs. Only `fetch` (built into Bun). | |
| 12 | */ | |
| 13 | ||
| 14 | interface SentryFrame { | |
| 15 | function?: string; | |
| 16 | filename?: string; | |
| 17 | lineno?: number; | |
| 18 | colno?: number; | |
| 19 | } | |
| 20 | ||
| 21 | function toErr(err: unknown): { message: string; stack?: string; type: string } { | |
| 22 | if (err instanceof Error) { | |
| 23 | return { message: err.message, stack: err.stack, type: err.name || "Error" }; | |
| 24 | } | |
| 25 | try { | |
| 26 | return { message: typeof err === "string" ? err : JSON.stringify(err), type: "NonError" }; | |
| 27 | } catch { | |
| 28 | return { message: String(err), type: "NonError" }; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | function parseStack(stack?: string): SentryFrame[] { | |
| 33 | if (!stack) return []; | |
| 34 | const frames: SentryFrame[] = []; | |
| 35 | for (const raw of stack.split("\n").slice(1)) { | |
| 36 | const m = raw.trim().match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/); | |
| 37 | if (!m) continue; | |
| 38 | frames.push({ | |
| 39 | function: m[1] || undefined, | |
| 40 | filename: m[2], | |
| 41 | lineno: Number(m[3]), | |
| 42 | colno: Number(m[4]), | |
| 43 | }); | |
| 44 | } | |
| 45 | return frames.reverse(); | |
| 46 | } | |
| 47 | ||
| 48 | function randomEventId(): string { | |
| 49 | const b = new Uint8Array(16); | |
| 50 | crypto.getRandomValues(b); | |
| 51 | return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); | |
| 52 | } | |
| 53 | ||
| 54 | function parseDsn(dsn: string): { url: string; headers: Record<string, string> } | null { | |
| 55 | try { | |
| 56 | const u = new URL(dsn); | |
| 57 | const key = u.username; | |
| 58 | const projectId = u.pathname.replace(/^\/+/, ""); | |
| 59 | if (!key || !projectId) return null; | |
| 60 | return { | |
| 61 | url: `${u.protocol}//${u.host}/api/${projectId}/envelope/`, | |
| 62 | headers: { | |
| 63 | "Content-Type": "application/x-sentry-envelope", | |
| 64 | "X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${key}, sentry_client=gluecron/1.0`, | |
| 65 | }, | |
| 66 | }; | |
| 67 | } catch { | |
| 68 | return null; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | function safeLog(msg: string, e: unknown): void { | |
| 73 | try { | |
| 74 | console.error(msg, e); | |
| 75 | } catch { | |
| 76 | /* ignore */ | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | function fireAndForget( | |
| 81 | url: string, | |
| 82 | init: RequestInit, | |
| 83 | label: string | |
| 84 | ): void { | |
| 85 | try { | |
| 86 | void fetch(url, init).catch((e) => safeLog(`[error] ${label} failed:`, e)); | |
| 87 | } catch (e) { | |
| 88 | safeLog(`[error] ${label} threw:`, e); | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | export function reportError(err: unknown, context?: Record<string, unknown>): void { | |
| 93 | const { message, stack, type } = toErr(err); | |
| 94 | const timestamp = new Date().toISOString(); | |
| 95 | ||
| 96 | safeLog("[error]", err); | |
| 97 | ||
| 98 | const webhookUrl = process.env.ERROR_WEBHOOK_URL; | |
| 99 | if (webhookUrl) { | |
| 100 | fireAndForget( | |
| 101 | webhookUrl, | |
| 102 | { | |
| 103 | method: "POST", | |
| 104 | headers: { "Content-Type": "application/json" }, | |
| 105 | body: JSON.stringify({ timestamp, message, stack, context, env: process.env.NODE_ENV }), | |
| 106 | }, | |
| 107 | "observability webhook" | |
| 108 | ); | |
| 109 | } | |
| 110 | ||
| 111 | const dsn = process.env.SENTRY_DSN; | |
| 112 | if (dsn) { | |
| 113 | const parsed = parseDsn(dsn); | |
| 114 | if (!parsed) return; | |
| 115 | const eventId = randomEventId(); | |
| 116 | const event = { | |
| 117 | event_id: eventId, | |
| 118 | timestamp, | |
| 119 | platform: "node", | |
| 120 | level: "error", | |
| 121 | message, | |
| 122 | exception: { | |
| 123 | values: [{ type, value: message, stacktrace: { frames: parseStack(stack) } }], | |
| 124 | }, | |
| 125 | extra: context, | |
| 126 | environment: process.env.NODE_ENV, | |
| 127 | }; | |
| 128 | const body = | |
| 129 | `${JSON.stringify({ event_id: eventId, sent_at: timestamp })}\n` + | |
| 130 | `${JSON.stringify({ type: "event" })}\n` + | |
| 131 | `${JSON.stringify(event)}\n`; | |
| 132 | fireAndForget(parsed.url, { method: "POST", headers: parsed.headers, body }, "sentry report"); | |
| 133 | } | |
| 134 | } |