Skip to content

Commit 46a9c7c

Browse files
committed
feat(monitoring): replace webhook alert with Sentry error tracking via HTTP store API
1 parent 6b985fd commit 46a9c7c

File tree

3 files changed

+90
-39
lines changed

3 files changed

+90
-39
lines changed

src/app.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ declare global {
1515
GOOGLE_CLIENT_SECRET: string;
1616
JWT_SECRET: string;
1717
APP_URL: string;
18-
/** Optional. Webhook URL for health alerts (Discord/Slack/etc). */
19-
ALERT_WEBHOOK_URL?: string;
18+
/** Optional. Sentry DSN for error alerting. */
19+
SENTRY_DSN?: string;
2020
};
2121
}
2222
// Cloudflare Workers Cron Trigger handler signature.

src/hooks.server.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
1-
import type { Handle } from '@sveltejs/kit';
1+
import type { Handle, HandleServerError } from '@sveltejs/kit';
22
import { runHealthChecks } from '$lib/server/monitor';
3+
4+
// Parse DSN into the store endpoint and auth header Sentry expects.
5+
// DSN format: https://<key>@<host>/<project_id>
6+
function parseDsn(dsn: string): { url: string; key: string } | null {
7+
try {
8+
const u = new URL(dsn);
9+
const key = u.username;
10+
const projectId = u.pathname.replace('/', '');
11+
const host = u.host;
12+
return { url: `https://${host}/api/${projectId}/store/`, key };
13+
} catch {
14+
return null;
15+
}
16+
}
17+
18+
async function captureToSentry(
19+
dsn: string,
20+
payload: { message?: string; exception?: unknown; level: string; request?: unknown }
21+
): Promise<void> {
22+
const parsed = parseDsn(dsn);
23+
if (!parsed) return;
24+
const event = {
25+
timestamp: new Date().toISOString(),
26+
platform: 'javascript',
27+
level: payload.level,
28+
...(payload.message ? { message: payload.message } : {}),
29+
...(payload.exception ? { exception: payload.exception } : {}),
30+
...(payload.request ? { request: payload.request } : {}),
31+
};
32+
await fetch(parsed.url, {
33+
method: 'POST',
34+
headers: {
35+
'Content-Type': 'application/json',
36+
'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${parsed.key}, sentry_client=openboot/1.0`,
37+
},
38+
body: JSON.stringify(event),
39+
signal: AbortSignal.timeout(5_000),
40+
}).catch(() => {}); // fire-and-forget, never block the response
41+
}
342
import { RESERVED_ALIASES } from '$lib/server/validation';
443
import { getConfigForHookAlias, getConfigForInstall, getConfigForHookSlug } from '$lib/server/db';
544
import { serveInstallByAlias, serveInstallBySlug } from '$lib/server/alias';
@@ -49,6 +88,19 @@ function isVersionOlderThan(version: string, minVersion: string): boolean {
4988
return aMaj < bMaj || (aMaj === bMaj && (aMin < bMin || (aMin === bMin && aPat < bPat)));
5089
}
5190

