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..1d55f0130f 100644 --- a/next.config.js +++ b/next.config.js @@ -139,4 +139,51 @@ const nextConfig = { }, } -module.exports = nextConfig +// Injected content via Sentry wizard below + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig( + nextConfig, + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: "zgen", + project: "guildxyz", + + // 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, + + sourcemaps: { deleteSourcemapsAfterUpload: true }, + } +); diff --git a/package.json b/package.json index 9b2c388d12..b2dc1402ac 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "@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", "@tanstack/react-query": "^5.62.2", @@ -48,6 +50,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/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/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index e11b50a273..9b6a1aa843 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -1,16 +1,19 @@ "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"; 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"; 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 +29,15 @@ const GuildPage = () => { return (
- {roles.map((role) => ( + {[...roles, { id: "fake" }].map((role) => ( } key={role.id} > - + + {/* @ts-ignore: intentional error placed for testing */} + + ))}
@@ -39,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 }), ); @@ -63,7 +77,9 @@ const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
{rewards.map((reward) => ( - + + + ))}
@@ -96,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/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx index a02183106e..b01405616a 100644 --- a/src/app/(dashboard)/[guildUrlName]/layout.tsx +++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx @@ -9,7 +9,9 @@ import { userOptions, } from "@/lib/options"; import type { DynamicRoute } from "@/lib/types"; +import { Status } from "@reflet/http"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { notFound } from "next/navigation"; import { type PropsWithChildren, Suspense } from "react"; import { GuildTabs, GuildTabsSkeleton } from "./components/GuildTabs"; import { JoinButton } from "./components/JoinButton"; @@ -65,8 +67,11 @@ const GuildLayout = async ({ }).queryKey, ); - if (guild?.error || !guild?.data) { - throw new Error(`Failed to fetch guild ${guild?.error?.status || ""}`); + if (guild?.error?.partialResponse.status === Status.NotFound) { + notFound(); + } + if (!guild?.data) { + throw new Error("Failed to fetch guild"); } return ( diff --git a/src/app/(dashboard)/explorer/components/InfiniteScrollGuilds.tsx b/src/app/(dashboard)/explorer/components/InfiniteScrollGuilds.tsx index 4078a7b89d..f64d1e7844 100644 --- a/src/app/(dashboard)/explorer/components/InfiniteScrollGuilds.tsx +++ b/src/app/(dashboard)/explorer/components/InfiniteScrollGuilds.tsx @@ -51,7 +51,7 @@ export const InfiniteScrollGuilds = () => { ? // biome-ignore lint: it's safe to use index as key in this case [...Array(PAGE_SIZE)].map((_, i) => ) : guilds.map((guild, _i) => ( - + ))}
+ return NextResponse.json({ data: "Testing Sentry Error..." }); +} diff --git a/src/app/error.tsx b/src/app/error.tsx index 142f8237b5..8c2e2f11d5 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -7,7 +7,8 @@ const ErrorBoundary = ({ }: { error: Error & { digest?: string; statusCode?: string }; }) => { - console.log(error.cause); + if (error.cause) console.log(error.cause); + return ( { + 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/not-found.tsx b/src/app/not-found.tsx index 71e433da4e..0a7d57a6db 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,7 +1,7 @@ import "server-only"; import { ErrorPage } from "@/components/ErrorPage"; -export default function NotFound() { +const NotFound = () => { return ( ); -} +}; + +export default NotFound; 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/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 new file mode 100644 index 0000000000..139eabf7c3 --- /dev/null +++ b/src/components/GenericError.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { type CustomError, ValidationError } from "@/lib/error"; +import { useErrorBoundary } from "react-error-boundary"; +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: CustomError | ZodError }) => { + const { resetBoundary } = useErrorBoundary(); + const convergedError = + error instanceof ZodError ? ValidationError.fromZodError(error) : error; + + return ( + +
+

{convergedError.display}

+ {convergedError.cause && ( + + + Read more about what went wrong + + + + {convergedError.cause} + + + + )} + {convergedError.recoverable && ( + + )} +
+
+ ); +}; 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/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; diff --git a/src/lib/error.ts b/src/lib/error.ts new file mode 100644 index 0000000000..f631575c55 --- /dev/null +++ b/src/lib/error.ts @@ -0,0 +1,152 @@ +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.", +// "Please follow the instructions provided or contact support for assistance.", +//] as const; + +/** + * 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 ReasonParts = [ArrayLike, ...Record[]]; + +/** + * Serializable `Error` object custom errors derive from. + */ +export class CustomError extends Error { + /** 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.cause].filter(Boolean).join("\n\n"); + } + public causeRaw: ReasonParts; + + public recoverable: boolean; + + 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 = ", ") { + const [templateStringArray, ...props] = this.causeRaw; + return Array.from(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: ReasonParts; + recoverable: boolean; + }>, + ) { + super(); + + this.name = this.constructor.name; + this.display = props?.message || this.defaultDisplay; + this.causeRaw = props?.cause ?? CustomError.expected``; + this.recoverable = props?.recoverable || false; + } + + public toJSON() { + return { + name: this.name, + message: this.message, + cause: this.cause, + display: this.display, + }; + } + + protected get defaultDisplay() { + return "An error occurred."; + } +} + +/** + * 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."; + } +} + +/** + * 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."; + } + + 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]), + ); + }); + + if (error.issues.length) { + result.causeRaw = [ + [ + "Zod validation to pass, but failed at: \n", + ...parsedIssues + .slice(error.issues.length * 2) + .flatMap(() => [" occured at ", " with ", ". \n"]), + ], + ...parsedIssues, + ]; + } + + return result; + } +} + +/** + * 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 1449d0a650..089bd74357 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,6 +1,8 @@ import { signOut } from "@/actions/auth"; import { tryGetToken } from "@/lib/token"; +import { RequestHeader, ResponseHeader, Status } from "@reflet/http"; import { env } from "./env"; +import { FetchError, ValidationError } from "./error"; import type { ErrorLike } from "./types"; type FetchResult = @@ -64,10 +66,14 @@ export const fetchGuildApi = async ( requestInit?: RequestInit, ): Promise> => { if (pathname.startsWith("/")) { - throw new Error(`"pathname" must not start with slash: ${pathname}`); + throw new ValidationError({ + cause: ValidationError.expected`${{ pathname }} must not start with slash`, + }); } if (pathname.endsWith("/")) { - throw new Error(`"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); @@ -77,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, { @@ -91,13 +98,15 @@ export const fetchGuildApi = async ( headers, }); - if (response.status === 401) { + if (response.status === Status.Unauthorized) { signOut(); } - const contentType = response.headers.get("content-type"); + const contentType = response.headers.get(ResponseHeader.ContentType); 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); @@ -106,7 +115,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"); @@ -129,8 +140,11 @@ const unpackFetcher = (fetcher: typeof fetchGuildApi) => { return async ( ...args: Parameters ) => { - const { data, status } = await fetcher(...args); - return status === "error" ? Promise.reject(data) : data; + const { data, status, response } = await fetcher(...args); + const partialResponse = { status: response.status }; + return status === "error" + ? Promise.reject({ data, partialResponse }) + : data; }; }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 2faafb886c..601bd35b7c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,9 +23,14 @@ export type DynamicRoute> = { */ // TODO: align this to backend when error handling gets consistent export type ErrorLike = { - message: string; - status?: string; - error?: string; + data: { + message: string; + status?: string; + error?: string; + }; + partialResponse: { + status: number; + }; }; /**