diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 43e4e82c61b0..c0b943277570 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -78,7 +78,7 @@ body: label: Link to Sentry event description: If applicable, please provide a link to the affected event from your Sentry account. The event will only be - viewable by Sentry staff. + viewable by Sentry staff; however, the event URL will still appear on your public GitHub issue. placeholder: https://sentry.io/organizations//issues//events//?project= - type: textarea id: sdk-setup diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67d956b620be..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', @@ -1067,6 +1068,10 @@ jobs: 'react-send-to-sentry', 'node-express-send-to-sentry', 'debug-id-sourcemaps', + 'nextjs-app-dir', + 'nextjs-13', + 'nextjs-14', + 'nextjs-15', ] build-command: - false @@ -1081,6 +1086,30 @@ jobs: - test-application: 'create-remix-app-legacy' assert-command: 'test:assert-sourcemaps' label: 'create-remix-app-legacy (sourcemaps)' + - test-application: 'nextjs-app-dir' + build-command: 'test:build-canary' + label: 'nextjs-app-dir (canary)' + - test-application: 'nextjs-app-dir' + build-command: 'test:build-latest' + label: 'nextjs-app-dir (latest)' + - test-application: 'nextjs-13' + build-command: 'test:build-canary' + label: 'nextjs-13 (canary)' + - test-application: 'nextjs-13' + build-command: 'test:build-latest' + label: 'nextjs-13 (latest)' + - test-application: 'nextjs-14' + build-command: 'test:build-canary' + label: 'nextjs-14 (canary)' + - test-application: 'nextjs-14' + build-command: 'test:build-latest' + label: 'nextjs-14 (latest)' + - test-application: 'nextjs-15' + build-command: 'test:build-canary' + label: 'nextjs-15 (canary)' + - test-application: 'nextjs-15' + build-command: 'test:build-latest' + label: 'nextjs-15 (latest)' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 000000000000..e9d1e81b75ea --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,27 @@ +name: "Automation: Notify issues for release" +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: false + +# This workflow is triggered when a release is published +jobs: + release-comment-issues: + runs-on: ubuntu-20.04 + name: 'Notify issues' + steps: + - name: Get version + id: get_version + run: echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> $GITHUB_OUTPUT + + - name: Comment on linked issues that are mentioned in release + if: steps.get_version.outputs.version != '' + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} diff --git a/.github/workflows/release-size-info.yml b/.github/workflows/release-size-info.yml index f56883faf986..04e51e5ae14e 100644 --- a/.github/workflows/release-size-info.yml +++ b/.github/workflows/release-size-info.yml @@ -23,7 +23,7 @@ jobs: - name: Update Github Release if: steps.get_version.outputs.version != '' - uses: getsentry/size-limit-release@v1 + uses: getsentry/size-limit-release@v2 with: github_token: ${{ secrets.GITHUB_TOKEN }} version: ${{ steps.get_version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b384d4c427..ec58dd36abbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,46 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.33.0 + +### Important Changes + +- **feat(nextjs): Support new async APIs (`headers()`, `params`, `searchParams`) + ([#13828](https://github.com/getsentry/sentry-javascript/pull/13828))** + +Adds support for [new dynamic Next.js APIs](https://github.com/vercel/next.js/pull/68812). + +- **feat(node): Add `lru-memoizer` instrumentation + ([#13796](https://github.com/getsentry/sentry-javascript/pull/13796))** + +Adds integration for lru-memoizer using @opentelemetry/instrumentation-lru-memoizer. + +- **feat(nuxt): Add `unstable_sentryBundlerPluginOptions` to module options + ([#13811](https://github.com/getsentry/sentry-javascript/pull/13811))** + +Allows passing other options from the bundler plugins (vite and rollup) to Nuxt module options. + +### Other Changes + +- fix(browser): Ensure `wrap()` only returns functions + ([#13838](https://github.com/getsentry/sentry-javascript/pull/13838)) +- fix(core): Adapt trpc middleware input attachment + ([#13831](https://github.com/getsentry/sentry-javascript/pull/13831)) +- fix(core): Don't return trace data in `getTraceData` and `getTraceMetaTags` if SDK is disabled + ([#13760](https://github.com/getsentry/sentry-javascript/pull/13760)) +- fix(nuxt): Don't restrict source map assets upload + ([#13800](https://github.com/getsentry/sentry-javascript/pull/13800)) +- fix(nuxt): Use absolute path for client config ([#13798](https://github.com/getsentry/sentry-javascript/pull/13798)) +- fix(replay): Stop global event handling for paused replays + ([#13815](https://github.com/getsentry/sentry-javascript/pull/13815)) +- fix(sveltekit): add url param to source map upload options + ([#13812](https://github.com/getsentry/sentry-javascript/pull/13812)) +- fix(types): Add jsdocs to cron types ([#13776](https://github.com/getsentry/sentry-javascript/pull/13776)) +- fix(nextjs): Loosen @sentry/nextjs webpack peer dependency + ([#13826](https://github.com/getsentry/sentry-javascript/pull/13826)) + +Work in this release was contributed by @joshuajaco. Thank you for your contribution! + ## 8.32.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx index 92bee1dbac4b..0d8f1841ea9d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx @@ -7,33 +7,31 @@ export default function Page() { return

