From 87a24a98f0098d84c14d815d0ec2b1e54db4acc6 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 29 Mar 2025 20:37:28 -0400 Subject: [PATCH] sentry instrumentation --- .cursorrules | 2 +- app/contact/actions.ts | 139 +- app/contact/page.tsx | 3 +- app/error.tsx | 14 + app/global-error.tsx | 25 + app/notes/[slug]/counter.tsx | 5 +- app/notes/[slug]/page.tsx | 51 +- app/notes/page.tsx | 20 +- app/page.tsx | 5 +- app/robots.ts | 2 +- components/MenuItem/MenuItem.tsx | 3 +- components/PageTitle/PageTitle.tsx | 3 +- instrumentation-client.ts | 29 + instrumentation.ts | 13 + middleware.ts | 35 +- next.config.ts | 54 +- package.json | 13 +- pnpm-lock.yaml | 2137 +++++++++++++++++++++++++--- public/humans.txt | 1 + sentry.edge.config.ts | 12 + sentry.server.config.ts | 11 + 21 files changed, 2244 insertions(+), 333 deletions(-) create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 instrumentation-client.ts create mode 100644 instrumentation.ts create mode 100644 sentry.edge.config.ts create mode 100644 sentry.server.config.ts diff --git a/.cursorrules b/.cursorrules index 000d561d..f56f6448 100644 --- a/.cursorrules +++ b/.cursorrules @@ -23,7 +23,7 @@ CODE ARCHITECTURE: │ ├── config/ # Configuration constants │ ├── helpers/ # Utility functions ├── notes/ # Blog posts in markdown/MDX format - └── static/ # Static files such as images and videos + └── public/ # Static files 2. Component Organization: - Keep reusable components in ./components/. diff --git a/app/contact/actions.ts b/app/contact/actions.ts index b722b62c..2c28392b 100644 --- a/app/contact/actions.ts +++ b/app/contact/actions.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { z } from "zod"; import { Resend } from "resend"; +import * as Sentry from "@sentry/nextjs"; import * as config from "../../lib/config"; const schema = z.object({ @@ -22,71 +23,81 @@ export const sendMessage = async ( errors?: z.inferFormattedError; payload?: FormData; }> => { - try { - const validatedFields = schema.safeParse(Object.fromEntries(formData)); + return await Sentry.withServerActionInstrumentation( + "sendMessage", + { + formData, + headers: headers(), + recordResponse: true, + }, + async () => { + try { + const validatedFields = schema.safeParse(Object.fromEntries(formData)); - // backup to client-side validations just in case someone squeezes through without them - if (!validatedFields.success) { - console.debug("[contact form] validation error:", validatedFields.error.flatten()); + // backup to client-side validations just in case someone squeezes through without them + if (!validatedFields.success) { + console.debug("[contact form] validation error:", validatedFields.error.flatten()); - return { - success: false, - message: "Please make sure that all fields are properly filled in.", - errors: validatedFields.error.format(), - payload: formData, - }; + return { + success: false, + message: "Please make sure that all fields are properly filled in.", + errors: validatedFields.error.format(), + payload: formData, + }; + } + + // validate captcha + const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA", + response: validatedFields.data["cf-turnstile-response"], + remoteip: (await headers()).get("x-forwarded-for") || "", + }), + cache: "no-store", + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + + if (!turnstileResponse.ok) { + throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`); + } + + const turnstileData = (await turnstileResponse.json()) as { success: boolean }; + + if (!turnstileData.success) { + return { + success: false, + message: "Did you complete the CAPTCHA? (If you're human, that is...)", + payload: formData, + }; + } + + if (!process.env.RESEND_FROM_EMAIL) { + console.warn("[contact form] RESEND_FROM_EMAIL not set, falling back to onboarding@resend.dev."); + } + + // send email + const resend = new Resend(process.env.RESEND_API_KEY); + await resend.emails.send({ + from: `${validatedFields.data.name} <${process.env.RESEND_FROM_EMAIL ?? "onboarding@resend.dev"}>`, + replyTo: `${validatedFields.data.name} <${validatedFields.data.email}>`, + to: [config.authorEmail], + subject: `[${config.siteName}] Contact Form Submission`, + text: validatedFields.data.message, + }); + + return { success: true, message: "Thanks! You should hear from me soon.", payload: formData }; + } catch (error) { + Sentry.captureException(error); + + return { + success: false, + message: "Internal server error... Try again later or shoot me an old-fashioned email?", + errors: error instanceof z.ZodError ? error.format() : undefined, + payload: formData, + }; + } } - - // validate captcha - const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA", - response: validatedFields.data["cf-turnstile-response"], - remoteip: (await headers()).get("x-forwarded-for") || "", - }), - cache: "no-store", - signal: AbortSignal.timeout(5000), // 5 second timeout - }); - - if (!turnstileResponse.ok) { - throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`); - } - - const turnstileData = (await turnstileResponse.json()) as { success: boolean }; - - if (!turnstileData.success) { - return { - success: false, - message: "Did you complete the CAPTCHA? (If you're human, that is...)", - payload: formData, - }; - } - - if (!process.env.RESEND_FROM_EMAIL) { - console.warn("[contact form] RESEND_FROM_EMAIL not set, falling back to onboarding@resend.dev."); - } - - // send email - const resend = new Resend(process.env.RESEND_API_KEY); - await resend.emails.send({ - from: `${validatedFields.data.name} <${process.env.RESEND_FROM_EMAIL ?? "onboarding@resend.dev"}>`, - replyTo: `${validatedFields.data.name} <${validatedFields.data.email}>`, - to: [config.authorEmail], - subject: `[${config.siteName}] Contact Form Submission`, - text: validatedFields.data.message, - }); - } catch (error) { - console.error("[contact form] fatal error:", error); - - return { - success: false, - message: "Internal server error... Try again later or shoot me an old-fashioned email?", - errors: error instanceof z.ZodError ? error.format() : undefined, - payload: formData, - }; - } - - return { success: true, message: "Thanks! You should hear from me soon.", payload: formData }; + ); }; diff --git a/app/contact/page.tsx b/app/contact/page.tsx index e5f96e66..879b81f6 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -2,7 +2,6 @@ import PageTitle from "../../components/PageTitle"; import Link from "../../components/Link"; import ContactForm from "./form"; import { addMetadata } from "../../lib/helpers/metadata"; -import type { Route } from "next"; export const metadata = addMetadata({ title: "Contact Me", @@ -29,7 +28,7 @@ const Page = () => {

