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.
+ )}
+
+
+
+ );
+}
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;
- }
},
);
};