Hello World!

; } -export async function generateMetadata({ - searchParams, -}: { - searchParams: { [key: string]: string | string[] | undefined }; -}) { +export async function generateMetadata({ searchParams }: { searchParams: any }) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedSearchParams = await searchParams; + Sentry.setTag('my-isolated-tag', true); Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope - if (searchParams['shouldThrowInGenerateMetadata']) { + if (normalizedSearchParams['shouldThrowInGenerateMetadata']) { throw new Error('generateMetadata Error'); } return { - title: searchParams['metadataTitle'] ?? 'not set', + title: normalizedSearchParams['metadataTitle'] ?? 'not set', }; } -export function generateViewport({ - searchParams, -}: { - searchParams: { [key: string]: string | undefined }; -}) { - if (searchParams['shouldThrowInGenerateViewport']) { +export async function generateViewport({ searchParams }: { searchParams: any }) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedSearchParams = await searchParams; + + if (normalizedSearchParams['shouldThrowInGenerateViewport']) { throw new Error('generateViewport Error'); } return { - themeColor: searchParams['viewportThemeColor'] ?? 'black', + themeColor: normalizedSearchParams['viewportThemeColor'] ?? 'black', }; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/utils.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/utils.ts index a065c53ee4c9..249efabe58f3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/utils.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/utils.ts @@ -26,8 +26,8 @@ export function makeHttpRequest(url: string) { }); } -export function checkHandler() { - const headerList = headers(); +export async function checkHandler() { + const headerList = await headers(); const headerObj: Record = {}; headerList.forEach((value, key) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx index ec2b2b1232c7..c67513e0e4fd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx @@ -3,10 +3,13 @@ import * as Sentry from '@sentry/nextjs'; export default async function Page({ searchParams, }: { - searchParams: { id?: string }; + searchParams: any; }) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedSearchParams = await searchParams; + try { - console.log(searchParams.id); // Accessing a field on searchParams will throw the PPR error + console.log(normalizedSearchParams.id); // Accessing a field on searchParams will throw the PPR error } catch (e) { Sentry.captureException(e); // This error should not be reported await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[...parameters]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[...parameters]/page.tsx index 31fa4ee21be5..f09f06875c3a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[...parameters]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[...parameters]/page.tsx @@ -1,10 +1,14 @@ +import { use } from 'react'; import { ClientErrorDebugTools } from '../../../../components/client-error-debug-tools'; -export default function Page({ params }: { params: Record }) { +export default function Page({ params }: any) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedParams = 'then' in params ? use(params) : params; + return (

Page (/client-component/[...parameters])

-

Params: {JSON.stringify(params['parameters'])}

+

Params: {JSON.stringify(normalizedParams['parameters'])}

); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[parameter]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[parameter]/page.tsx index 2b9c28b922ac..514a0833c998 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[parameter]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/client-component/parameter/[parameter]/page.tsx @@ -1,10 +1,14 @@ +import { use } from 'react'; import { ClientErrorDebugTools } from '../../../../components/client-error-debug-tools'; -export default function Page({ params }: { params: Record }) { +export default function Page({ params }: any) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedParams = 'then' in params ? use(params) : params; + return (

Page (/client-component/[parameter])

-

Parameter: {JSON.stringify(params['parameter'])}

+

Parameter: {JSON.stringify(normalizedParams['parameter'])}

); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[...parameters]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[...parameters]/page.tsx index 5d9d6c8262c5..63d0e7b53f0b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[...parameters]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[...parameters]/page.tsx @@ -1,12 +1,16 @@ +import { use } from 'react'; import { ClientErrorDebugTools } from '../../../../components/client-error-debug-tools'; export const dynamic = 'force-dynamic'; -export default async function Page({ params }: { params: Record }) { +export default function Page({ params }: any) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedParams = 'then' in params ? use(params) : params; + return (

Page (/server-component/[...parameters])

-

Params: {JSON.stringify(params['parameters'])}

+

Params: {JSON.stringify(normalizedParams['parameters'])}

); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[parameter]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[parameter]/page.tsx index f88fe1cd4a06..98ecb7352ad2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[parameter]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/parameter/[parameter]/page.tsx @@ -1,12 +1,16 @@ +import { use } from 'react'; import { ClientErrorDebugTools } from '../../../../components/client-error-debug-tools'; export const dynamic = 'force-dynamic'; -export default async function Page({ params }: { params: Record }) { +export default function Page({ params }: any) { + // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests + const normalizedParams = 'then' in params ? use(params) : params; + return (

Page (/server-component/[parameter])

-

Parameter: {JSON.stringify(params['parameter'])}

+

Parameter: {JSON.stringify(normalizedParams['parameter'])}

); 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/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue index 379e8e417b35..e83392b37b5c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue @@ -1,17 +1,23 @@ - - + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts index e9445d4c2382..d1556d511bf0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts @@ -8,7 +8,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data').click(); + await page.getByText('Fetch Server Data', { exact: true }).click(); const error = await errorPromise; @@ -26,7 +26,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByText('Fetch Server Data').click(); + await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts new file mode 100644 index 000000000000..46e2b135a9b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { + return txnEvent.transaction.includes('GET /test-param/'); + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction_info: { source: 'url' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts index b709760aab94..b6164d541b93 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts @@ -10,6 +10,7 @@ test('captures an exception', async ({ page }) => { ); }); + await page.goto('/error-boundary'); await page.goto('/error-boundary'); await page.locator('#caughtErrorBtn').click(); const errorEvent = await errorEventPromise; @@ -40,6 +41,7 @@ test('captures a second exception after resetting the boundary', async ({ page } ); }); + await page.goto('/error-boundary'); await page.goto('/error-boundary'); await page.locator('#caughtErrorBtn').click(); const firstErrorEvent = await firstErrorEventPromise; diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d46241cf5993..468c8ea645c5 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -51,6 +51,7 @@ "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", + "lru-memoizer": "2.3.0", "mongodb": "^3.7.3", "mongodb-memory-server-global": "^7.6.3", "mongoose": "^5.13.22", diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts index 3fd00abcd46c..38b3c26dd95e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -1,7 +1,8 @@ import type { TransactionEvent } from '@sentry/types'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -jest.setTimeout(30_000); +// When running docker compose, we need a larger timeout, as this takes some time. +jest.setTimeout(90_000); const EXPECTED_MESSAGE_SPAN_PRODUCER = expect.objectContaining({ op: 'message', diff --git a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/scenario.js b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/scenario.js new file mode 100644 index 000000000000..79f5564d1971 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/scenario.js @@ -0,0 +1,47 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const run = async () => { + // Test ported from the OTEL implementation: + // https://github.com/open-telemetry/opentelemetry-js-contrib/blob/0d6ebded313bb75b5a0e7a6422206c922daf3943/plugins/node/instrumentation-lru-memoizer/test/index.test.ts#L28 + const memoizer = require('lru-memoizer'); + + let memoizerLoadCallback; + const memoizedFoo = memoizer({ + load: (_param, callback) => { + memoizerLoadCallback = callback; + }, + hash: () => 'bar', + }); + + Sentry.startSpan({ op: 'run' }, async span => { + const outerSpanContext = span.spanContext(); + + memoizedFoo({ foo: 'bar' }, () => { + const innerContext = Sentry.getActiveSpan().spanContext(); + + // The span context should be the same as the outer span + // Throwing an error here will cause the test to fail + if (outerSpanContext !== innerContext) { + throw new Error('Outer and inner span context should match'); + } + }); + + span.end(); + }); + + // Invoking the load callback outside the span + memoizerLoadCallback(); +}; + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts new file mode 100644 index 000000000000..050505e4055e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts @@ -0,0 +1,29 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('lru-memoizer', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('keeps outer context inside the memoized inner functions', done => { + createRunner(__dirname, 'scenario.js') + // We expect only one transaction and nothing else. + // A failed test will result in an error event being sent to Sentry. + // Which will fail this suite. + .expect({ + transaction: { + transaction: '', + contexts: { + trace: expect.objectContaining({ + op: 'run', + data: expect.objectContaining({ + 'sentry.op': 'run', + 'sentry.origin': 'manual', + }), + }), + }, + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-sdk-disabled.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-sdk-disabled.js new file mode 100644 index 000000000000..3ad43cae7647 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-sdk-disabled.js @@ -0,0 +1,34 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + enabled: false, +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.get('/test', (_req, res) => { + res.send({ + response: ` + + + ${Sentry.getTraceMetaTags()} + + + Hi :) + + + `, + }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-tracesSampleRate-zero.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-tracesSampleRate-zero.js new file mode 100644 index 000000000000..31db69722c3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server-tracesSampleRate-zero.js @@ -0,0 +1,33 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + transport: loggingTransport, +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.get('/test', (_req, res) => { + res.send({ + response: ` + + + ${Sentry.getTraceMetaTags()} + + + Hi :) + + + `, + }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts index c42269dd8504..ab63b1c9cb35 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts @@ -5,7 +5,7 @@ describe('getTraceMetaTags', () => { cleanupChildProcesses(); }); - test('injects sentry tracing tags', async () => { + test('injects tags with trace from incoming headers', async () => { const traceId = 'cd7ee7a6fe3ebe7ab9c3271559bc203c'; const parentSpanId = '100ff0980e7a4ead'; @@ -22,4 +22,53 @@ describe('getTraceMetaTags', () => { expect(html).toMatch(//); expect(html).toContain(''); }); + + test('injects tags with new trace if no incoming headers', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest('get', '/test'); + + // @ts-ignore - response is defined, types just don't reflect it + const html = response?.response as unknown as string; + + const traceId = html.match(//)?.[1]; + expect(traceId).not.toBeUndefined(); + + expect(html).toContain(' tags with negative sampling decision if tracesSampleRate is 0', async () => { + const runner = createRunner(__dirname, 'server-tracesSampleRate-zero.js').start(); + + const response = await runner.makeRequest('get', '/test'); + + // @ts-ignore - response is defined, types just don't reflect it + const html = response?.response as unknown as string; + + const traceId = html.match(//)?.[1]; + expect(traceId).not.toBeUndefined(); + + expect(html).toContain(' tags if SDK is disabled", async () => { + const traceId = 'cd7ee7a6fe3ebe7ab9c3271559bc203c'; + const parentSpanId = '100ff0980e7a4ead'; + + const runner = createRunner(__dirname, 'server-sdk-disabled.js').start(); + + const response = await runner.makeRequest('get', '/test', { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: 'sentry-environment=production', + }); + + // @ts-ignore - response is defined, types just don't reflect it + const html = response?.response as unknown as string; + + expect(html).not.toContain('"sentry-trace"'); + expect(html).not.toContain('"baggage"'); + }); }); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index bfd6886f3861..91ea79f833bc 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -72,6 +72,7 @@ export { lastEventId, linkedErrorsIntegration, localVariablesIntegration, + lruMemoizerIntegration, makeNodeTransport, metrics, modulesIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 9bbba6eeda66..7b0e05c9a48f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -91,6 +91,7 @@ export { genericPoolIntegration, graphqlIntegration, kafkaIntegration, + lruMemoizerIntegration, mongoIntegration, mongooseIntegration, mysqlIntegration, diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index a232d24044dc..702f44e36b7b 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -64,7 +64,13 @@ export function wrap( // the original wrapper. const wrapper = fn.__sentry_wrapped__; if (wrapper) { - return wrapper; + if (typeof wrapper === 'function') { + return wrapper; + } else { + // If we find that the `__sentry_wrapped__` function is not a function at the time of accessing it, it means + // that something messed with it. In that case we want to return the originally passed function. + return fn; + } } // We don't wanna wrap it twice diff --git a/packages/browser/test/integrations/helpers.test.ts b/packages/browser/test/integrations/helpers.test.ts index 37806e06f8a9..ebfabd475e09 100644 --- a/packages/browser/test/integrations/helpers.test.ts +++ b/packages/browser/test/integrations/helpers.test.ts @@ -174,4 +174,17 @@ describe('internal wrap()', () => { expect(wrapped.__sentry_original__).toBe(fn); expect(fn.__sentry_wrapped__).toBe(wrapped); }); + + it('should only return __sentry_wrapped__ when it is a function', () => { + const fn = (() => 1337) as WrappedFunction; + + wrap(fn); + expect(fn).toHaveProperty('__sentry_wrapped__'); + fn.__sentry_wrapped__ = 'something that is not a function' as any; + + const wrapped = wrap(fn); + + expect(wrapped).toBe(fn); + expect(wrapped).not.toBe('something that is not a function'); + }); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index ef3bcb020823..7026e6800b14 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -112,6 +112,7 @@ export { genericPoolIntegration, graphqlIntegration, kafkaIntegration, + lruMemoizerIntegration, mongoIntegration, mongooseIntegration, mysqlIntegration, 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; - } }, ); }; diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 831e8187996e..c56c8f71ba1d 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -8,6 +8,7 @@ import { import { getAsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../carrier'; import { getClient, getCurrentScope } from '../currentScopes'; +import { isEnabled } from '../exports'; import { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from '../tracing'; import { getActiveSpan, getRootSpan, spanToTraceHeader } from './spanUtils'; @@ -23,6 +24,10 @@ import { getActiveSpan, getRootSpan, spanToTraceHeader } from './spanUtils'; * or meta tag name. */ export function getTraceData(): SerializedTraceData { + if (!isEnabled()) { + return {}; + } + const carrier = getMainCarrier(); const acs = getAsyncContextStrategy(carrier); if (acs.getTraceData) { diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index a6fb3c57814e..aa6d2497dd54 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -1,5 +1,6 @@ import { SentrySpan, getTraceData } from '../../../src/'; import * as SentryCoreCurrentScopes from '../../../src/currentScopes'; +import * as SentryCoreExports from '../../../src/exports'; import * as SentryCoreTracing from '../../../src/tracing'; import * as SentryCoreSpanUtils from '../../../src/utils/spanUtils'; @@ -22,6 +23,14 @@ const mockedScope = { } as any; describe('getTraceData', () => { + beforeEach(() => { + jest.spyOn(SentryCoreExports, 'isEnabled').mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('returns the tracing data from the span, if a span is available', () => { { jest.spyOn(SentryCoreTracing, 'getDynamicSamplingContextFromSpan').mockReturnValueOnce({ @@ -139,6 +148,14 @@ describe('getTraceData', () => { expect(traceData).toEqual({}); }); + + it('returns an empty object if the SDK is disabled', () => { + jest.spyOn(SentryCoreExports, 'isEnabled').mockReturnValueOnce(false); + + const traceData = getTraceData(); + + expect(traceData).toEqual({}); + }); }); describe('isValidBaggageString', () => { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index a9d3e5025d92..c2d743eb1bf1 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -91,6 +91,7 @@ export { genericPoolIntegration, graphqlIntegration, kafkaIntegration, + lruMemoizerIntegration, mongoIntegration, mongooseIntegration, mysqlIntegration, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6e86613b5bd2..cd5ef68f077d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -92,7 +92,7 @@ }, "peerDependencies": { "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0", - "webpack": "5.94.0" + "webpack": ">=5.0.0" }, "peerDependenciesMeta": { "webpack": { diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 14c701638ee5..c1633d8fab1b 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -15,7 +15,18 @@ import { vercelWaitUntil } from './utils/vercelWaitUntil'; interface Options { formData?: FormData; - headers?: Headers; + + /** + * Headers as returned from `headers()`. + * + * Currently accepts both a plain `Headers` object and `Promise` to be compatible with async APIs introduced in Next.js 15: https://github.com/vercel/next.js/pull/68812 + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + headers?: Headers | Promise; + + /** + * Whether the server action response should be included in any events captured within the server action. + */ recordResponse?: boolean; } @@ -55,16 +66,17 @@ async function withServerActionInstrumentationImplementation> { return escapeNextjsTracing(() => { - return withIsolationScope(isolationScope => { + return withIsolationScope(async isolationScope => { const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; let sentryTraceHeader; let baggageHeader; const fullHeadersObject: Record = {}; try { - sentryTraceHeader = options.headers?.get('sentry-trace') ?? undefined; - baggageHeader = options.headers?.get('baggage'); - options.headers?.forEach((value, key) => { + const awaitedHeaders: Headers = await options.headers; + sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined; + baggageHeader = awaitedHeaders?.get('baggage'); + awaitedHeaders?.forEach((value, key) => { fullHeadersObject[key] = value; }); } catch (e) { diff --git a/packages/node/package.json b/packages/node/package.json index 490a4aa5fcf0..2eb317c77e01 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -82,6 +82,7 @@ "@opentelemetry/instrumentation-ioredis": "0.43.0", "@opentelemetry/instrumentation-kafkajs": "0.3.0", "@opentelemetry/instrumentation-koa": "0.43.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.40.0", "@opentelemetry/instrumentation-mongodb": "0.47.0", "@opentelemetry/instrumentation-mongoose": "0.42.0", "@opentelemetry/instrumentation-mysql": "0.41.0", diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index e97780f79ead..bc63094e2e87 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -15,6 +15,7 @@ export { expressIntegration, expressErrorHandler, setupExpressErrorHandler } fro export { fastifyIntegration, setupFastifyErrorHandler } from './integrations/tracing/fastify'; export { graphqlIntegration } from './integrations/tracing/graphql'; export { kafkaIntegration } from './integrations/tracing/kafka'; +export { lruMemoizerIntegration } from './integrations/tracing/lrumemoizer'; export { mongoIntegration } from './integrations/tracing/mongo'; export { mongooseIntegration } from './integrations/tracing/mongoose'; export { mysqlIntegration } from './integrations/tracing/mysql'; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index cc8ef752c815..3c038b14354c 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -11,6 +11,7 @@ import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; +import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; import { instrumentMysql, mysqlIntegration } from './mysql'; @@ -45,6 +46,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { kafkaIntegration(), dataloaderIntegration(), amqplibIntegration(), + lruMemoizerIntegration(), ]; } @@ -61,6 +63,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentHapi, instrumentKafka, instrumentKoa, + instrumentLruMemoizer, instrumentNest, instrumentMongo, instrumentMongoose, diff --git a/packages/node/src/integrations/tracing/lrumemoizer.ts b/packages/node/src/integrations/tracing/lrumemoizer.ts new file mode 100644 index 000000000000..d94234c3e57d --- /dev/null +++ b/packages/node/src/integrations/tracing/lrumemoizer.ts @@ -0,0 +1,25 @@ +import { LruMemoizerInstrumentation } from '@opentelemetry/instrumentation-lru-memoizer'; + +import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; + +const INTEGRATION_NAME = 'LruMemoizer'; + +export const instrumentLruMemoizer = generateInstrumentOnce(INTEGRATION_NAME, () => new LruMemoizerInstrumentation()); + +const _lruMemoizerIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentLruMemoizer(); + }, + }; +}) satisfies IntegrationFn; + +/** + * LruMemoizer integration + * + * Propagate traces through LruMemoizer. + */ +export const lruMemoizerIntegration = defineIntegration(_lruMemoizerIntegration); diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 5fbe68bd89cb..bcc14ad1d307 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -1,4 +1,6 @@ import type { init as initNode } from '@sentry/node'; +import type { SentryRollupPluginOptions } from '@sentry/rollup-plugin'; +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { init as initVue } from '@sentry/vue'; // Omitting 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this) @@ -111,4 +113,12 @@ export type SentryNuxtModuleOptions = { * @default false */ experimental_basicServerTracing?: boolean; + + /** + * Options to be passed directly to the Sentry Rollup Plugin (`@sentry/rollup-plugin`) and Sentry Vite Plugin (`@sentry/vite-plugin`) that ship with the Sentry Nuxt SDK. + * You can use this option to override any options the SDK passes to the Vite (for Nuxt) and Rollup (for Nitro) plugin. + * + * Please note that this option is unstable and may change in a breaking way in any release. + */ + unstable_sentryBundlerPluginOptions?: SentryRollupPluginOptions & SentryVitePluginOptions; }; diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 18eed2cbcfd8..9abbfe8eaf08 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -39,7 +39,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu } // Add Sentry plugin - nitroConfig.rollupConfig.plugins.push(sentryRollupPlugin(getPluginOptions(moduleOptions, true))); + nitroConfig.rollupConfig.plugins.push(sentryRollupPlugin(getPluginOptions(moduleOptions))); // Enable source maps nitroConfig.rollupConfig.output = nitroConfig?.rollupConfig?.output || {}; @@ -58,9 +58,13 @@ function normalizePath(path: string): string { return path.replace(/^(\.\.\/)+/, './'); } -function getPluginOptions( +/** + * Generates source maps upload options for the Sentry Vite and Rollup plugin. + * + * Only exported for Testing purposes. + */ +export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, - isNitro = false, ): SentryVitePluginOptions | SentryRollupPluginOptions { const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {}; @@ -69,19 +73,24 @@ function getPluginOptions( project: sourceMapsUploadOptions.project ?? process.env.SENTRY_PROJECT, authToken: sourceMapsUploadOptions.authToken ?? process.env.SENTRY_AUTH_TOKEN, telemetry: sourceMapsUploadOptions.telemetry ?? true, - sourcemaps: { - assets: - sourceMapsUploadOptions.sourcemaps?.assets ?? isNitro ? ['./.output/server/**/*'] : ['./.output/public/**/*'], - ignore: sourceMapsUploadOptions.sourcemaps?.ignore ?? undefined, - filesToDeleteAfterUpload: sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload ?? undefined, - rewriteSources: (source: string) => normalizePath(source), - }, + debug: moduleOptions.debug ?? false, _metaOptions: { telemetry: { metaFramework: 'nuxt', }, }, - debug: moduleOptions.debug ?? false, + ...moduleOptions?.unstable_sentryBundlerPluginOptions, + + sourcemaps: { + // The server/client files are in different places depending on the nitro preset (e.g. '.output/server' or '.netlify/functions-internal/server') + // We cannot determine automatically how the build folder looks like (depends on the preset), so we have to accept that sourcemaps are uploaded multiple times (with the vitePlugin for Nuxt and the rollupPlugin for Nitro). + // If we could know where the server/client assets are located, we could do something like this (based on the Nitro preset): isNitro ? ['./.output/server/**/*'] : ['./.output/public/**/*'], + assets: sourceMapsUploadOptions.sourcemaps?.assets ?? undefined, + ignore: sourceMapsUploadOptions.sourcemaps?.ignore ?? undefined, + filesToDeleteAfterUpload: sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload ?? undefined, + rewriteSources: (source: string) => normalizePath(source), + ...moduleOptions?.unstable_sentryBundlerPluginOptions?.sourcemaps, + }, }; } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 7d794e807fd7..e41d3fb06cab 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -22,7 +22,5 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde } } - const filePath = filePaths.find(filename => fs.existsSync(filename)); - - return filePath ? path.basename(filePath) : undefined; + return filePaths.find(filename => fs.existsSync(filename)); } diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts new file mode 100644 index 000000000000..34c520b96d83 --- /dev/null +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNuxtModuleOptions } from '../../src/common/types'; +import { getPluginOptions } from '../../src/vite/sourceMaps'; + +describe('getPluginOptions', () => { + beforeEach(() => { + vi.resetModules(); + process.env = {}; + }); + + it('uses environment variables when no moduleOptions are provided', () => { + const defaultEnv = { + SENTRY_ORG: 'default-org', + SENTRY_PROJECT: 'default-project', + SENTRY_AUTH_TOKEN: 'default-token', + }; + + process.env = { ...defaultEnv }; + + const options = getPluginOptions({} as SentryNuxtModuleOptions); + + expect(options).toEqual( + expect.objectContaining({ + org: 'default-org', + project: 'default-project', + authToken: 'default-token', + telemetry: true, + sourcemaps: expect.objectContaining({ + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nuxt', + }), + }), + debug: false, + }), + ); + }); + + it('returns default options when no moduleOptions are provided', () => { + const options = getPluginOptions({} as SentryNuxtModuleOptions); + + expect(options.org).toBeUndefined(); + expect(options.project).toBeUndefined(); + expect(options.authToken).toBeUndefined(); + expect(options).toEqual( + expect.objectContaining({ + telemetry: true, + sourcemaps: expect.objectContaining({ + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nuxt', + }), + }), + debug: false, + }), + ); + }); + + it('merges custom moduleOptions with default options', () => { + const customOptions: SentryNuxtModuleOptions = { + sourceMapsUploadOptions: { + org: 'custom-org', + project: 'custom-project', + authToken: 'custom-token', + telemetry: false, + sourcemaps: { + assets: ['custom-assets/**/*'], + ignore: ['ignore-this.js'], + filesToDeleteAfterUpload: ['delete-this.js'], + }, + }, + debug: true, + }; + const options = getPluginOptions(customOptions); + expect(options).toEqual( + expect.objectContaining({ + org: 'custom-org', + project: 'custom-project', + authToken: 'custom-token', + telemetry: false, + sourcemaps: expect.objectContaining({ + assets: ['custom-assets/**/*'], + ignore: ['ignore-this.js'], + filesToDeleteAfterUpload: ['delete-this.js'], + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nuxt', + }), + }), + debug: true, + }), + ); + }); + + it('overrides options that were undefined with options from unstable_sentryRollupPluginOptions', () => { + const customOptions: SentryNuxtModuleOptions = { + sourceMapsUploadOptions: { + org: 'custom-org', + project: 'custom-project', + sourcemaps: { + assets: ['custom-assets/**/*'], + filesToDeleteAfterUpload: ['delete-this.js'], + }, + }, + debug: true, + unstable_sentryBundlerPluginOptions: { + org: 'unstable-org', + sourcemaps: { + assets: ['unstable-assets/**/*'], + }, + release: { + name: 'test-release', + }, + }, + }; + const options = getPluginOptions(customOptions); + expect(options).toEqual( + expect.objectContaining({ + debug: true, + org: 'unstable-org', + project: 'custom-project', + sourcemaps: expect.objectContaining({ + assets: ['unstable-assets/**/*'], + filesToDeleteAfterUpload: ['delete-this.js'], + rewriteSources: expect.any(Function), + }), + release: expect.objectContaining({ + name: 'test-release', + }), + }), + ); + }); +}); diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 0ca81b3e2986..5115742be0f0 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -10,26 +10,26 @@ describe('findDefaultSdkInitFile', () => { }); it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( - 'should return the server file with .%s extension if it exists', + 'should return the server file path with .%s extension if it exists', ext => { vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.includes(`sentry.server.config.${ext}`); }); const result = findDefaultSdkInitFile('server'); - expect(result).toBe(`sentry.server.config.${ext}`); + expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`); }, ); it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])( - 'should return the client file with .%s extension if it exists', + 'should return the client file path with .%s extension if it exists', ext => { vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { return !(filePath instanceof URL) && filePath.includes(`sentry.client.config.${ext}`); }); const result = findDefaultSdkInitFile('client'); - expect(result).toBe(`sentry.client.config.${ext}`); + expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`); }, ); @@ -47,7 +47,7 @@ describe('findDefaultSdkInitFile', () => { expect(result).toBeUndefined(); }); - it('should return the server config file if server.config and instrument exist', () => { + it('should return the server config file path if server.config and instrument exist', () => { vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { return ( !(filePath instanceof URL) && @@ -56,6 +56,6 @@ describe('findDefaultSdkInitFile', () => { }); const result = findDefaultSdkInitFile('server'); - expect(result).toBe('sentry.server.config.js'); + expect(result).toMatch('packages/nuxt/sentry.server.config.js'); }); }); diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index a13f4d24827e..d0ea607e1c06 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -14,8 +14,8 @@ import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent'; export function handleGlobalEventListener(replay: ReplayContainer): (event: Event, hint: EventHint) => Event | null { return Object.assign( (event: Event, hint: EventHint) => { - // Do nothing if replay has been disabled - if (!replay.isEnabled()) { + // Do nothing if replay has been disabled or paused + if (!replay.isEnabled() || replay.isPaused()) { return event; } diff --git a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts index 2c41dbfbfd62..9e888568d04d 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -397,4 +397,23 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { expect(handleGlobalEventListener(replay)(errorEvent, {})).toEqual(errorEvent); }); + + it('does not add replayId if replay is paused', async () => { + const transaction = Transaction(); + const error = Error(); + + replay['_isPaused'] = true; + + expect(handleGlobalEventListener(replay)(transaction, {})).toEqual( + expect.not.objectContaining({ + // no tags at all here by default + tags: expect.anything(), + }), + ); + expect(handleGlobalEventListener(replay)(error, {})).toEqual( + expect.objectContaining({ + tags: expect.not.objectContaining({ replayId: expect.anything() }), + }), + ); + }); }); diff --git a/packages/sveltekit/src/vite/types.ts b/packages/sveltekit/src/vite/types.ts index abd526c1e13a..7102971375d4 100644 --- a/packages/sveltekit/src/vite/types.ts +++ b/packages/sveltekit/src/vite/types.ts @@ -105,6 +105,12 @@ type SourceMapsUploadOptions = { */ inject?: boolean; }; + + /** + * The URL of the Sentry instance to upload the source maps to. + */ + url?: string; + /** * Options to further customize the Sentry Vite Plugin (@sentry/vite-plugin) behavior directly. * Options specified in this object take precedence over the options specified in diff --git a/packages/types/src/checkin.ts b/packages/types/src/checkin.ts index 02811295870e..9d200811183a 100644 --- a/packages/types/src/checkin.ts +++ b/packages/types/src/checkin.ts @@ -2,7 +2,7 @@ import type { TraceContext } from './context'; interface CrontabSchedule { type: 'crontab'; - // The crontab schedule string, e.g. 0 * * * *. + /** The crontab schedule string, e.g. 0 * * * *. */ value: string; } @@ -14,32 +14,37 @@ interface IntervalSchedule { type MonitorSchedule = CrontabSchedule | IntervalSchedule; -// https://develop.sentry.dev/sdk/check-ins/ export interface SerializedCheckIn { - // Check-In ID (unique and client generated). + /** Check-In ID (unique and client generated). */ check_in_id: string; - // The distinct slug of the monitor. + /** The distinct slug of the monitor. */ monitor_slug: string; - // The status of the check-in. + /** The status of the check-in. */ status: 'in_progress' | 'ok' | 'error'; - // The duration of the check-in in seconds. Will only take effect if the status is ok or error. + /** The duration of the check-in in seconds. Will only take effect if the status is ok or error. */ duration?: number; release?: string; environment?: string; monitor_config?: { schedule: MonitorSchedule; - // The allowed allowed margin of minutes after the expected check-in time that - // the monitor will not be considered missed for. + /** + * The allowed allowed margin of minutes after the expected check-in time that + * the monitor will not be considered missed for. + */ checkin_margin?: number; - // The allowed allowed duration in minutes that the monitor may be `in_progress` - // for before being considered failed due to timeout. + /** + * The allowed allowed duration in minutes that the monitor may be `in_progress` + * for before being considered failed due to timeout. + */ max_runtime?: number; - // A tz database string representing the timezone which the monitor's execution schedule is in. - // See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + /** + * A tz database string representing the timezone which the monitor's execution schedule is in. + * See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + */ timezone?: string; - // How many consecutive failed check-ins it takes to create an issue. + /** How many consecutive failed check-ins it takes to create an issue. */ failure_issue_threshold?: number; - // How many consecutive OK check-ins it takes to resolve an issue. + /** How many consecutive OK check-ins it takes to resolve an issue. */ recovery_threshold?: number; }; contexts?: { @@ -48,27 +53,27 @@ export interface SerializedCheckIn { } export interface HeartbeatCheckIn { - // The distinct slug of the monitor. + /** The distinct slug of the monitor. */ monitorSlug: SerializedCheckIn['monitor_slug']; - // The status of the check-in. + /** The status of the check-in. */ status: 'ok' | 'error'; } export interface InProgressCheckIn { - // The distinct slug of the monitor. + /** The distinct slug of the monitor. */ monitorSlug: SerializedCheckIn['monitor_slug']; - // The status of the check-in. + /** The status of the check-in. */ status: 'in_progress'; } export interface FinishedCheckIn { - // The distinct slug of the monitor. + /** The distinct slug of the monitor. */ monitorSlug: SerializedCheckIn['monitor_slug']; - // The status of the check-in. + /** The status of the check-in. */ status: 'ok' | 'error'; - // Check-In ID (unique and client generated). + /** Check-In ID (unique and client generated). */ checkInId: SerializedCheckIn['check_in_id']; - // The duration of the check-in in seconds. Will only take effect if the status is ok or error. + /** The duration of the check-in in seconds. Will only take effect if the status is ok or error. */ duration?: SerializedCheckIn['duration']; } @@ -77,18 +82,27 @@ export type CheckIn = HeartbeatCheckIn | InProgressCheckIn | FinishedCheckIn; type SerializedMonitorConfig = NonNullable; export interface MonitorConfig { + /** + * The schedule on which the monitor should run. Either a crontab schedule string or an interval. + */ schedule: MonitorSchedule; - // The allowed allowed margin of minutes after the expected check-in time that - // the monitor will not be considered missed for. + /** + * The allowed allowed margin of minutes after the expected check-in time that + * the monitor will not be considered missed for. + */ checkinMargin?: SerializedMonitorConfig['checkin_margin']; - // The allowed allowed duration in minutes that the monitor may be `in_progress` - // for before being considered failed due to timeout. + /** + * The allowed allowed duration in minutes that the monitor may be `in_progress` + * for before being considered failed due to timeout. + */ maxRuntime?: SerializedMonitorConfig['max_runtime']; - // A tz database string representing the timezone which the monitor's execution schedule is in. - // See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + /** + * A tz database string representing the timezone which the monitor's execution schedule is in. + * See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + */ timezone?: SerializedMonitorConfig['timezone']; - // How many consecutive failed check-ins it takes to create an issue. + /** How many consecutive failed check-ins it takes to create an issue. */ failureIssueThreshold?: SerializedMonitorConfig['failure_issue_threshold']; - // How many consecutive OK check-ins it takes to resolve an issue. + /** How many consecutive OK check-ins it takes to resolve an issue. */ recoveryThreshold?: SerializedMonitorConfig['recovery_threshold']; } diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 95346cf1f812..0ff9b32da402 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -234,7 +234,7 @@ function _dropUndefinedKeys(inputValue: T, memoizationMap: Map