🔐 You can grab my public key here:{" "} - + 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39 diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 00000000..69a82a5b --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +const Error = ({ error }: { error: Error & { digest?: string } }) => { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return Something went very wrong! 😳; +}; + +export default Error; diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 00000000..42a04c21 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; +import NextError from "next/error"; +import * as Sentry from "@sentry/nextjs"; + +const 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. */} + + + + ); +}; + +export default GlobalError; diff --git a/app/notes/[slug]/counter.tsx b/app/notes/[slug]/counter.tsx index 761a4af9..b54d2d30 100644 --- a/app/notes/[slug]/counter.tsx +++ b/app/notes/[slug]/counter.tsx @@ -1,4 +1,5 @@ import { connection } from "next/server"; +import * as Sentry from "@sentry/nextjs"; import commaNumber from "comma-number"; import CountUp from "../../../components/CountUp"; import redis from "../../../lib/helpers/redis"; @@ -21,9 +22,7 @@ const HitCounter = async ({ slug }: { slug: string }) => { ); } catch (error) { - console.error("[hit counter] fatal error:", error); - - throw new Error(); + Sentry.captureException(error); } }; diff --git a/app/notes/[slug]/page.tsx b/app/notes/[slug]/page.tsx index 44cfa8d5..e3917924 100644 --- a/app/notes/[slug]/page.tsx +++ b/app/notes/[slug]/page.tsx @@ -1,5 +1,4 @@ import { Suspense } from "react"; -import { ErrorBoundary } from "react-error-boundary"; import { JsonLd } from "react-schemaorg"; import { CalendarIcon, TagIcon, SquarePenIcon, EyeIcon } from "lucide-react"; import Link from "../../../components/Link"; @@ -10,8 +9,8 @@ import HitCounter from "./counter"; import { getSlugs, getFrontMatter } from "../../../lib/helpers/posts"; import { addMetadata } from "../../../lib/helpers/metadata"; import * as config from "../../../lib/config"; -import { BASE_URL } from "../../../lib/config/constants"; -import type { Metadata, Route } from "next"; +import { BASE_URL, POSTS_DIR } from "../../../lib/config/constants"; +import type { Metadata } from "next"; import type { Article } from "schema-dts"; import styles from "./page.module.css"; @@ -50,7 +49,7 @@ export const generateMetadata = async ({ params }: { params: Promise<{ slug: str card: "summary_large_image", }, alternates: { - canonical: `/notes/${slug}`, + canonical: `/${POSTS_DIR}/${slug}`, }, }); }; @@ -59,7 +58,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => { const { slug } = await params; const frontmatter = await getFrontMatter(slug); - const { default: MDXContent } = await import(`../../../notes/${slug}/index.mdx`); + const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`); return ( <> @@ -70,7 +69,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => { headline: frontmatter!.title, description: frontmatter!.description, url: frontmatter!.permalink, - image: [`${BASE_URL}/notes/${slug}/opengraph-image`], + image: [`${BASE_URL}/${POSTS_DIR}/${frontmatter!.slug}/opengraph-image`], keywords: frontmatter!.tags, datePublished: frontmatter!.date, dateModified: frontmatter!.date, @@ -85,7 +84,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {

- +

{ - // parse the year of each note and group them together - const notes = await getFrontMatter(); - const notesByYear: { + // parse the year of each post and group them together + const posts = await getFrontMatter(); + const postsByYear: { [year: string]: FrontMatter[]; } = {}; - notes.forEach((note) => { - const year = new Date(note.date).getUTCFullYear(); - (notesByYear[year] || (notesByYear[year] = [])).push(note); + posts.forEach((post) => { + const year = new Date(post.date).getUTCFullYear(); + (postsByYear[year] || (postsByYear[year] = [])).push(post); }); const sections: ReactElement[] = []; - Object.entries(notesByYear).forEach(([year, posts]) => { + Object.entries(postsByYear).forEach(([year, posts]) => { sections.push(

{year}

@@ -40,7 +40,7 @@ const Page = async () => {
  • ))} diff --git a/app/page.tsx b/app/page.tsx index 623862ae..3fb32aa3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,6 @@ import { rgba } from "polished"; import { LockIcon } from "lucide-react"; import UnstyledLink from "../components/Link"; import type { ComponentPropsWithoutRef } from "react"; -import type { Route } from "next"; import styles from "./page.module.css"; @@ -149,7 +148,7 @@ const Page = () => { {" "} and{" "} { {" "} ({ rules: [ { userAgent: "*", - disallow: ["/_stream/", "/api/", "/stats/", "/tweets/", "/404", "/500"], + disallow: ["/_stream/", "/_otel/", "/api/", "/404", "/500"], }, ], sitemap: `${BASE_URL}/sitemap.xml`, diff --git a/components/MenuItem/MenuItem.tsx b/components/MenuItem/MenuItem.tsx index 9a85d7a7..9fa856b5 100644 --- a/components/MenuItem/MenuItem.tsx +++ b/components/MenuItem/MenuItem.tsx @@ -1,6 +1,5 @@ import clsx from "clsx"; import Link from "../Link"; -import type { Route } from "next"; import type { ComponentPropsWithoutRef } from "react"; import type { LucideIcon } from "lucide-react"; @@ -8,7 +7,7 @@ import styles from "./MenuItem.module.css"; export type MenuItemProps = Omit, "href"> & { text?: string; - href?: Route; + href?: string; icon?: LucideIcon; current?: boolean; }; diff --git a/components/PageTitle/PageTitle.tsx b/components/PageTitle/PageTitle.tsx index 7997313c..605eca82 100644 --- a/components/PageTitle/PageTitle.tsx +++ b/components/PageTitle/PageTitle.tsx @@ -1,7 +1,6 @@ import clsx from "clsx"; import Link from "../Link"; import type { ComponentPropsWithoutRef } from "react"; -import type { Route } from "next"; import styles from "./PageTitle.module.css"; @@ -12,7 +11,7 @@ export type PageTitleProps = ComponentPropsWithoutRef<"h1"> & { const PageTitle = ({ canonical, className, children, ...rest }: PageTitleProps) => { return (

    - + {children}

    diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 00000000..c4975d56 --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,29 @@ +// This file configures the initialization of Sentry on the client. +// The added config 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: process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN, + integrations: [ + Sentry.replayIntegration({ + networkDetailAllowUrls: [process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL!], + networkRequestHeaders: ["referer", "origin", "user-agent", "x-upstream-proxy"], + networkResponseHeaders: [ + "location", + "x-matched-path", + "x-nextjs-prerender", + "x-vercel-cache", + "x-vercel-id", + "x-vercel-error", + "x-rewrite-url", + "x-got-milk", + ], + }), + ], + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 1.0, + debug: false, +}); diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 00000000..e2b5d05d --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export const onRequestError = Sentry.captureRequestError; + +export const register = async () => { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +}; diff --git a/middleware.ts b/middleware.ts index e78a0e1c..a586e3f6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -36,28 +36,19 @@ export const middleware = (request: NextRequest) => { // search the rewrite map for the short code const proxiedOrigin = rewrites.get(key); - // return a 400 error if a rewrite was requested but the short code isn't found - if (!proxiedOrigin) { - return NextResponse.json( - { error: "Unknown proxy key" }, - { - status: 400, - headers, - } - ); + if (proxiedOrigin) { + // it's now safe to build the rewritten URL + const proxiedPath = slashIndex === -1 ? "/" : pathAfterPrefix.slice(slashIndex); + const proxiedUrl = new URL(`${proxiedPath}${request.nextUrl.search}`, proxiedOrigin); + + headers.set("x-rewrite-url", proxiedUrl.toString()); + + // finally do the rewriting + return NextResponse.rewrite(proxiedUrl, { + request, + headers, + }); } - - // it's now safe to build the rewritten URL - const proxiedPath = slashIndex === -1 ? "/" : pathAfterPrefix.slice(slashIndex); - const proxiedUrl = new URL(`${proxiedPath}${request.nextUrl.search}`, proxiedOrigin); - - headers.set("x-rewrite-url", proxiedUrl.toString()); - - // finally do the rewriting - return NextResponse.rewrite(proxiedUrl, { - request, - headers, - }); } // if we've gotten this far, continue normally to next.js @@ -70,6 +61,6 @@ export const middleware = (request: NextRequest) => { export const config: MiddlewareConfig = { // save compute time by skipping middleware for next.js internals and static files matcher: [ - "/((?!_next/|_vercel/|api/|\\.well-known/|[^?]*\\.(?:png|jpe?g|gif|webp|avif|svg|ico|webm|mp4|ttf|woff2?|xml|atom|txt|pdf|webmanifest)).*)", + "/((?!_next/static|_next/image|_vercel|_otel|api|\\.well-known|[^?]*\\.(?:png|jpe?g|gif|webp|avif|svg|ico|webm|mp4|ttf|woff2?|xml|atom|txt|pdf|webmanifest)).*)", ], }; diff --git a/next.config.ts b/next.config.ts index 60b31e21..979d9dc1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,15 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + import path from "path"; -import type { NextConfig } from "next"; -import withBundleAnalyzer from "@next/bundle-analyzer"; -import withMDX from "@next/mdx"; import { visit } from "unist-util-visit"; import * as mdxPlugins from "./lib/helpers/remark-rehype-plugins"; +import type { NextConfig } from "next"; + +type NextPlugin = ( + config: NextConfig, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: any +) => NextConfig; const nextConfig: NextConfig = { reactStrictMode: true, @@ -143,11 +149,13 @@ const nextConfig: NextConfig = { ], }; -const nextPlugins = [ - withBundleAnalyzer({ - enabled: process.env.ANALYZE === "true", +// my own macgyvered version of next-compose-plugins (RIP) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nextPlugins: Array = [ + require("@next/bundle-analyzer")({ + enabled: !!process.env.ANALYZE, }), - withMDX({ + require("@next/mdx")({ options: { remarkPlugins: [ mdxPlugins.remarkFrontmatter, @@ -156,7 +164,8 @@ const nextPlugins = [ mdxPlugins.remarkSmartypants, // workaround for rehype-mdx-import-media not applying to `