Skip to content

Commit e391353

Browse files
committed
feat(web): add Sentry and more robust error handling
1 parent d3635c6 commit e391353

18 files changed

+2125
-262
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,6 @@ service-account-key.json
250250

251251
# My local design files
252252
designs.tldr
253+
254+
# Sentry secrets
255+
.env.sentry-build-plugin

apps/web/next.config.js

+51-15
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,60 @@ const { withPlausibleProxy } = require('next-plausible');
22
const getBaseApiUrl = require('./shared');
33
const dotenv = require('dotenv');
44
const path = require('node:path');
5+
const { withSentryConfig } = require('@sentry/nextjs');
56

67
dotenv.config({ path: path.join(__dirname, '..', '..', '.env') });
78

89
/** @type {import('next').NextConfig} */
9-
const nextConfig = withPlausibleProxy()({
10-
productionBrowserSourceMaps: true,
11-
env: {
12-
// biome-ignore lint/style/useNamingConvention: This is an environment variable
13-
NEXT_PUBLIC_API_URL: getBaseApiUrl(),
14-
},
15-
// Needed for a Next.js bug https://github.com/vercel/next.js/discussions/32237#discussioncomment-4793595
16-
webpack: (config) => {
17-
config.resolve.extensionAlias = {
18-
'.js': ['.ts', '.tsx', '.js'],
19-
};
2010

21-
return config;
22-
},
23-
});
11+
module.exports = withSentryConfig(
12+
withPlausibleProxy()({
13+
productionBrowserSourceMaps: true,
14+
env: {
15+
// biome-ignore lint/style/useNamingConvention: This is an environment variable
16+
NEXT_PUBLIC_API_URL: getBaseApiUrl(),
17+
},
18+
// Needed for a Next.js bug https://github.com/vercel/next.js/discussions/32237#discussioncomment-4793595
19+
webpack: (config) => {
20+
config.resolve.extensionAlias = {
21+
'.js': ['.ts', '.tsx', '.js'],
22+
};
23+
24+
return config;
25+
},
26+
}),
27+
{
28+
// For all available options, see:
29+
// https://github.com/getsentry/sentry-webpack-plugin#options
30+
31+
org: 'frcsh',
32+
project: 'hours-web',
33+
34+
// Only print logs for uploading source maps in CI
35+
silent: !process.env.CI,
36+
37+
// For all available options, see:
38+
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
2439

25-
module.exports = nextConfig;
40+
// Upload a larger set of source maps for prettier stack traces (increases build time)
41+
widenClientFileUpload: true,
42+
43+
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
44+
// This can increase your server load as well as your hosting bill.
45+
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
46+
// side errors will fail.
47+
tunnelRoute: '/__s',
48+
49+
// Hides source maps from generated client bundles
50+
hideSourceMaps: false,
51+
52+
// Automatically tree-shake Sentry logger statements to reduce bundle size
53+
disableLogger: true,
54+
55+
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
56+
// See the following for more information:
57+
// https://docs.sentry.io/product/crons/
58+
// https://vercel.com/docs/cron-jobs
59+
automaticVercelMonitors: true,
60+
},
61+
);

apps/web/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@radix-ui/react-switch": "1.1.0",
3131
"@radix-ui/react-tabs": "1.1.0",
3232
"@radix-ui/react-tooltip": "1.1.1",
33+
"@sentry/nextjs": "^8.15.0",
3334
"@simplewebauthn/browser": "10.0.0",
3435
"@tanstack/react-query": "5.49.2",
3536
"@tanstack/react-table": "8.19.2",

apps/web/sentry.client.config.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// This file configures the initialization of Sentry on the client.
2+
// The config you add here will be used whenever a users loads a page in their browser.
3+
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
4+
5+
import * as Sentry from "@sentry/nextjs";
6+
7+
Sentry.init({
8+
dsn: "https://2665da63fbe57c625e3a9bf89556bd9b@o4506925144014848.ingest.us.sentry.io/4507548777840640",
9+
10+
// Adjust this value in production, or use tracesSampler for greater control
11+
tracesSampleRate: 1,
12+
13+
// Setting this option to true will print useful information to the console while you're setting up Sentry.
14+
debug: false,
15+
16+
replaysOnErrorSampleRate: 1.0,
17+
18+
// This sets the sample rate to be 10%. You may want this to be 100% while
19+
// in development and sample at a lower rate in production
20+
replaysSessionSampleRate: 0.1,
21+
22+
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
23+
integrations: [
24+
Sentry.replayIntegration({
25+
// Additional Replay configuration goes in here, for example:
26+
maskAllText: true,
27+
blockAllMedia: true,
28+
}),
29+
],
30+
});

