From 65ea798f4d7355ac8a92dd4d9be9cc7630adf30a Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Mon, 9 Dec 2024 14:17:18 +0100 Subject: [PATCH 01/14] feat: add simple error handling with artificial error for test --- package.json | 1 + .../[guildUrlName]/[pageUrlName]/page.tsx | 8 +- src/components/GenericError.tsx | 29 +++++++ src/components/ui/Button.tsx | 1 + src/lib/error.ts | 76 +++++++++++++++++++ src/lib/fetchGuildApi.ts | 9 ++- 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/components/GenericError.tsx create mode 100644 src/lib/error.ts diff --git a/package.json b/package.json index 9b2c388d12..557e68c4a9 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "react": "19.0.0-rc-66855b96-20241106", "react-canvas-confetti": "^2.0.7", "react-dom": "19.0.0-rc-66855b96-20241106", + "react-error-boundary": "^4.1.2", "react-hook-form": "^7.53.2", "react-markdown": "^9.0.1", "rehype-external-links": "^3.0.0", diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index e11b50a273..b9b4c0d69f 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { GenericError } from "@/components/GenericError"; import { RequirementDisplayComponent } from "@/components/requirements/RequirementDisplayComponent"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; @@ -11,6 +12,7 @@ import { Lock } from "@phosphor-icons/react/dist/ssr"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; const GuildPage = () => { const { pageUrlName, guildUrlName } = useParams<{ @@ -26,12 +28,14 @@ const GuildPage = () => { return (
- {roles.map((role) => ( + {[...roles, { id: "fake" }].map((role) => ( } key={role.id} > - + + + ))}
diff --git a/src/components/GenericError.tsx b/src/components/GenericError.tsx new file mode 100644 index 0000000000..37566fdff4 --- /dev/null +++ b/src/components/GenericError.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { CustomError } from "@/lib/error"; +import { useErrorBoundary } from "react-error-boundary"; +import { ZodError } from "zod"; +import { Button } from "./ui/Button"; +import { Card } from "./ui/Card"; + +export const GenericError = ({ error }: { error: CustomError | ZodError }) => { + const { resetBoundary } = useErrorBoundary(); + const message = + error instanceof ZodError ? error.issues.at(0)?.message : error.message; + + return ( + +
+

Something went wrong on our side

+

{message}

+ +
+
+ ); +}; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index b59343d5da..85ccca0a44 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -77,6 +77,7 @@ const Button = forwardRef( rightIcon, asChild = false, children, + type = "button", ...props }, ref, diff --git a/src/lib/error.ts b/src/lib/error.ts new file mode 100644 index 0000000000..e8abc6e811 --- /dev/null +++ b/src/lib/error.ts @@ -0,0 +1,76 @@ +export const promptRetryMessages = [ + "Please try refreshing or contact support if the issue persists.", + "Please follow the instructions provided or contact support for assistance.", +] as const; + +export type Either = Data; + +type Cause = { + code: string; + values: string; +}; + +type ConstructorProps = [ + message?: string, + options?: T & Partial<{ cause: Cause }>, +]; + +export class CustomError extends Error { + public readonly cause: Cause; + public readonly message: string; + public readonly name: string; + public readonly isExpected: boolean; + + constructor( + ...[message, options, internalOptions]: [ + ...ConstructorProps, + { isExpected: boolean }, + ] + ) { + super(message, { cause: options?.cause }); + this.name = this.constructor.name; + this.message = message || "An error occurred."; + this.cause = { code: this.name, values: "", ...options?.cause }; + this.isExpected = internalOptions.isExpected; + } + + toJSON() { + return { + name: this.name, + message: this.message, + cause: this.cause, + isExpected: this.isExpected, + }; + } +} + +export class NoSkeletonError extends CustomError { + constructor(...[message, options]: ConstructorProps) { + super(message || "Something went wrong while loading the page.", options, { + isExpected: false, + }); + } +} + +export class ValidationError extends CustomError { + constructor(...[message, options]: ConstructorProps) { + super(message || "There are issues with the provided data.", options, { + isExpected: true, + }); + } +} + +export class FetchError extends CustomError { + public readonly status: number; + public readonly statusText: string; + public readonly headers: Headers; + + constructor( + ...[message, options]: Required> + ) { + super(message, options, { isExpected: true }); + this.status = options.response.status; + this.statusText = options.response.statusText; + this.headers = options.response.headers; + } +} diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 1449d0a650..dab9d0994b 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,6 +1,7 @@ import { signOut } from "@/actions/auth"; import { tryGetToken } from "@/lib/token"; import { env } from "./env"; +import { ValidationError } from "./error"; import type { ErrorLike } from "./types"; type FetchResult = @@ -64,10 +65,14 @@ export const fetchGuildApi = async ( requestInit?: RequestInit, ): Promise> => { if (pathname.startsWith("/")) { - throw new Error(`"pathname" must not start with slash: ${pathname}`); + throw new ValidationError( + `"pathname" must not start with slash: ${pathname}`, + ); } if (pathname.endsWith("/")) { - throw new Error(`"pathname" must not end with slash: ${pathname}`); + throw new ValidationError( + `"pathname" must not end with slash: ${pathname}`, + ); } const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API); From 8382fc3b16a417c8518bc96f4880c54f18c45a3c Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Mon, 9 Dec 2024 18:24:59 +0100 Subject: [PATCH 02/14] feat: add sentry --- .gitignore | 3 + next.config.js | 48 ++++++++++++++ package.json | 1 + sentry.client.config.ts | 28 ++++++++ sentry.edge.config.ts | 16 +++++ sentry.server.config.ts | 15 +++++ src/app/api/sentry-example-api/route.ts | 10 +++ src/app/global-error.tsx | 25 +++++++ src/app/sentry-example-page/page.tsx | 86 +++++++++++++++++++++++++ src/instrumentation.ts | 13 ++++ 10 files changed, 245 insertions(+) create mode 100644 sentry.client.config.ts create mode 100644 sentry.edge.config.ts create mode 100644 sentry.server.config.ts create mode 100644 src/app/api/sentry-example-api/route.ts create mode 100644 src/app/global-error.tsx create mode 100644 src/app/sentry-example-page/page.tsx create mode 100644 src/instrumentation.ts diff --git a/.gitignore b/.gitignore index 1fffb8e724..cf77178438 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ bun.lockb /playwright/.cache/ /playwright/.auth/ /playwright/results + +# Sentry Config File +.env.sentry-build-plugin diff --git a/next.config.js b/next.config.js index 4a038a3d69..ed8ce749cc 100644 --- a/next.config.js +++ b/next.config.js @@ -140,3 +140,51 @@ const nextConfig = { } module.exports = nextConfig + + +// Injected content via Sentry wizard below + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig( + module.exports, + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: "zgen", + project: "javascript-nextjs", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Automatically annotate React components to show their full name in breadcrumbs and session replay + reactComponentAnnotation: { + enabled: true, + }, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + } +); diff --git a/package.json b/package.json index 557e68c4a9..d253472da8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@sentry/nextjs": "^8", "@t3-oss/env-nextjs": "^0.11.1", "@tailwindcss/typography": "^0.5.15", "@tanstack/react-query": "^5.62.2", diff --git a/sentry.client.config.ts b/sentry.client.config.ts new file mode 100644 index 0000000000..7cdfa2bb87 --- /dev/null +++ b/sentry.client.config.ts @@ -0,0 +1,28 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://eba4a78ff99c793accdeb809346d12de@o4508437633171456.ingest.de.sentry.io/4508437781151824", + + // Add optional integrations for additional features + integrations: [ + Sentry.replayIntegration(), + ], + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts new file mode 100644 index 0000000000..a2176bb4d2 --- /dev/null +++ b/sentry.edge.config.ts @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://eba4a78ff99c793accdeb809346d12de@o4508437633171456.ingest.de.sentry.io/4508437781151824", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/sentry.server.config.ts b/sentry.server.config.ts new file mode 100644 index 0000000000..c44636be62 --- /dev/null +++ b/sentry.server.config.ts @@ -0,0 +1,15 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://eba4a78ff99c793accdeb809346d12de@o4508437633171456.ingest.de.sentry.io/4508437781151824", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/src/app/api/sentry-example-api/route.ts b/src/app/api/sentry-example-api/route.ts new file mode 100644 index 0000000000..22fe60cf58 --- /dev/null +++ b/src/app/api/sentry-example-api/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +// A faulty API route to test Sentry's error monitoring +export function GET() { + throw new Error("Sentry Example API Route Error"); + // biome-ignore lint/correctness/noUnreachable: + return NextResponse.json({ data: "Testing Sentry Error..." }); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000000..ab74d041de --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/src/app/sentry-example-page/page.tsx b/src/app/sentry-example-page/page.tsx new file mode 100644 index 0000000000..b8f9422e35 --- /dev/null +++ b/src/app/sentry-example-page/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import Head from "next/head"; + +export default function Page() { + return ( +
+ + Sentry Onboarding + + + +
+

+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: */} + + + +

+ +

Get started by sending us a sample error:

+ + +

+ Next, look for the error on the{" "} + + Issues Page + + . +

+

+ For more information, see{" "} + + https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +

+
+
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000000..ecb65282ba --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("../sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("../sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; From 68d1329e55c14f8c0a35e8412f0b6ffc830906ba Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Mon, 9 Dec 2024 18:31:56 +0100 Subject: [PATCH 03/14] fix: ignore test error --- src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index b9b4c0d69f..4741751b0e 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -34,6 +34,7 @@ const GuildPage = () => { key={role.id} > + {/* @ts-ignore: intentional error placed for testing */} From 880c206c6c170eda69c13c89b8b2e813d94d4bd8 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Tue, 10 Dec 2024 20:23:44 +0100 Subject: [PATCH 04/14] feat: rework error inheritance, interface --- package.json | 1 + src/components/GenericError.tsx | 7 ++- src/lib/error.ts | 91 ++++++++++++++++----------------- src/lib/fetchGuildApi.ts | 12 ++--- 4 files changed, 55 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index d253472da8..b2dc1402ac 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@reflet/http": "^1.0.0", "@sentry/nextjs": "^8", "@t3-oss/env-nextjs": "^0.11.1", "@tailwindcss/typography": "^0.5.15", diff --git a/src/components/GenericError.tsx b/src/components/GenericError.tsx index 37566fdff4..434219bffa 100644 --- a/src/components/GenericError.tsx +++ b/src/components/GenericError.tsx @@ -1,12 +1,15 @@ "use client"; -import type { CustomError } from "@/lib/error"; +import { type CustomError, NoSkeletonError } from "@/lib/error"; import { useErrorBoundary } from "react-error-boundary"; +import type { Jsonify } from "type-fest"; import { ZodError } from "zod"; import { Button } from "./ui/Button"; import { Card } from "./ui/Card"; -export const GenericError = ({ error }: { error: CustomError | ZodError }) => { +export const GenericError = ({ error }: { error: Jsonify }) => { + const e = new NoSkeletonError(); + console.log(e.message); const { resetBoundary } = useErrorBoundary(); const message = error instanceof ZodError ? error.issues.at(0)?.message : error.message; diff --git a/src/lib/error.ts b/src/lib/error.ts index e8abc6e811..6b18b7f08c 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -1,37 +1,42 @@ -export const promptRetryMessages = [ - "Please try refreshing or contact support if the issue persists.", - "Please follow the instructions provided or contact support for assistance.", -] as const; +import type { PartialDeep, Primitive } from "type-fest"; -export type Either = Data; +//export const promptRetryMessages = [ +// "Please try refreshing or contact support if the issue persists.", +// "Please follow the instructions provided or contact support for assistance.", +//] as const; -type Cause = { - code: string; - values: string; -}; +/** + * Marker type for indicating if a function could throw an Error + * Note: There is no type enforcement that confirms this type's claim. + */ +export type Either = Data; -type ConstructorProps = [ - message?: string, - options?: T & Partial<{ cause: Cause }>, -]; +type Cause = [TemplateStringsArray, ...Primitive[]]; -export class CustomError extends Error { - public readonly cause: Cause; - public readonly message: string; +class CustomError extends Error { + public readonly cause: ReturnType; public readonly name: string; - public readonly isExpected: boolean; + public readonly displayMessage: string; + + public override get message() { + return [this.displayMessage, this.cause].filter(Boolean).join("\n\n"); + } + + public static expected(...args: [TemplateStringsArray, ...Primitive[]]) { + return args; + } constructor( - ...[message, options, internalOptions]: [ - ...ConstructorProps, - { isExpected: boolean }, - ] + props?: PartialDeep<{ + message: string; + cause: Cause; + }>, ) { - super(message, { cause: options?.cause }); + super(props?.message, { cause: props?.cause }); + this.name = this.constructor.name; - this.message = message || "An error occurred."; - this.cause = { code: this.name, values: "", ...options?.cause }; - this.isExpected = internalOptions.isExpected; + this.displayMessage = props?.message || this.defaultDisplayMessage; + this.cause = props?.cause ?? CustomError.expected``; // { code: this.name, values: {}, ...props?.cause }; } toJSON() { @@ -39,38 +44,28 @@ export class CustomError extends Error { name: this.name, message: this.message, cause: this.cause, - isExpected: this.isExpected, }; } -} -export class NoSkeletonError extends CustomError { - constructor(...[message, options]: ConstructorProps) { - super(message || "Something went wrong while loading the page.", options, { - isExpected: false, - }); + protected get defaultDisplayMessage() { + return "An error occurred."; } } -export class ValidationError extends CustomError { - constructor(...[message, options]: ConstructorProps) { - super(message || "There are issues with the provided data.", options, { - isExpected: true, - }); +export class NoSkeletonError extends CustomError { + protected override get defaultDisplayMessage() { + return "Something went wrong while loading the page."; } } -export class FetchError extends CustomError { - public readonly status: number; - public readonly statusText: string; - public readonly headers: Headers; +export class NotImplementedError extends CustomError {} - constructor( - ...[message, options]: Required> - ) { - super(message, options, { isExpected: true }); - this.status = options.response.status; - this.statusText = options.response.statusText; - this.headers = options.response.headers; +export class ValidationError extends CustomError { + protected override get defaultDisplayMessage() { + return "There are issues with the provided data."; } + + //constructor(...props: ConstructorParameters) { + // super(...props); + //} } diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index dab9d0994b..454129dc3f 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -65,14 +65,14 @@ export const fetchGuildApi = async ( requestInit?: RequestInit, ): Promise> => { if (pathname.startsWith("/")) { - throw new ValidationError( - `"pathname" must not start with slash: ${pathname}`, - ); + throw new ValidationError({ + cause: ValidationError.expected`${pathname} must not start with slash`, + }); } if (pathname.endsWith("/")) { - throw new ValidationError( - `"pathname" must not end with slash: ${pathname}`, - ); + throw new ValidationError({ + cause: ValidationError.expected`${pathname} must not end with slash`, + }); } const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API); From 0df21f0575e56a9bd2458fbbe56b45eb220ffedf Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Tue, 10 Dec 2024 21:48:22 +0100 Subject: [PATCH 05/14] feat: add better debug message parsing with names --- src/components/GenericError.tsx | 4 +-- src/lib/error.ts | 44 ++++++++++++++++++++++++--------- src/lib/fetchGuildApi.ts | 4 +-- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/components/GenericError.tsx b/src/components/GenericError.tsx index 434219bffa..00d015c8a5 100644 --- a/src/components/GenericError.tsx +++ b/src/components/GenericError.tsx @@ -1,6 +1,6 @@ "use client"; -import { type CustomError, NoSkeletonError } from "@/lib/error"; +import type { CustomError } from "@/lib/error"; import { useErrorBoundary } from "react-error-boundary"; import type { Jsonify } from "type-fest"; import { ZodError } from "zod"; @@ -8,8 +8,6 @@ import { Button } from "./ui/Button"; import { Card } from "./ui/Card"; export const GenericError = ({ error }: { error: Jsonify }) => { - const e = new NoSkeletonError(); - console.log(e.message); const { resetBoundary } = useErrorBoundary(); const message = error instanceof ZodError ? error.issues.at(0)?.message : error.message; diff --git a/src/lib/error.ts b/src/lib/error.ts index 6b18b7f08c..9a655be2e2 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -11,22 +11,42 @@ import type { PartialDeep, Primitive } from "type-fest"; */ export type Either = Data; -type Cause = [TemplateStringsArray, ...Primitive[]]; +type Cause = [TemplateStringsArray, ...Record[]]; -class CustomError extends Error { +export class CustomError extends Error { public readonly cause: ReturnType; public readonly name: string; - public readonly displayMessage: string; + public readonly display: string; public override get message() { - return [this.displayMessage, this.cause].filter(Boolean).join("\n\n"); + return [this.display, this.parsedErrorCause].filter(Boolean).join("\n\n"); } - public static expected(...args: [TemplateStringsArray, ...Primitive[]]) { + public static expected(...args: Cause) { return args; } - constructor( + private get parsedErrorCause() { + return `Expected ${this.interpolateErrorCause()}`; + } + + private interpolateErrorCause(delimiter = " and ") { + const [templateStringArray, ...props] = this.cause; + + return templateStringArray + .reduce((acc, val, i) => { + acc.push( + val, + ...Object.entries(props.at(i) ?? {}) + .map(([key, value]) => `"${key}" (${String(value)})`) + .join(delimiter), + ); + return acc; + }, []) + .join(""); + } + + public constructor( props?: PartialDeep<{ message: string; cause: Cause; @@ -35,11 +55,11 @@ class CustomError extends Error { super(props?.message, { cause: props?.cause }); this.name = this.constructor.name; - this.displayMessage = props?.message || this.defaultDisplayMessage; - this.cause = props?.cause ?? CustomError.expected``; // { code: this.name, values: {}, ...props?.cause }; + this.display = props?.message || this.defaultDisplay; + this.cause = props?.cause ?? CustomError.expected``; } - toJSON() { + public toJSON() { return { name: this.name, message: this.message, @@ -47,13 +67,13 @@ class CustomError extends Error { }; } - protected get defaultDisplayMessage() { + protected get defaultDisplay() { return "An error occurred."; } } export class NoSkeletonError extends CustomError { - protected override get defaultDisplayMessage() { + protected override get defaultDisplay() { return "Something went wrong while loading the page."; } } @@ -61,7 +81,7 @@ export class NoSkeletonError extends CustomError { export class NotImplementedError extends CustomError {} export class ValidationError extends CustomError { - protected override get defaultDisplayMessage() { + protected override get defaultDisplay() { return "There are issues with the provided data."; } diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 454129dc3f..2edbfc7d81 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -66,12 +66,12 @@ export const fetchGuildApi = async ( ): Promise> => { if (pathname.startsWith("/")) { throw new ValidationError({ - cause: ValidationError.expected`${pathname} must not start with slash`, + cause: ValidationError.expected`${{ pathname }} must not start with slash`, }); } if (pathname.endsWith("/")) { throw new ValidationError({ - cause: ValidationError.expected`${pathname} must not end with slash`, + cause: ValidationError.expected`${{ pathname }} must not end with slash`, }); } const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API); From 02bd15071e712fe4b17f651e63a459f4e536e1d0 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Tue, 10 Dec 2024 22:13:49 +0100 Subject: [PATCH 06/14] fix: override message from Error, avoid displaying empty cause --- src/lib/error.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/error.ts b/src/lib/error.ts index 9a655be2e2..545f8fd717 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -27,7 +27,8 @@ export class CustomError extends Error { } private get parsedErrorCause() { - return `Expected ${this.interpolateErrorCause()}`; + const interpolated = this.interpolateErrorCause(); + return interpolated ? `Expected ${interpolated}` : undefined; } private interpolateErrorCause(delimiter = " and ") { @@ -52,7 +53,7 @@ export class CustomError extends Error { cause: Cause; }>, ) { - super(props?.message, { cause: props?.cause }); + super(undefined, { cause: props?.cause }); this.name = this.constructor.name; this.display = props?.message || this.defaultDisplay; From 1b0e00181ea39b9a4a4a62a8c5f4dc5e13051191 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Tue, 10 Dec 2024 22:16:31 +0100 Subject: [PATCH 07/14] chore: add minimal docs and rename type --- src/lib/error.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/lib/error.ts b/src/lib/error.ts index 545f8fd717..c80caa9c89 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -11,18 +11,24 @@ import type { PartialDeep, Primitive } from "type-fest"; */ export type Either = Data; -type Cause = [TemplateStringsArray, ...Record[]]; +type ReasonParts = [TemplateStringsArray, ...Record[]]; +/** + * Serializable `Error` object custom errors derive from. + */ export class CustomError extends Error { - public readonly cause: ReturnType; + /** Error reason in raw format - used for debugging and error delegation */ + public readonly cause: ReasonParts; + /** Error identifier, indentical to class name */ public readonly name: string; + /** Human friendly message for end users */ public readonly display: string; - + /** Parsed final form of `display` and `cause` */ public override get message() { return [this.display, this.parsedErrorCause].filter(Boolean).join("\n\n"); } - public static expected(...args: Cause) { + public static expected(...args: ReasonParts) { return args; } @@ -33,7 +39,6 @@ export class CustomError extends Error { private interpolateErrorCause(delimiter = " and ") { const [templateStringArray, ...props] = this.cause; - return templateStringArray .reduce((acc, val, i) => { acc.push( @@ -50,7 +55,7 @@ export class CustomError extends Error { public constructor( props?: PartialDeep<{ message: string; - cause: Cause; + cause: ReasonParts; }>, ) { super(undefined, { cause: props?.cause }); @@ -73,20 +78,25 @@ export class CustomError extends Error { } } +/** + * If the page segment is rendered on server, there is no need for skeleton so it can be thrown indicating it's not meant to be on client. + * */ export class NoSkeletonError extends CustomError { protected override get defaultDisplay() { return "Something went wrong while loading the page."; } } +/** + * For functionality left out intentionally, that would only be relevant later. + * */ export class NotImplementedError extends CustomError {} +/** + * Error for custom validations, where `zod` isn't used. + * */ export class ValidationError extends CustomError { protected override get defaultDisplay() { return "There are issues with the provided data."; } - - //constructor(...props: ConstructorParameters) { - // super(...props); - //} } From 35ab04a4a74edd8220a36f1c90717a31d09c2485 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Tue, 10 Dec 2024 22:30:35 +0100 Subject: [PATCH 08/14] chore: have cause return the parsed version --- src/lib/error.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/error.ts b/src/lib/error.ts index c80caa9c89..d5bcf0463e 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -17,28 +17,28 @@ type ReasonParts = [TemplateStringsArray, ...Record[]]; * Serializable `Error` object custom errors derive from. */ export class CustomError extends Error { - /** Error reason in raw format - used for debugging and error delegation */ - public readonly cause: ReasonParts; /** Error identifier, indentical to class name */ public readonly name: string; /** Human friendly message for end users */ public readonly display: string; /** Parsed final form of `display` and `cause` */ public override get message() { - return [this.display, this.parsedErrorCause].filter(Boolean).join("\n\n"); + return [this.display, this.cause].filter(Boolean).join("\n\n"); } + private readonly causeRaw: ReasonParts; - public static expected(...args: ReasonParts) { - return args; - } - - private get parsedErrorCause() { + public override get cause() { const interpolated = this.interpolateErrorCause(); return interpolated ? `Expected ${interpolated}` : undefined; } + /** Tool for constructing the `cause` field */ + public static expected(...args: ReasonParts) { + return args; + } + private interpolateErrorCause(delimiter = " and ") { - const [templateStringArray, ...props] = this.cause; + const [templateStringArray, ...props] = this.causeRaw; return templateStringArray .reduce((acc, val, i) => { acc.push( @@ -58,11 +58,11 @@ export class CustomError extends Error { cause: ReasonParts; }>, ) { - super(undefined, { cause: props?.cause }); + super(); this.name = this.constructor.name; this.display = props?.message || this.defaultDisplay; - this.cause = props?.cause ?? CustomError.expected``; + this.causeRaw = props?.cause ?? CustomError.expected``; } public toJSON() { From cdbd025761e131f316423476a519879e7b1b3b5a Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Wed, 11 Dec 2024 00:05:34 +0100 Subject: [PATCH 09/14] feat: add more errors --- src/lib/error.ts | 29 ++++++++++++++++++++++++----- src/lib/fetchGuildApi.ts | 13 +++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/lib/error.ts b/src/lib/error.ts index d5bcf0463e..cd5b548acf 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -44,7 +44,7 @@ export class CustomError extends Error { acc.push( val, ...Object.entries(props.at(i) ?? {}) - .map(([key, value]) => `"${key}" (${String(value)})`) + .map(([key, value]) => `${key} \`${String(value)}\``) .join(delimiter), ); return acc; @@ -79,8 +79,8 @@ export class CustomError extends Error { } /** - * If the page segment is rendered on server, there is no need for skeleton so it can be thrown indicating it's not meant to be on client. - * */ + * Page segment is meant to be rendered on server, but was called on the client. + */ export class NoSkeletonError extends CustomError { protected override get defaultDisplay() { return "Something went wrong while loading the page."; @@ -89,14 +89,33 @@ export class NoSkeletonError extends CustomError { /** * For functionality left out intentionally, that would only be relevant later. - * */ + */ export class NotImplementedError extends CustomError {} /** * Error for custom validations, where `zod` isn't used. - * */ + */ export class ValidationError extends CustomError { protected override get defaultDisplay() { return "There are issues with the provided data."; } } + +/** + * Successful response came in during fetching, but the response could not be + * handled. + */ +export class FetchError extends CustomError { + protected override get defaultDisplay() { + return "Failed to retrieve data."; + } +} + +/** + * On parsing a response with zod that isn't supposed to fail. Note that this could also happen when requesting a similar but wrong endpoint. + */ +export class ResponseMismatchError extends CustomError { + protected override get defaultDisplay() { + return "Failed to retrieve data."; + } +} diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 2edbfc7d81..1002b4c5a9 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,7 +1,8 @@ import { signOut } from "@/actions/auth"; import { tryGetToken } from "@/lib/token"; +import { Status } from "@reflet/http"; import { env } from "./env"; -import { ValidationError } from "./error"; +import { FetchError, ValidationError } from "./error"; import type { ErrorLike } from "./types"; type FetchResult = @@ -96,13 +97,15 @@ export const fetchGuildApi = async ( headers, }); - if (response.status === 401) { + if (response.status === Status.Unauthorized) { signOut(); } const contentType = response.headers.get("content-type"); if (!contentType?.includes("application/json")) { - throw new Error("Guild API failed to respond with json"); + throw new FetchError({ + cause: FetchError.expected`JSON from Guild API response, instead received ${{ contentType }}`, + }); } logger.info({ response }, "\n", url.toString(), response.status); @@ -111,7 +114,9 @@ export const fetchGuildApi = async ( try { json = await response.json(); } catch { - throw new Error("Failed to parse json from response"); + throw new FetchError({ + cause: FetchError.expected`to parse JSON from response`, + }); } logger.info({ response }, json, "\n"); From c7473b496f38ef6778184ed45637f157a9013fc7 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Wed, 11 Dec 2024 00:11:16 +0100 Subject: [PATCH 10/14] chore: add typed http objects --- src/lib/fetchGuildApi.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 1002b4c5a9..8332b0de7c 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,6 +1,6 @@ import { signOut } from "@/actions/auth"; import { tryGetToken } from "@/lib/token"; -import { Status } from "@reflet/http"; +import { RequestHeader, ResponseHeader, Status } from "@reflet/http"; import { env } from "./env"; import { FetchError, ValidationError } from "./error"; import type { ErrorLike } from "./types"; @@ -83,13 +83,14 @@ export const fetchGuildApi = async ( } catch (_) {} const headers = new Headers(requestInit?.headers); + if (token) { headers.set("X-Auth-Token", token); } if (requestInit?.body instanceof FormData) { - headers.set("Content-Type", "multipart/form-data"); + headers.set(RequestHeader.ContentType, "multipart/form-data"); } else if (requestInit?.body) { - headers.set("Content-Type", "application/json"); + headers.set(RequestHeader.ContentType, "application/json"); } const response = await fetch(url, { @@ -101,7 +102,7 @@ export const fetchGuildApi = async ( signOut(); } - const contentType = response.headers.get("content-type"); + const contentType = response.headers.get(ResponseHeader.ContentType); if (!contentType?.includes("application/json")) { throw new FetchError({ cause: FetchError.expected`JSON from Guild API response, instead received ${{ contentType }}`, From b67e05e969bb002f4fff887eec533a3835f9d7b7 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Wed, 11 Dec 2024 15:58:00 +0100 Subject: [PATCH 11/14] feat: add recoverability to errors, more implementation examples --- next.config.js | 7 +-- .../[guildUrlName]/[pageUrlName]/page.tsx | 16 ++++++- src/components/ErrorPage.tsx | 2 +- src/components/GenericError.tsx | 44 +++++++++++++------ src/lib/error.ts | 37 +++++++++++++--- 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/next.config.js b/next.config.js index ed8ce749cc..3874acf19d 100644 --- a/next.config.js +++ b/next.config.js @@ -139,21 +139,18 @@ const nextConfig = { }, } -module.exports = nextConfig - - // Injected content via Sentry wizard below const { withSentryConfig } = require("@sentry/nextjs"); module.exports = withSentryConfig( - module.exports, + nextConfig, { // For all available options, see: // https://github.com/getsentry/sentry-webpack-plugin#options org: "zgen", - project: "javascript-nextjs", + project: "guildxyz", // Only print logs for uploading source maps in CI silent: !process.env.CI, diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index 4741751b0e..9b6a1aa843 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { ScrollArea } from "@/components/ui/ScrollArea"; import { Skeleton } from "@/components/ui/Skeleton"; +import { CustomError, FetchError } from "@/lib/error"; import { rewardBatchOptions, roleBatchOptions } from "@/lib/options"; import type { Schemas } from "@guildxyz/types"; import { Lock } from "@phosphor-icons/react/dist/ssr"; @@ -44,6 +45,14 @@ const GuildPage = () => { }; const RoleCard = ({ role }: { role: Schemas["Role"] }) => { + const blacklistedRoleName = "Member"; + if (role.name === blacklistedRoleName) { + throw new FetchError({ + recoverable: true, + message: `Failed to show ${role.name} role`, + cause: FetchError.expected`${{ roleName: role.name }} to not match ${{ blacklistedRoleName }}`, + }); + } const { data: rewards } = useSuspenseQuery( rewardBatchOptions({ roleId: role.id }), ); @@ -68,7 +77,9 @@ const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
{rewards.map((reward) => ( - + + + ))}
@@ -101,6 +112,9 @@ const RoleCard = ({ role }: { role: Schemas["Role"] }) => { }; const Reward = ({ reward }: { reward: Schemas["Reward"] }) => { + if (reward.name === "Admin - update") { + throw new CustomError(); + } return (
{reward.name}
diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx index 332ca88646..c9bf9aa4a4 100644 --- a/src/components/ErrorPage.tsx +++ b/src/components/ErrorPage.tsx @@ -50,7 +50,7 @@ const ErrorPage = ({ className="font-black text-[clamp(128px,32vw,360px)] text-foreground leading-none tracking-tight opacity-20" aria-hidden > - {errorCode} + {errorCode.slice(0, 3)}
diff --git a/src/components/GenericError.tsx b/src/components/GenericError.tsx index 00d015c8a5..72c494e91d 100644 --- a/src/components/GenericError.tsx +++ b/src/components/GenericError.tsx @@ -1,29 +1,45 @@ "use client"; -import type { CustomError } from "@/lib/error"; +import { type CustomError, ValidationError } from "@/lib/error"; import { useErrorBoundary } from "react-error-boundary"; -import type { Jsonify } from "type-fest"; +import Markdown from "react-markdown"; import { ZodError } from "zod"; import { Button } from "./ui/Button"; import { Card } from "./ui/Card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/Collapsible"; -export const GenericError = ({ error }: { error: Jsonify }) => { +export const GenericError = ({ error }: { error: CustomError | ZodError }) => { const { resetBoundary } = useErrorBoundary(); - const message = - error instanceof ZodError ? error.issues.at(0)?.message : error.message; + const convergedError = + error instanceof ZodError ? ValidationError.fromZodError(error) : error; return (
-

Something went wrong on our side

-

{message}

- +

{convergedError.display}

+ {convergedError.cause && ( + + + Read more about what went wrong + + + {convergedError.cause} + + + )} + {error.recoverable && ( + + )}
); diff --git a/src/lib/error.ts b/src/lib/error.ts index cd5b548acf..08a41fad51 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -1,4 +1,5 @@ import type { PartialDeep, Primitive } from "type-fest"; +import type { ZodError } from "zod"; //export const promptRetryMessages = [ // "Please try refreshing or contact support if the issue persists.", @@ -11,7 +12,7 @@ import type { PartialDeep, Primitive } from "type-fest"; */ export type Either = Data; -type ReasonParts = [TemplateStringsArray, ...Record[]]; +type ReasonParts = [ArrayLike, ...Record[]]; /** * Serializable `Error` object custom errors derive from. @@ -25,7 +26,9 @@ export class CustomError extends Error { public override get message() { return [this.display, this.cause].filter(Boolean).join("\n\n"); } - private readonly causeRaw: ReasonParts; + public causeRaw: ReasonParts; + + public recoverable: boolean; public override get cause() { const interpolated = this.interpolateErrorCause(); @@ -37,14 +40,14 @@ export class CustomError extends Error { return args; } - private interpolateErrorCause(delimiter = " and ") { + private interpolateErrorCause(delimiter = ", ") { const [templateStringArray, ...props] = this.causeRaw; - return templateStringArray + return Array.from(templateStringArray) .reduce((acc, val, i) => { acc.push( val, ...Object.entries(props.at(i) ?? {}) - .map(([key, value]) => `${key} \`${String(value)}\``) + .map(([key, value]) => `**${key}** \`${String(value)}\``) .join(delimiter), ); return acc; @@ -56,6 +59,7 @@ export class CustomError extends Error { props?: PartialDeep<{ message: string; cause: ReasonParts; + recoverable: boolean; }>, ) { super(); @@ -63,6 +67,7 @@ export class CustomError extends Error { this.name = this.constructor.name; this.display = props?.message || this.defaultDisplay; this.causeRaw = props?.cause ?? CustomError.expected``; + this.recoverable = props?.recoverable || false; } public toJSON() { @@ -70,6 +75,7 @@ export class CustomError extends Error { name: this.name, message: this.message, cause: this.cause, + display: this.display, }; } @@ -99,6 +105,27 @@ export class ValidationError extends CustomError { protected override get defaultDisplay() { return "There are issues with the provided data."; } + + public static fromZodError(error: ZodError): ValidationError { + const result = new ValidationError(); + const parsedIssues = error.issues.flatMap((issue) => { + const path = issue.path.join(" -> "); + const { message, code } = issue; + return Object.entries({ code, path, message }).map((entry) => + Object.fromEntries([entry]), + ); + }); + + result.causeRaw = [ + [ + "Zod validation to pass, but failed at: \n", + ...parsedIssues.slice(2).flatMap(() => [" occured at ", " with ", "."]), + ], + ...parsedIssues, + ]; + + return result; + } } /** From 468f2da19c75dcfd0b37fba65e9a5e3b504009e9 Mon Sep 17 00:00:00 2001 From: Dominik Stumpf Date: Wed, 11 Dec 2024 16:20:56 +0100 Subject: [PATCH 12/14] fix: adjust build steps, address type errors --- next.config.js | 2 ++ src/components/GenericError.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 3874acf19d..1d55f0130f 100644 --- a/next.config.js +++ b/next.config.js @@ -183,5 +183,7 @@ module.exports = withSentryConfig( // https://docs.sentry.io/product/crons/ // https://vercel.com/docs/cron-jobs automaticVercelMonitors: true, + + sourcemaps: { deleteSourcemapsAfterUpload: true }, } ); diff --git a/src/components/GenericError.tsx b/src/components/GenericError.tsx index 72c494e91d..5afb9cd83f 100644 --- a/src/components/GenericError.tsx +++ b/src/components/GenericError.tsx @@ -31,7 +31,7 @@ export const GenericError = ({ error }: { error: CustomError | ZodError }) => { )} - {error.recoverable && ( + {convergedError.recoverable && (