91+
export const handleError: HandleServerError = async ({ error, event }) => {
92+
const dsn = event.platform?.env?.SENTRY_DSN;
93+
if (dsn) {
94+
await captureToSentry(dsn, {
95+
level: 'error',
96+
exception: {
97+
values: [{ type: 'Error', value: error instanceof Error ? error.message : String(error) }],
98+
},
99+
request: { url: event.url.href, method: event.request.method },
100+
});
101+
}
102+
};
103+
52104
export const handle: Handle = async ({ event, resolve }) => {
53105
const path = event.url.pathname;
54106

@@ -106,6 +158,16 @@ export const handle: Handle = async ({ event, resolve }) => {
106158
}
107159

108160
const response = await resolve(event);
161+
162+
const dsn = event.platform?.env?.SENTRY_DSN;
163+
if (response.status >= 500 && dsn) {
164+
await captureToSentry(dsn, {
165+
level: 'error',
166+
message: `HTTP ${response.status} ${event.request.method} ${event.url.pathname}`,
167+
request: { url: event.url.href, method: event.request.method },
168+
});
169+
}
170+
109171
const securedResponse = withSecurityHeaders(response);
110172

111173
// Version negotiation: if CLI sends X-OpenBoot-Version, check compatibility.
@@ -140,9 +202,21 @@ export const handle: Handle = async ({ event, resolve }) => {
140202
// Cloudflare Workers Cron Trigger — runs on the schedule defined in wrangler.toml.
141203
// Checks critical production signals and sends an alert to ALERT_WEBHOOK_URL if any fail.
142204
export const scheduled: App.Scheduled = async ({ platform }) => {
143-
await runHealthChecks({
144-
APP_URL: platform?.env?.APP_URL ?? 'https://openboot.dev',
145-
ALERT_WEBHOOK_URL: platform?.env?.ALERT_WEBHOOK_URL,
146-
});
205+
const dsn = platform?.env?.SENTRY_DSN;
206+
try {
207+
await runHealthChecks({
208+
APP_URL: platform?.env?.APP_URL ?? 'https://openboot.dev',
209+
});
210+
} catch (err) {
211+
console.error('[monitor] cron failed:', err);
212+
if (dsn) {
213+
await captureToSentry(dsn, {
214+
level: 'error',
215+
exception: {
216+
values: [{ type: 'HealthCheckError', value: err instanceof Error ? err.message : String(err) }],
217+
},
218+
});
219+
}
220+
}
147221
};
148222

src/lib/server/monitor.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
/**
22
* Production health monitoring for Cloudflare Workers cron trigger.
33
*
4-
* Checks three critical signals on every run:
4+
* Checks two critical signals on every run:
55
* 1. /api/packages returns a non-empty array
6-
* 2. /api/health returns status "healthy"
7-
* 3. DB is reachable (via health endpoint)
6+
* 2. /api/health returns status "healthy" (DB reachable)
87
*
9-
* If any check fails, posts a JSON alert to ALERT_WEBHOOK_URL (if set).
10-
* Works with Discord, Slack, PagerDuty, or any webhook receiver.
8+
* Results are logged to Workers dashboard. Runtime errors are captured
9+
* via Sentry (configured in hooks.server.ts).
1110
*/
1211

1312
interface CheckResult {
@@ -18,7 +17,6 @@ interface CheckResult {
1817

1918
interface MonitorEnv {
2019
APP_URL: string;
21-
ALERT_WEBHOOK_URL?: string;
2220
}
2321

2422
async function checkPackages(appUrl: string): Promise<CheckResult> {
@@ -57,42 +55,21 @@ async function checkHealth(appUrl: string): Promise<CheckResult> {
5755
}
5856
}
5957

60-
async function sendAlert(webhookUrl: string, failures: CheckResult[]): Promise<void> {
61-
const lines = failures.map((f) => `• **${f.name}**: ${f.detail}`).join('\n');
62-
const payload = {
63-
// Discord-compatible format; Slack ignores unknown fields
64-
content: `🚨 **openboot.dev health alert** — ${failures.length} check(s) failed:\n${lines}`,
65-
// Slack-compatible fallback
66-
text: `openboot.dev health alert — ${failures.length} check(s) failed:\n${failures.map((f) => `• ${f.name}: ${f.detail}`).join('\n')}`,
67-
};
68-
await fetch(webhookUrl, {
69-
method: 'POST',
70-
headers: { 'Content-Type': 'application/json' },
71-
body: JSON.stringify(payload),
72-
signal: AbortSignal.timeout(10_000),
73-
});
74-
}
75-
7658
export async function runHealthChecks(env: MonitorEnv): Promise<void> {
7759
const appUrl = env.APP_URL ?? 'https://openboot.dev';
7860

7961
const results = await Promise.all([checkPackages(appUrl), checkHealth(appUrl)]);
8062

81-
const failures = results.filter((r) => !r.ok);
82-
83-
if (failures.length > 0 && env.ALERT_WEBHOOK_URL) {
84-
await sendAlert(env.ALERT_WEBHOOK_URL, failures);
85-
}
86-
87-
// Always log — visible in Workers dashboard → Logs
8863
for (const r of results) {
8964
const icon = r.ok ? '✓' : '✗';
9065
console.log(`[monitor] ${icon} ${r.name}: ${r.detail}`);
9166
}
9267

68+
const failures = results.filter((r) => !r.ok);
9369
if (failures.length > 0) {
94-
// Non-fatal: don't throw, just log. Workers cron retries on exceptions
95-
// which could flood alerts. Log and return instead.
96-
console.error(`[monitor] ${failures.length} check(s) failed`);
70+
// Throw so Sentry (via handleError / uncaught exception) captures the alert.
71+
throw new Error(
72+
`Health check failed: ${failures.map((f) => `${f.name}=${f.detail}`).join(', ')}`
73+
);
9774
}
9875
}

0 commit comments

Comments
 (0)