Skip to content

Commit 2d79bae

Browse files
committed
fix(kit): classify convex errors and asset 404s
1 parent 93b0ef2 commit 2d79bae

7 files changed

Lines changed: 228 additions & 43 deletions

File tree

packages/kit/scripts/smoke-server.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ probe() {
8686
probe "/health" "200"
8787
probe "/" "200"
8888
probe "/api/v1" "200"
89+
probe "/intu/project/intu/apikeys" "200"
90+
probe "/assets/missing-build-asset.js" "404"
91+
probe "/missing-static-doc.json" "404"
8992

9093
if [[ "$fail" -ne 0 ]]; then
9194
echo "---- server log ----" >&2

packages/kit/server/server.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
66
import path from "node:path";
77

88
import { apiRoutes } from "./api/v1/routes";
9+
import { shouldReturnNotFoundForMissingStaticPath } from "./staticPaths";
910
import { parsePort } from "./utils/env";
1011

1112
const app = new Hono();
@@ -27,21 +28,15 @@ const STATIC_ROOT = process.env.STATIC_ROOT ?? "./dist";
2728
// Serve the built SPA (hashed assets, favicons, llms.txt, etc.).
2829
app.use("/*", serveStatic({ root: STATIC_ROOT }));
2930

30-
// If a *root-level* static-document URL (`/llms.txt`, `/robots.txt`,
31-
// `/manifest.json`, …) reaches this point, `serveStatic` already
31+
// If a static-looking URL reaches this point, `serveStatic` already
3232
// couldn't match it, so return 404 instead of falling through to the
33-
// SPA shell. Otherwise a typo at `/llm.txt` would render `index.html`,
34-
// React Router's `:orgSlug` route would match the typo as a slug, and
35-
// the user (or a bot) would see "Organization not found" with a 200
36-
// status code — wrong for humans and doubly wrong for crawlers.
37-
//
38-
// Scope is deliberately narrow: only single-segment paths at the
39-
// site root. Hashed build assets under `/assets/*.json` (i18n, chunks)
40-
// and nested paths still fall through to the SPA handler below.
41-
const ROOT_STATIC_DOC =
42-
/^\/[a-z0-9._-]+\.(txt|xml|json|pdf|csv|yaml|yml|md|webmanifest)$/i;
33+
// SPA shell. This covers root documents (`/llms.txt`), favicons, and
34+
// hashed Vite assets (`/assets/index-*.js`). Falling back to
35+
// `index.html` for a missing asset gives browsers the wrong MIME type
36+
// and makes Sentry source-context fetches display the HTML shell as
37+
// the suspect source.
4338
app.get("*", async (c, next) => {
44-
if (ROOT_STATIC_DOC.test(c.req.path)) {
39+
if (shouldReturnNotFoundForMissingStaticPath(c.req.path)) {
4540
return c.notFound();
4641
}
4742
return next();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { shouldReturnNotFoundForMissingStaticPath } from "./staticPaths";
4+
5+
describe("shouldReturnNotFoundForMissingStaticPath", () => {
6+
test("treats missing build assets as static 404s", () => {
7+
expect(
8+
shouldReturnNotFoundForMissingStaticPath("/assets/index-QW95BJ-u.js"),
9+
).toBe(true);
10+
expect(
11+
shouldReturnNotFoundForMissingStaticPath(
12+
"/assets/index-QW95BJ-u.js.map?debug=1",
13+
),
14+
).toBe(true);
15+
});
16+
17+
test("treats missing root static documents and images as static 404s", () => {
18+
expect(shouldReturnNotFoundForMissingStaticPath("/manifest.json")).toBe(
19+
true,
20+
);
21+
expect(
22+
shouldReturnNotFoundForMissingStaticPath("/apple-touch-icon.webp"),
23+
).toBe(true);
24+
});
25+
26+
test("allows SPA routes to fall through to React Router", () => {
27+
expect(shouldReturnNotFoundForMissingStaticPath("/")).toBe(false);
28+
expect(
29+
shouldReturnNotFoundForMissingStaticPath("/intu/project/intu/apikeys"),
30+
).toBe(false);
31+
expect(
32+
shouldReturnNotFoundForMissingStaticPath("/docs/verification-apple"),
33+
).toBe(false);
34+
});
35+
});

packages/kit/server/staticPaths.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const STATIC_FILE_EXTENSION =
2+
/\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|md|mjs|otf|pdf|png|svg|ttf|txt|webmanifest|webp|woff2?|ya?ml|xml)$/i;
3+
4+
const ASSET_PATH = /^\/assets(?:\/|$)/i;
5+
6+
export function shouldReturnNotFoundForMissingStaticPath(
7+
requestPath: string,
8+
): boolean {
9+
const pathOnly = requestPath.split("?")[0] ?? "";
10+
return ASSET_PATH.test(pathOnly) || STATIC_FILE_EXTENSION.test(pathOnly);
11+
}

packages/kit/src/lib/sentry.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as Sentry from "@sentry/react";
22

3+
import { applySentryEventFilters } from "./sentryFilters";
4+
35
// Client-side Sentry init for the SPA. Kept separate from the server
46
// init at `server/sentry.ts` — both point at distinct Sentry projects
57
// (openiap-kit-node vs openiap-kit-react) so server bugs and UI bugs
@@ -48,36 +50,7 @@ if (typeof dsn === "string" && dsn.length > 0 && onAllowedHost) {
4850
// and floods Sentry as `TypeError: Load failed` noise that drowns
4951
// out real bugs. Tag those events so triage can filter them out
5052
// (or downsample) instead of treating each as a fresh signal.
51-
beforeSend: (event, hint) => {
52-
const exception = hint?.originalException;
53-
// Sentry events occasionally arrive with no `originalException`
54-
// (programmatic captureMessage, late-bound rejections that
55-
// lost their cause), only an `event.message` /
56-
// `logentry.message`. Build the classifier input from every
57-
// available source so reconnect noise gets tagged regardless
58-
// of where the message lives (CodeRabbit review on PR #127).
59-
const message = [
60-
exception instanceof Error
61-
? exception.message
62-
: typeof exception === "string"
63-
? exception
64-
: "",
65-
event.message ?? "",
66-
event.logentry?.message ?? "",
67-
]
68-
.filter(Boolean)
69-
.join(" ");
70-
const isFetchLoadFailed =
71-
/Load failed|Failed to fetch|NetworkError/i.test(message);
72-
const looksConvex = /convex\.cloud|\/api\/(action|query|mutation)/i.test(
73-
message + " " + (event.request?.url ?? ""),
74-
);
75-
if (isFetchLoadFailed && looksConvex) {
76-
event.tags = { ...(event.tags ?? {}), source: "convex-reconnect" };
77-
event.fingerprint = ["convex-reconnect-load-failed"];
78-
}
79-
return event;
80-
},
53+
beforeSend: applySentryEventFilters,
8154
});
8255
}
8356

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Event } from "@sentry/react";
2+
import { describe, expect, test } from "vitest";
3+
4+
import { applySentryEventFilters } from "./sentryFilters";
5+
6+
describe("applySentryEventFilters", () => {
7+
test("keeps Convex reconnect failures on a stable fingerprint", () => {
8+
const event: Event = {
9+
breadcrumbs: [
10+
{
11+
category: "fetch",
12+
data: {
13+
method: "POST",
14+
url: "https://healthy-kudu-836.convex.cloud/api/action",
15+
},
16+
},
17+
],
18+
request: { url: "https://kit.openiap.dev/intu/project/intu/apikeys" },
19+
};
20+
21+
const result = applySentryEventFilters(event, {
22+
originalException: new TypeError("Failed to fetch"),
23+
});
24+
25+
expect(result?.tags?.source).toBe("convex-reconnect");
26+
expect(result?.fingerprint).toEqual(["convex-reconnect-load-failed"]);
27+
});
28+
29+
test("fingerprints generic Convex action server errors", () => {
30+
const event: Event = {
31+
breadcrumbs: [
32+
{
33+
category: "fetch",
34+
data: {
35+
method: "POST",
36+
status_code: 200,
37+
url: "https://healthy-kudu-836.convex.cloud/api/action",
38+
},
39+
},
40+
],
41+
request: { url: "https://kit.openiap.dev/intu/project/intu/apikeys" },
42+
};
43+
44+
const result = applySentryEventFilters(event, {
45+
originalException: new Error(
46+
"[Request ID: 79b077815fe97b2b] Server Error",
47+
),
48+
});
49+
50+
expect(result?.tags?.source).toBe("convex-action-server-error");
51+
expect(result?.fingerprint).toEqual(["convex-action-server-error"]);
52+
});
53+
54+
test("does not rewrite unrelated server errors", () => {
55+
const event: Event = {
56+
breadcrumbs: [
57+
{
58+
category: "fetch",
59+
data: {
60+
method: "POST",
61+
url: "https://api.example.test/action",
62+
},
63+
},
64+
],
65+
request: { url: "https://kit.openiap.dev/intu/project/intu/apikeys" },
66+
};
67+
68+
const result = applySentryEventFilters(event, {
69+
originalException: new Error("[Request ID: abc123] Server Error"),
70+
});
71+
72+
expect(result?.tags?.source).toBeUndefined();
73+
expect(result?.fingerprint).toBeUndefined();
74+
});
75+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { Event, EventHint } from "@sentry/react";
2+
3+
function messageFromOriginalException(exception: unknown): string {
4+
if (exception instanceof Error) {
5+
return exception.message;
6+
}
7+
if (typeof exception === "string") {
8+
return exception;
9+
}
10+
return "";
11+
}
12+
13+
function stringFromUnknown(value: unknown): string {
14+
if (typeof value === "string") {
15+
return value;
16+
}
17+
if (typeof value === "number" || typeof value === "boolean") {
18+
return String(value);
19+
}
20+
return "";
21+
}
22+
23+
function breadcrumbText(event: Event): string {
24+
return (
25+
event.breadcrumbs
26+
?.map((breadcrumb) =>
27+
[
28+
breadcrumb.category,
29+
breadcrumb.message,
30+
stringFromUnknown(breadcrumb.data?.url),
31+
stringFromUnknown(breadcrumb.data?.method),
32+
stringFromUnknown(breadcrumb.data?.status_code),
33+
]
34+
.filter(Boolean)
35+
.join(" "),
36+
)
37+
.filter(Boolean)
38+
.join(" ") ?? ""
39+
);
40+
}
41+
42+
function eventMessage(event: Event, hint?: EventHint): string {
43+
const exception = hint?.originalException;
44+
return [
45+
messageFromOriginalException(exception),
46+
event.message ?? "",
47+
event.logentry?.message ?? "",
48+
]
49+
.filter(Boolean)
50+
.join(" ");
51+
}
52+
53+
function hasConvexActionBreadcrumb(event: Event): boolean {
54+
return /convex\.cloud\/api\/action|\/api\/action/i.test(
55+
breadcrumbText(event),
56+
);
57+
}
58+
59+
export function applySentryEventFilters<TEvent extends Event>(
60+
event: TEvent,
61+
hint?: EventHint,
62+
): TEvent | null {
63+
const message = eventMessage(event, hint);
64+
const searchText = [
65+
message,
66+
event.request?.url ?? "",
67+
breadcrumbText(event),
68+
].join(" ");
69+
70+
const isFetchLoadFailed = /Load failed|Failed to fetch|NetworkError/i.test(
71+
message,
72+
);
73+
const looksConvex = /convex\.cloud|\/api\/(action|query|mutation)/i.test(
74+
searchText,
75+
);
76+
77+
if (isFetchLoadFailed && looksConvex) {
78+
event.tags = { ...(event.tags ?? {}), source: "convex-reconnect" };
79+
event.fingerprint = ["convex-reconnect-load-failed"];
80+
}
81+
82+
const isGenericConvexServerError =
83+
/^\[Request ID: [a-z0-9]+\] Server Error$/i.test(message.trim());
84+
if (isGenericConvexServerError && hasConvexActionBreadcrumb(event)) {
85+
event.tags = {
86+
...(event.tags ?? {}),
87+
source: "convex-action-server-error",
88+
};
89+
event.fingerprint = ["convex-action-server-error"];
90+
}
91+
92+
return event;
93+
}

0 commit comments

Comments
 (0)