apps/web/sentry.edge.config.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
2+
// The config you add here will be used whenever one of the edge features is loaded.
3+
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
4+
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
5+
6+
import * as Sentry from "@sentry/nextjs";
7+
8+
Sentry.init({
9+
dsn: "https://2665da63fbe57c625e3a9bf89556bd9b@o4506925144014848.ingest.us.sentry.io/4507548777840640",
10+
11+
// Adjust this value in production, or use tracesSampler for greater control
12+
tracesSampleRate: 1,
13+
14+
// Setting this option to true will print useful information to the console while you're setting up Sentry.
15+
debug: false,
16+
});

apps/web/sentry.server.config.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// This file configures the initialization of Sentry on the server.
2+
// The config you add here will be used whenever the server handles a request.
3+
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
4+
5+
import * as Sentry from '@sentry/nextjs';
6+
7+
Sentry.init({
8+
dsn: 'https://2665da63fbe57c625e3a9bf89556bd9b@o4506925144014848.ingest.us.sentry.io/4507548777840640',
9+
10+
// Adjust this value in production, or use tracesSampler for greater control
11+
tracesSampleRate: 1,
12+
13+
// Setting this option to true will print useful information to the console while you're setting up Sentry.
14+
debug: false,
15+
16+
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
17+
spotlight: process.env.NODE_ENV === 'development',
18+
});
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,10 @@
1-
import { CreateTeamCard } from '@/src/components/home/create-team-card';
2-
import { TeamCard } from '@/src/components/home/team-card/team-card.server';
3-
import { trpcServer } from '@/src/trpc/trpc-server';
1+
import { TeamCards } from '@/src/components/home/team-cards';
42

