Repository navigation
Expand file tree
/
Copy pathsentry.ts
More file actions
357 lines (335 loc) · 13.8 KB
/
Copy pathsentry.ts
File metadata and controls
357 lines (335 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
/**
* Dependency-free Sentry capture for the Cloudflare Worker (SSR) side.
*
* We deliberately DO NOT use @sentry/cloudflare or @sentry/astro: both want to
* wrap the Worker's fetch handler, but the handler is generated by the pinned
* @astrojs/cloudflare adapter (see CLAUDE.md — do not bump Astro 5 / adapter 12).
* Wrapping the generated `_worker.js` is brittle. Instead this module speaks the
* Sentry "envelope" ingestion protocol directly over `fetch`, which is all a
* Worker needs and adds zero dependencies / zero bundle weight.
*
* The browser side is handled separately by the Sentry JS Loader in
* `src/components/Analytics.astro`. This file is SERVER-ONLY.
*
* DSN comes from `env.SENTRY_DSN` (a wrangler var / secret) so error reporting
* never depends on D1 — the database may be the very thing that is failing.
*/
import { isApexHost } from "./site";
interface ParsedDsn {
envelopeUrl: string;
publicKey: string;
host: string;
projectId: string;
}
/** Parse a Sentry DSN (`https://<key>@<host>/<projectId>`) into its parts. */
export function parseDsn(dsn: string): ParsedDsn | null {
try {
const u = new URL(dsn);
const projectId = u.pathname.replace(/^\//, "");
if (!u.username || !projectId) return null;
return {
publicKey: u.username,
host: u.host,
projectId,
envelopeUrl: `${u.protocol}//${u.host}/api/${projectId}/envelope/?sentry_key=${u.username}&sentry_version=7`,
};
} catch {
return null;
}
}
/** Best-effort parse of a V8/Spidermonkey-style Error.stack into Sentry frames. */
function stackFrames(stack: string | undefined): { filename?: string; function?: string; lineno?: number; colno?: number }[] {
if (!stack) return [];
const frames: { filename?: string; function?: string; lineno?: number; colno?: number }[] = [];
for (const raw of stack.split("\n")) {
const line = raw.trim();
// "at fn (url:line:col)" | "at url:line:col"
const m = line.match(/^at\s+(?:(.*?)\s+\()?(.*?):(\d+):(\d+)\)?$/);
if (!m) continue;
frames.push({
function: m[1] || "?",
filename: m[2],
lineno: Number(m[3]),
colno: Number(m[4]),
});
}
// Sentry expects frames oldest-first; stacks are newest-first.
return frames.reverse();
}
export interface CaptureContext {
request?: Request;
level?: "fatal" | "error" | "warning" | "info";
tags?: Record<string, string>;
extra?: Record<string, unknown>;
transaction?: string;
}
function buildEvent(env: Env, err: unknown, ctx: CaptureContext) {
const error = err instanceof Error ? err : new Error(typeof err === "string" ? err : "Unknown server error");
// Sentry requires a 32-hex-char event_id. Both branches yield exactly that:
// randomUUID (always present in Workers) stripped of dashes, or 16 random bytes.
const eventId = (
crypto.randomUUID?.() ??
Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("")
).replace(/-/g, "");
const url = ctx.request ? new URL(ctx.request.url) : null;
return {
eventId,
payload: {
event_id: eventId,
timestamp: Date.now() / 1000,
platform: "node",
level: ctx.level ?? "error",
logger: "worker",
server_name: "cloudflare-worker",
environment: env.SENTRY_ENVIRONMENT || (import.meta.env.DEV ? "development" : "production"),
release: env.SENTRY_RELEASE || undefined,
transaction: ctx.transaction ?? (url ? url.pathname : undefined),
exception: {
values: [
{
type: error.name || "Error",
value: error.message || String(err),
stacktrace: { frames: stackFrames(error.stack) },
mechanism: { type: "generic", handled: false },
},
],
},
request: ctx.request
? {
url: ctx.request.url,
method: ctx.request.method,
headers: {
"User-Agent": ctx.request.headers.get("user-agent") || "",
Referer: ctx.request.headers.get("referer") || "",
},
}
: undefined,
tags: { runtime: "cloudflare-workers", ...(ctx.tags ?? {}) },
extra: ctx.extra,
},
};
}
/* ── quota guard ───────────────────────────────────────────────────────────
* Sentry's free tier is a hard monthly error quota, and a single bad deploy can
* burn it in minutes (one broken page × every crawler hit = thousands of
* identical events). Two cheap guards over the CACHE KV namespace keep us well
* inside it while still reporting *every distinct* problem:
*
* 1. Fingerprint cooldown — the same error (type + message + route) is sent
* at most once per DEDUPE_TTL. A storm collapses to one event.
* 2. Monthly budget — a counter per calendar month; past MONTHLY_BUDGET we
* stop sending entirely until the month rolls over.
*
* Both fail OPEN (a KV outage must not silence error reporting) — Sentry's own
* spike protection is the backstop for that window. */
const MONTHLY_BUDGET = 2500; // server share; the browser SDK spends from the same 5k/mo free quota
const DEDUPE_TTL = 900; // seconds — one report per distinct error per 15 min
const BUDGET_TTL = 40 * 24 * 3600; // counter outlives its month, then expires
/** Stable, short fingerprint for "the same error in the same place". */
async function fingerprint(err: unknown, ctx: CaptureContext): Promise<string> {
const e = err instanceof Error ? err : new Error(String(err));
const raw = `${e.name}|${e.message}|${ctx.transaction ?? ""}|${ctx.tags?.area ?? ""}`;
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(raw));
return Array.from(new Uint8Array(digest).slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join("");
}
const monthKey = () => `sentry:budget:${new Date().toISOString().slice(0, 7)}`;
/** True when this event should be sent (and books it against the budget). */
async function admit(env: Env, err: unknown, ctx: CaptureContext): Promise<boolean> {
const kv = env.CACHE;
if (!kv) return true; // no KV bound (bare test) → don't block reporting
try {
const fp = `sentry:seen:${await fingerprint(err, ctx)}`;
const [seen, spentRaw] = await Promise.all([kv.get(fp), kv.get(monthKey())]);
if (seen) return false; // already reported this exact error recently
const spent = parseInt(spentRaw ?? "0", 10) || 0;
if (spent >= MONTHLY_BUDGET) return false; // budget exhausted for this month
await Promise.all([
kv.put(fp, "1", { expirationTtl: DEDUPE_TTL }),
kv.put(monthKey(), String(spent + 1), { expirationTtl: BUDGET_TTL }),
]);
return true;
} catch {
return true; // KV trouble → fail open
}
}
/** How much of this month's server budget is left (for /admin diagnostics). */
export async function sentryBudget(env: Env): Promise<{ spent: number; budget: number }> {
const spent = parseInt((await env.CACHE?.get(monthKey())) ?? "0", 10) || 0;
return { spent, budget: MONTHLY_BUDGET };
}
/**
* Send an exception to Sentry. Fire-and-forget: never throws, returns the
* in-flight promise so callers can hand it to `ctx.waitUntil(...)`. No-ops
* (resolves immediately) when no DSN is configured, when the same error was
* just reported, or when this month's budget is spent.
*/
export async function captureServerException(env: Env, err: unknown, ctx: CaptureContext = {}): Promise<void> {
const dsn = env.SENTRY_DSN;
if (!dsn) return;
const parsed = parseDsn(dsn);
if (!parsed) return;
if (!(await admit(env, err, ctx))) return;
const { eventId, payload } = buildEvent(env, err, ctx);
const header = JSON.stringify({ event_id: eventId, sent_at: new Date().toISOString(), dsn });
const itemHeader = JSON.stringify({ type: "event" });
const body = `${header}\n${itemHeader}\n${JSON.stringify(payload)}`;
await fetch(parsed.envelopeUrl, {
method: "POST",
headers: { "Content-Type": "application/x-sentry-envelope" },
body,
}).catch(() => undefined);
}
/* ── structured logs (Sentry Logs, via OTLP) ───────────────────────────────
* Errors answer "what broke"; logs answer "what happened just before it". The
* free plan is generous here (5 GB/month, versus 5k errors), so this is the one
* place we can afford to be liberal — but we still only log deliberate,
* low-volume, audit-shaped events (who edited what, who signed in). Debug noise
* belongs in Cloudflare Workers Logs, which already collects every `console.*`.
*
* Sent as OpenTelemetry JSON to Sentry's direct OTLP endpoint (open beta), which
* keeps this module dependency-free like the event sender above:
* https://o<orgId>.ingest.<region>.sentry.io/api/<projectId>/integration/otlp/v1/logs
* Both parts are derivable from the DSN, so there is nothing new to configure. */
const SEVERITY: Record<string, number> = { debug: 5, info: 9, warn: 13, error: 17 };
/** Build the OTLP logs URL + auth key from the DSN (`…@o<org>.ingest.<r>.sentry.io/<project>`). */
function otlpLogsTarget(dsn: string): { url: string; key: string } | null {
const parsed = parseDsn(dsn);
if (!parsed) return null;
return {
url: `https://${parsed.host}/api/${parsed.projectId}/integration/otlp/v1/logs`,
key: parsed.publicKey,
};
}
const attr = (k: string, v: string | number | boolean) => ({
key: k,
value:
typeof v === "number"
? { intValue: String(Math.round(v)) }
: typeof v === "boolean"
? { boolValue: v }
: { stringValue: v },
});
/**
* Record one structured log line. Fire-and-forget; never throws. Keep the
* message stable and put the varying parts in `attributes` so the log is
* searchable rather than a soup of unique strings.
*/
export function captureServerLog(
env: Env,
level: "debug" | "info" | "warn" | "error",
message: string,
attributes: Record<string, string | number | boolean> = {},
): Promise<void> {
try {
if (import.meta.env.DEV || !env?.SENTRY_DSN) return Promise.resolve();
const target = otlpLogsTarget(env.SENTRY_DSN);
if (!target) return Promise.resolve();
const body = {
resourceLogs: [
{
resource: {
attributes: [
attr("service.name", "devmultigroup-web"),
attr("deployment.environment", env.SENTRY_ENVIRONMENT || "production"),
],
},
scopeLogs: [
{
scope: { name: "worker" },
logRecords: [
{
timeUnixNano: String(Date.now() * 1e6),
body: { stringValue: message },
severityText: level.toUpperCase(),
severityNumber: SEVERITY[level] ?? 9,
attributes: Object.entries(attributes).map(([k, v]) => attr(k, v)),
},
],
},
],
},
],
};
return fetch(target.url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-sentry-auth": `sentry sentry_key=${target.key}`,
},
body: JSON.stringify(body),
})
.then(() => undefined)
.catch(() => undefined);
} catch {
return Promise.resolve();
}
}
/** `captureServerLog` for call sites holding `Astro.locals` — hands the send to waitUntil. */
export function logEvent(
locals: App.Locals,
level: "debug" | "info" | "warn" | "error",
message: string,
attributes: Record<string, string | number | boolean> = {},
): void {
try {
const env = locals?.runtime?.env as Env | undefined;
if (!env) return;
const p = captureServerLog(env, level, message, attributes);
const wait = locals?.runtime?.ctx?.waitUntil?.bind(locals.runtime.ctx);
if (wait) wait(p);
} catch {
/* logging must never break the request */
}
}
/**
* Fire-and-forget report for library code that holds an `env` but no request or
* `waitUntil` (search indexing, embedding, query fallbacks). Best-effort by
* design: the caller is already on a degraded-but-working path, so the report
* must never add latency to it and must never throw.
*/
export function reportBackground(env: Env, err: unknown, ctx: CaptureContext & { area?: string } = {}): void {
try {
if (import.meta.env.DEV || !env?.SENTRY_DSN) return;
const { area, ...rest } = ctx;
void captureServerException(env, err, {
level: "warning",
...rest,
tags: { ...(rest.tags ?? {}), ...(area ? { area } : {}) },
});
} catch {
/* reporting must never break the caller */
}
}
/**
* One-liner for call sites that already hold `Astro.locals`: resolves the env,
* applies the same dev/apex-only policy as the middleware, and hands the send
* to `waitUntil` so it never delays the response. Use this inside `catch`
* blocks that swallow an error to keep serving the user — the user still gets
* the graceful path, we still learn it happened.
*
* Pass `request` whenever you have one: it carries the URL/UA into the event
* AND is what the apex-only check reads (no request → reported regardless).
*/
export function reportError(
locals: App.Locals,
err: unknown,
ctx: CaptureContext & { area?: string } = {},
): void {
try {
if (import.meta.env.DEV) return;
const env = locals?.runtime?.env as Env | undefined;
if (!env?.SENTRY_DSN) return;
// Staging (workers.dev) and CF previews share the DSN — keep their noise out
// of the quota, exactly like the analytics + middleware gates do.
if (ctx.request && !isApexHost(new URL(ctx.request.url).host)) return;
const { area, ...rest } = ctx;
const p = captureServerException(env, err, {
...rest,
tags: { ...(rest.tags ?? {}), ...(area ? { area } : {}) },
});
const wait = locals?.runtime?.ctx?.waitUntil?.bind(locals.runtime.ctx);
if (wait) wait(p);
} catch {
/* reporting must never break the request */
}
}