diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2711cd26ffcc..8a45f7de410c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -902,6 +902,7 @@ jobs: 'nextjs-13', 'nextjs-14', 'nextjs-15', + 'nextjs-t3', 'react-17', 'react-19', 'react-create-hash-router', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-t3/.gitignore new file mode 100644 index 000000000000..e799cc33c4e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +# Sentry +.sentryclirc + +.vscode + +test-results diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-t3/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/next-env.d.ts new file mode 100644 index 000000000000..40c3d68096c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-t3/next.config.js new file mode 100644 index 000000000000..b22141b67893 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/next.config.js @@ -0,0 +1,11 @@ +await import('./src/env.js'); + +/** @type {import("next").NextConfig} */ +const config = {}; + +import { withSentryConfig } from '@sentry/nextjs'; + +export default withSentryConfig(config, { + disableLogger: true, + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json new file mode 100644 index 000000000000..d5c3a9d20f0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json @@ -0,0 +1,54 @@ +{ + "name": "t3", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.50.0", + "@trpc/client": "^11.0.0-rc.446", + "@trpc/react-query": "^11.0.0-rc.446", + "@trpc/server": "^11.0.0-rc.446", + "geist": "^1.3.0", + "next": "^14.2.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "server-only": "^0.0.1", + "superjson": "^2.2.1", + "zod": "^3.23.3" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/eslint": "^8.56.10", + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.1.0", + "@typescript-eslint/parser": "^8.1.0", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.4", + "postcss": "^8.4.39", + "prettier": "^3.3.2", + "prettier-plugin-tailwindcss": "^0.6.5", + "tailwindcss": "^3.4.3", + "typescript": "^5.5.3" + }, + "ct3aMetadata": { + "initVersion": "7.37.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-t3/playwright.config.mjs new file mode 100644 index 000000000000..8448829443d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/playwright.config.mjs @@ -0,0 +1,19 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig( + { + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/postcss.config.cjs b/dev-packages/e2e-tests/test-applications/nextjs-t3/postcss.config.cjs new file mode 100644 index 000000000000..4cdb2f430f8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/postcss.config.cjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +module.exports = config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-t3/public/favicon.ico new file mode 100644 index 000000000000..60c702aac134 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nextjs-t3/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.client.config.ts new file mode 100644 index 000000000000..0e3121a8f01b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.client.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + debug: false, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.edge.config.ts new file mode 100644 index 000000000000..4f1cb3e93e9c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.edge.config.ts @@ -0,0 +1,13 @@ +// 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({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.server.config.ts new file mode 100644 index 000000000000..ad780407a5b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/sentry.server.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/_components/post.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/_components/post.tsx new file mode 100644 index 000000000000..0b1c6dcf367b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/_components/post.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; + +import { api } from '~/trpc/react'; + +export function LatestPost() { + const [latestPost] = api.post.getLatest.useSuspenseQuery(); + + const utils = api.useUtils(); + const [name, setName] = useState(''); + const createPost = api.post.create.useMutation({ + onSuccess: async () => { + await utils.post.invalidate(); + setName(''); + }, + }); + + const throwingMutation = api.post.throwError.useMutation({ + onSuccess: async () => { + await utils.post.invalidate(); + setName(''); + }, + }); + + return ( +
+ {latestPost ? ( +

Your most recent post: {latestPost.name}

+ ) : ( +

You have no posts yet.

+ )} +
{ + e.preventDefault(); + createPost.mutate({ name }); + }} + className="flex flex-col gap-2" + > + setName(e.target.value)} + id="createInput" + className="w-full rounded-full px-4 py-2 text-black" + /> + +
+
{ + e.preventDefault(); + throwingMutation.mutate({ name: 'I love dogs' }); + }} + className="flex flex-col gap-2" + > + +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/api/trpc/[trpc]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 000000000000..5756411c583e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,32 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { type NextRequest } from 'next/server'; + +import { env } from '~/env'; +import { appRouter } from '~/server/api/root'; +import { createTRPCContext } from '~/server/api/trpc'; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a HTTP request (e.g. when you make requests from Client Components). + */ +const createContext = async (req: NextRequest) => { + return createTRPCContext({ + headers: req.headers, + }); +}; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: () => createContext(req), + onError: + env.NODE_ENV === 'development' + ? ({ path, error }) => { + console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`); + } + : undefined, + }); + +export { handler as GET, handler as POST }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/global-error.tsx new file mode 100644 index 000000000000..912ad3606a61 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/global-error.tsx @@ -0,0 +1,27 @@ +'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/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx new file mode 100644 index 000000000000..e703260be1a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/layout.tsx @@ -0,0 +1,22 @@ +import '~/styles/globals.css'; + +import { GeistSans } from 'geist/font/sans'; +import { type Metadata } from 'next'; + +import { TRPCReactProvider } from '~/trpc/react'; + +export const metadata: Metadata = { + title: 'Create T3 App', + description: 'Generated by create-t3-app', + icons: [{ rel: 'icon', url: '/favicon.ico' }], +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/page.tsx new file mode 100644 index 000000000000..f8e261c98c34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/app/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; + +import { LatestPost } from '~/app/_components/post'; +import { HydrateClient, api } from '~/trpc/server'; + +export default async function Home() { + const hello = await api.post.hello({ text: 'from tRPC' }); + + void api.post.getLatest.prefetch(); + + return ( + +
+
+

+ Create T3 App +

+
+ +

First Steps →

+
+ Just the basics - Everything you need to know to set up your database and authentication. +
+ + +

Documentation →

+
+ Learn more about Create T3 App, the libraries it uses, and how to deploy it. +
+ +
+
+

{hello ? hello.greeting : 'Loading tRPC query...'}

+
+ + +
+
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/env.js b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/env.js new file mode 100644 index 000000000000..8c66c421c7ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/env.js @@ -0,0 +1,40 @@ +import { createEnv } from '@t3-oss/env-nextjs'; +import { z } from 'zod'; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + NODE_ENV: z.enum(['development', 'test', 'production']), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and + * `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/instrumentation.ts new file mode 100644 index 000000000000..8aff09f087d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/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/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/root.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/root.ts new file mode 100644 index 000000000000..4a6e7dc0f6bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/root.ts @@ -0,0 +1,23 @@ +import { postRouter } from '~/server/api/routers/post'; +import { createCallerFactory, createTRPCRouter } from '~/server/api/trpc'; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ +export const appRouter = createTRPCRouter({ + post: postRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; + +/** + * Create a server-side caller for the tRPC API. + * @example + * const trpc = createCaller(createContext); + * const res = await trpc.post.all(); + * ^? Post[] + */ +export const createCaller = createCallerFactory(appRouter); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/routers/post.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/routers/post.ts new file mode 100644 index 000000000000..042ebe69e9bb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/routers/post.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'; + +// Mocked DB +interface Post { + id: number; + name: string; +} +const posts: Post[] = [ + { + id: 1, + name: 'Hello World', + }, +]; + +export const postRouter = createTRPCRouter({ + hello: publicProcedure.input(z.object({ text: z.string() })).query(({ input }) => { + return { + greeting: `Hello ${input.text}`, + }; + }), + + create: publicProcedure.input(z.object({ name: z.string().min(1) })).mutation(async ({ input }) => { + const post: Post = { + id: posts.length + 1, + name: input.name, + }; + posts.push(post); + return post; + }), + + getLatest: publicProcedure.query(() => { + return posts.at(-1) ?? null; + }), + throwError: publicProcedure.input(z.object({ name: z.string().min(1) })).mutation(async () => { + throw new Error('Error thrown in trpc router'); + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/trpc.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/trpc.ts new file mode 100644 index 000000000000..0bc74b51243e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/server/api/trpc.ts @@ -0,0 +1,77 @@ +import * as Sentry from '@sentry/nextjs'; +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ +import { initTRPC } from '@trpc/server'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createTRPCContext = async (opts: { headers: Headers }) => { + return { + ...opts, + }; +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +const sentryMiddleware = Sentry.trpcMiddleware({ + attachRpcInput: true, +}); + +export const publicProcedure = t.procedure.use(async opts => sentryMiddleware(opts)); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/styles/globals.css b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/styles/globals.css new file mode 100644 index 000000000000..b5c61c956711 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/query-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/query-client.ts new file mode 100644 index 000000000000..22319e7c0a5a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/query-client.ts @@ -0,0 +1,20 @@ +import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'; +import SuperJSON from 'superjson'; + +export const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000, + }, + dehydrate: { + serializeData: SuperJSON.serialize, + shouldDehydrateQuery: query => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', + }, + hydrate: { + deserializeData: SuperJSON.deserialize, + }, + }, + }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/react.tsx b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/react.tsx new file mode 100644 index 000000000000..12459d66eee6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/react.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client'; +import { createTRPCReact } from '@trpc/react-query'; +import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; +import { useState } from 'react'; +import SuperJSON from 'superjson'; + +import { type AppRouter } from '~/server/api/root'; +import { createQueryClient } from './query-client'; + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +const getQueryClient = () => { + if (typeof window === 'undefined') { + // Server: always make a new query client + return createQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + return (clientQueryClientSingleton ??= createQueryClient()); +}; + +export const api = createTRPCReact(); + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +export type RouterOutputs = inferRouterOutputs; + +export function TRPCReactProvider(props: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + api.createClient({ + links: [ + loggerLink({ + enabled: op => + process.env.NODE_ENV === 'development' || (op.direction === 'down' && op.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + transformer: SuperJSON, + url: getBaseUrl() + '/api/trpc', + headers: () => { + const headers = new Headers(); + headers.set('x-trpc-source', 'nextjs-react'); + return headers; + }, + }), + ], + }), + ); + + return ( + + + {props.children} + + + ); +} + +function getBaseUrl() { + if (typeof window !== 'undefined') return window.location.origin; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + return `http://localhost:${process.env.PORT ?? 3000}`; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/server.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/server.ts new file mode 100644 index 000000000000..b6cb13a70781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/src/trpc/server.ts @@ -0,0 +1,27 @@ +import 'server-only'; + +import { createHydrationHelpers } from '@trpc/react-query/rsc'; +import { headers } from 'next/headers'; +import { cache } from 'react'; + +import { type AppRouter, createCaller } from '~/server/api/root'; +import { createTRPCContext } from '~/server/api/trpc'; +import { createQueryClient } from './query-client'; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a tRPC call from a React Server Component. + */ +const createContext = cache(() => { + const heads = new Headers(headers()); + heads.set('x-trpc-source', 'rsc'); + + return createTRPCContext({ + headers: heads, + }); +}); + +const getQueryClient = cache(createQueryClient); +const caller = createCaller(createContext); + +export const { trpc: api, HydrateClient } = createHydrationHelpers(caller, getQueryClient); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-t3/start-event-proxy.mjs new file mode 100644 index 000000000000..afc5d2e465e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-t3', +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts new file mode 100644 index 000000000000..bdd1ea1f6102 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/tailwind.config.ts @@ -0,0 +1,14 @@ +import { type Config } from 'tailwindcss'; +import { fontFamily } from 'tailwindcss/defaultTheme'; + +export default ({ + content: ['./src/**/*.tsx'], + theme: { + extend: { + fontFamily: { + sans: ['var(--font-geist-sans)', ...fontFamily.sans], + }, + }, + }, + plugins: [], +} satisfies Config); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-error.test.ts new file mode 100644 index 000000000000..0245b641db5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-error.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should capture error with trpc context', async ({ page }) => { + const errorEventPromise = waitForError('nextjs-t3', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in trpc router'; + }); + + await page.goto('/'); + await page.click('#error-button'); + + const trpcError = await errorEventPromise; + + expect(trpcError).toBeDefined(); + expect(trpcError.contexts.trpc).toBeDefined(); + expect(trpcError.contexts.trpc.procedure_type).toEqual('mutation'); + expect(trpcError.contexts.trpc.input).toEqual({ name: 'I love dogs' }); +}); + +test('should create transaction with trpc input for error', async ({ page }) => { + const trpcTransactionPromise = waitForTransaction('nextjs-t3', async transactionEvent => { + return transactionEvent?.transaction === 'POST /api/trpc/[trpc]'; + }); + + await page.goto('/'); + await page.click('#error-button'); + + const trpcTransaction = await trpcTransactionPromise; + + expect(trpcTransaction).toBeDefined(); + expect(trpcTransaction.contexts.trpc).toBeDefined(); + expect(trpcTransaction.contexts.trpc.procedure_type).toEqual('mutation'); + expect(trpcTransaction.contexts.trpc.input).toEqual({ name: 'I love dogs' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-mutation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-mutation.test.ts new file mode 100644 index 000000000000..47d6a52f8a19 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/tests/trpc-mutation.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create transaction with trpc input for mutation', async ({ page }) => { + const trpcTransactionPromise = waitForTransaction('nextjs-t3', async transactionEvent => { + return transactionEvent?.transaction === 'POST /api/trpc/[trpc]'; + }); + + await page.goto('/'); + await page.locator('#createInput').fill('I love dogs'); + await page.click('#createButton'); + + const trpcTransaction = await trpcTransactionPromise; + + expect(trpcTransaction).toBeDefined(); + expect(trpcTransaction.contexts.trpc).toBeDefined(); + expect(trpcTransaction.contexts.trpc.procedure_type).toEqual('mutation'); + expect(trpcTransaction.contexts.trpc.input).toEqual({ name: 'I love dogs' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-t3/tsconfig.json new file mode 100644 index 000000000000..905062ded60c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + + /* Strictness */ + "strict": true, + "noUncheckedIndexedAccess": true, + "checkJs": true, + + /* Bundled projects */ + "lib": ["dom", "dom.iterable", "ES2022"], + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "plugins": [{ "name": "next" }], + "incremental": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + ".eslintrc.cjs", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.cjs", + "**/*.js", + ".next/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index de240b761df0..4fa07d82ff6d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -105,7 +105,9 @@ Sentry.addEventProcessor(event => { export const t = initTRPC.context().create(); -const procedure = t.procedure.use(Sentry.trpcMiddleware({ attachRpcInput: true })); +const sentryMiddleware = Sentry.trpcMiddleware({ attachRpcInput: true }); + +const procedure = t.procedure.use(async opts => sentryMiddleware(opts)); export const appRouter = t.router({ getSomething: procedure.input(z.string()).query(opts => { diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 1320f0ff15bc..a3101d793a31 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -1,4 +1,4 @@ -import { isThenable, normalize } from '@sentry/utils'; +import { normalize } from '@sentry/utils'; import { getClient } from './currentScopes'; import { captureException, setContext } from './exports'; @@ -15,16 +15,31 @@ export interface SentryTrpcMiddlewareArguments { type?: unknown; next: () => T; rawInput?: unknown; + getRawInput?: () => Promise; } const trpcCaptureContext = { mechanism: { handled: false, data: { function: 'trpcMiddleware' } } }; +function captureIfError(nextResult: unknown): void { + // TODO: Set span status based on what TRPCError was encountered + if ( + typeof nextResult === 'object' && + nextResult !== null && + 'ok' in nextResult && + !nextResult.ok && + 'error' in nextResult + ) { + captureException(nextResult.error, trpcCaptureContext); + } +} + /** * Sentry tRPC middleware that captures errors and creates spans for tRPC procedures. */ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { - return function (opts: SentryTrpcMiddlewareArguments): T { - const { path, type, next, rawInput } = opts; + return async function (opts: SentryTrpcMiddlewareArguments): Promise { + const { path, type, next, rawInput, getRawInput } = opts; + const client = getClient(); const clientOptions = client && client.getOptions(); @@ -33,23 +48,21 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { }; if (options.attachRpcInput !== undefined ? options.attachRpcInput : clientOptions && clientOptions.sendDefaultPii) { - trpcContext.input = normalize(rawInput); - } + if (rawInput !== undefined) { + trpcContext.input = normalize(rawInput); + } - setContext('trpc', trpcContext); + if (getRawInput !== undefined && typeof getRawInput === 'function') { + try { + const rawRes = await getRawInput(); - function captureIfError(nextResult: unknown): void { - // TODO: Set span status based on what TRPCError was encountered - if ( - typeof nextResult === 'object' && - nextResult !== null && - 'ok' in nextResult && - !nextResult.ok && - 'error' in nextResult - ) { - captureException(nextResult.error, trpcCaptureContext); + trpcContext.input = normalize(rawRes); + } catch (err) { + // noop + } } } + setContext('trpc', trpcContext); return startSpanManual( { @@ -60,34 +73,17 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.rpc.trpc', }, }, - span => { - let maybePromiseResult; + async span => { try { - maybePromiseResult = next(); + const nextResult = await next(); + captureIfError(nextResult); + span.end(); + return nextResult; } catch (e) { captureException(e, trpcCaptureContext); span.end(); throw e; } - - if (isThenable(maybePromiseResult)) { - return maybePromiseResult.then( - nextResult => { - captureIfError(nextResult); - span.end(); - return nextResult; - }, - e => { - captureException(e, trpcCaptureContext); - span.end(); - throw e; - }, - ) as T; - } else { - captureIfError(maybePromiseResult); - span.end(); - return maybePromiseResult; - } }, ); };