53
// biome-ignore lint/style/noDefaultExport: This must be a default export
6-
export default async function TeamSelectPage() {
7-
// TODO: Make this stream, make it a separate component <TeamCardsForSelf />
8-
const teams = await trpcServer.teams.forUser.getTeamNames.query();
9-
4+
export default function TeamSelectPage() {
105
return (
116
<div className='flex items-center justify-center w-full'>
12-
<div className='grid grid-cols-1 gap-4 xs:grid-cols-2 md:grid-cols-3 w-full md:max-w-4xl'>
13-
{teams.map((team) => (
14-
<TeamCard key={team.slug} team={team} />
15-
))}
16-
<CreateTeamCard />
17-
</div>
7+
<TeamCards />
188
</div>
199
);
2010
}

apps/web/src/app/(user)/(not-account)/(home)/layout.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { trpcServer } from '@/src/trpc/trpc-server';
2+
import { captureException } from '@sentry/nextjs';
23
import { unstable_noStore as noStore } from 'next/cache';
34
import type { ReactNode } from 'react';
45

@@ -10,7 +11,13 @@ type Props = {
1011
// biome-ignore lint/style/noDefaultExport: This must be a default export
1112
export default async function TeamSelectConditionalLayout({ authed, landing }: Props) {
1213
noStore();
13-
const { user } = await trpcServer.user.getSelf.query();
1414

15-
return <>{user ? authed : landing}</>;
15+
try {
16+
const { user } = await trpcServer.user.getSelf.query();
17+
18+
return user ? authed : landing;
19+
} catch (error) {
20+
captureException(error);
21+
return landing;
22+
}
1623
}

apps/web/src/app/error.tsx

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client';
2+
3+
import { Button } from '@/components/ui/button';
4+
import { captureException } from '@sentry/nextjs';
5+
import { useEffect } from 'react';
6+
import { MainContent } from '../components/main-content';
7+
import { BaseNavbar } from '../components/navbar/base-navbar';
8+
import { PageHeader } from '../components/page-header';
9+
10+
// biome-ignore lint/style/noDefaultExport: This must be a default export
11+
export default function ErrorPage({
12+
error,
13+
reset,
14+
}: {
15+
error: Error & { digest?: string };
16+
reset: () => void;
17+
}) {
18+
useEffect(() => {
19+
captureException(error);
20+
}, [error]);
21+
22+
return (
23+
<>
24+
<BaseNavbar />
25+
26+
<PageHeader title='Error' />
27+
28+
<MainContent className='items-center justify-center gap-4'>
29+
<p>An error occurred while rendering this page</p>
30+
31+
<Button onClick={reset} className='max-w-min'>
32+
Reload
33+
</Button>
34+
</MainContent>
35+
</>
36+
);
37+
}

apps/web/src/app/global-error.tsx

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client';
2+
3+
import * as Sentry from '@sentry/nextjs';
4+
import NextError from 'next/error';
5+
import { useEffect } from 'react';
6+
import { isTrpcClientError } from '../trpc/common';
7+
8+
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
9+
useEffect(() => {
10+
if (error instanceof AggregateError) {
11+
for (const childError of error.errors) {
12+
if (isTrpcClientError(childError)) {
13+
Sentry.addBreadcrumb({
14+
message: childError.message,
15+
data: {
16+
meta: childError.meta,
17+
data: childError.data,
18+
},
19+
});
20+
}
21+
}
22+
}
23+
24+
if (isTrpcClientError(error)) {
25+
Sentry.captureException(error, {
26+
data: {
27+
meta: error.meta,
28+
data: error.data,
29+
},
30+
});
31+
} else {
32+
Sentry.captureException(error);
33+
}
34+
}, [error]);
35+
36+
return (
37+
<html lang='en'>
38+
<body>
39+
{/* `NextError` is the default Next.js error page component. Its type
40+
definition requires a `statusCode` prop. However, since the App Router
41+
does not expose status codes for errors, we simply pass 0 to render a
42+
generic error message. */}
43+
<NextError statusCode={0} />
44+
</body>
45+
</html>
46+
);
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { TeamSchema } from '@hours.frc.sh/api/app/team/schemas/team_schema';
2+
import { TeamCard } from './team-card/team-card.server';
3+
import { CreateTeamCard } from './create-team-card';
4+
import { Suspense, use } from 'react';
5+
import { trpcServer } from '@/src/trpc/trpc-server';
6+
import { Card, CardContent, CardHeader } from '@/components/ui/card';
7+
import { Skeleton } from '@/components/ui/skeleton';
8+
9+
export function TeamCards() {
10+
const teamsPromise = trpcServer.teams.forUser.getTeamNames.query();
11+
12+
return (
13+
<div className='grid grid-cols-1 gap-4 xs:grid-cols-2 md:grid-cols-3 w-full md:max-w-4xl'>
14+
<Suspense
15+
fallback={
16+
<>
17+
<TeamCardSkeleton />
18+
<TeamCardSkeleton />
19+
<TeamCardSkeleton />
20+
<TeamCardSkeleton />
21+
</>
22+
}
23+
>
24+
<TeamCardsInner teamsPromise={teamsPromise} />
25+
<CreateTeamCard />
26+
</Suspense>
27+
</div>
28+
);
29+
}
30+
31+
function TeamCardSkeleton() {
32+
return (
33+
<Card>
34+
<CardHeader>
35+
<Skeleton className='h-4 w-72' />
36+
</CardHeader>
37+
38+
<CardContent>
39+
<Skeleton className='h-9 w-80' />
40+
</CardContent>
41+
</Card>
42+
);
43+
}
44+
45+
function TeamCardsInner({
46+
teamsPromise,
47+
}: {
48+
teamsPromise: Promise<Pick<TeamSchema, 'displayName' | 'slug'>[]>;
49+
}) {
50+
const teams = use(teamsPromise);
51+
52+
return (
53+
<>
54+
{teams.map((team) => (
55+
<TeamCard key={team.slug} team={team} />
56+
))}
57+
</>
58+
);
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { cn } from '@/lib/utils';
2+
import { Link } from 'next-view-transitions';
3+
import { ReactNode } from 'react';
4+
5+
type Props = {
6+
className?: string;
7+
8+
left?: ReactNode;
9+
right?: ReactNode;
10+
bottom?: ReactNode;
11+
};
12+
13+
export function BaseNavbar({ className, left, bottom, right }: Props) {
14+
return (
15+
<header className={cn('w-full py-4 bg-background border-b flex flex-col', className)}>
16+
<div className='container max-w-6xl mx-auto'>
17+
<div className='flex justify-between'>
18+
<div className='flex justify-start items-center'>
19+
<Link href='/' className='text-xl font-semibold leading-none'>
20+
hours.frc.sh
21+
</Link>
22+
23+
{left}
24+
</div>
25+
26+
{right}
27+
</div>
28+
29+
{bottom}
30+
</div>
31+
</header>
32+
);
33+
}

0 commit comments

Comments
 (0)