diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd039420fc3d..f2eadf738a54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -108,6 +108,10 @@ jobs: - 'packages/replay-canvas/**' - 'packages/feedback/**' - 'packages/wasm/**' + node: &node + - *shared + - 'packages/node/**' + - 'packages/opentelemetry/**' browser_integration: - *shared - *browser @@ -116,21 +120,21 @@ jobs: - *shared - *browser - 'packages/ember/**' - node: + node_integration: - *shared - - 'packages/node/**' + - *node - 'dev-packages/node-integration-tests/**' nextjs: - *shared - *browser + - *node - 'packages/nextjs/**' - - 'packages/node/**' - 'packages/react/**' remix: - *shared - *browser + - *node - 'packages/remix/**' - - 'packages/node/**' - 'packages/react/**' profiling_node: - *shared @@ -157,6 +161,7 @@ jobs: changed_ember: ${{ steps.changed.outputs.ember }} changed_remix: ${{ steps.changed.outputs.remix }} changed_node: ${{ steps.changed.outputs.node }} + changed_node_integration: ${{ steps.changed.outputs.node_integration }} changed_profiling_node: ${{ steps.changed.outputs.profiling_node }} changed_profiling_node_bindings: ${{ steps.changed.outputs.profiling_node_bindings }} changed_deno: ${{ steps.changed.outputs.deno }} @@ -224,6 +229,44 @@ jobs: message: | ⚠️ This PR is opened against **master**. You probably want to open it against **develop**. + job_external_contributor: + name: External Contributors + needs: job_install_deps + runs-on: ubuntu-20.04 + if: | + github.event_name == 'pull_request' + && (github.action == 'opened' || github.action == 'reopened') + && github.event.pull_request.author_association != 'COLLABORATOR' + && github.event.pull_request.author_association != 'MEMBER' + && github.event.pull_request.author_association != 'OWNER' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + - name: Check dependency cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + fail-on-cache-miss: true + + - name: Add external contributor to CHANGELOG.md + uses: ./dev-packages/external-contributor-gh-action + with: + name: ${{ github.event.pull_request.user.login }} + - name: Create PR with changes + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "ref: Add external contributor to CHANGELOG.md" + title: "ref: Add external contributor to CHANGELOG.md" + branch: 'external-contributor/patch-${{ github.event.pull_request.user.login }}' + delete-branch: true + body: This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. + job_build: name: Build needs: [job_get_metadata, job_install_deps] @@ -824,7 +867,7 @@ jobs: Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration Tests needs: [job_get_metadata, job_build] - if: needs.job_get_metadata.outputs.changed_node == 'true' || github.event_name != 'pull_request' + if: needs.job_get_metadata.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 timeout-minutes: 15 strategy: @@ -853,15 +896,15 @@ jobs: - name: Overwrite typescript version if: matrix.typescript - run: yarn add --dev --ignore-workspace-root-check typescript@${{ matrix.typescript }} + run: node ./scripts/use-ts-version.js ${{ matrix.typescript }} + working-directory: dev-packages/node-integration-tests - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} TS_VERSION: ${{ matrix.typescript }} - run: | - cd dev-packages/node-integration-tests - yarn test + working-directory: dev-packages/node-integration-tests + run: yarn test job_remix_integration_tests: name: Remix v${{ matrix.remix }} (Node ${{ matrix.node }}) Tests @@ -874,10 +917,8 @@ jobs: matrix: node: [18, 20, 22] remix: [1, 2] - # Remix v2 only supports Node 18+, so run Node 14, 16 tests separately + # Remix v2 only supports Node 18+, so run 16 tests separately include: - - node: 14 - remix: 1 - node: 16 remix: 1 steps: @@ -999,8 +1040,11 @@ jobs: 'create-react-app', 'create-next-app', 'create-remix-app', + 'create-remix-app-legacy', 'create-remix-app-v2', + 'create-remix-app-v2-legacy', 'create-remix-app-express', + 'create-remix-app-express-legacy', 'create-remix-app-express-vite-dev', 'node-express-esm-loader', 'node-express-esm-preload', diff --git a/CHANGELOG.md b/CHANGELOG.md index fafc53349b60..8100c705347f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.10.0 + +### Important Changes + +- **feat(remix): Migrate to `opentelemetry-instrumentation-remix`. (#12110)** + +You can now simplify your remix instrumentation by opting-in like this: + +```js +const Sentry = require('@sentry/remix'); + +Sentry.init({ + dsn: YOUR_DSN + // opt-in to new auto instrumentation + autoInstrumentRemix: true, +}); +``` + +With this setup, you do not need to add e.g. `wrapExpressCreateRequestHandler` anymore. Additionally, the quality of the +captured data improves. The old way to use `@sentry/remix` continues to work, but it is encouraged to use the new setup. + +### Other Changes + +- feat(browser): Export `thirdPartyErrorFilterIntegration` from `@sentry/browser` (#12512) +- feat(feedback): Allow passing `tags` field to any feedback config param (#12197) +- feat(feedback): Improve screenshot quality for retina displays (#12487) +- feat(feedback): Screenshots don't resize after cropping (#12481) +- feat(node) add max lineno and colno limits (#12514) +- feat(profiling) add global profile context while profiler is running (#12394) +- feat(react): Add React version to events (#12390) +- feat(replay): Add url to replay hydration error breadcrumb type (#12521) +- fix(core): Ensure standalone spans respect sampled flag (#12533) +- fix(core): Use maxValueLength in extra error data integration (#12174) +- fix(feedback): Fix scrolling after feedback submission (#12499) +- fix(feedback): Send feedback rejects invalid responses (#12518) +- fix(nextjs): Update @rollup/plugin-commonjs (#12527) +- fix(node): Ensure status is correct for http server span errors (#12477) +- fix(node): Unify`getDynamicSamplingContextFromSpan` (#12522) +- fix(profiling): continuous profile chunks should be in seconds (#12532) +- fix(remix): Add nativeFetch support for accessing request headers (#12479) +- fix(remix): Export no-op as `captureRemixServerException` from client SDK (#12497) +- ref(node) refactor contextlines to use readline (#12221) + +Work in this release was contributed by @AndreyKovanov and @kiliman. Thank you for your contributions! + ## 8.9.2 - fix(profiling): Update exports so types generate properly (#12469) diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/init.js b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/init.js index 6455e8d8851a..7d36dc233847 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/init.js +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/init.js @@ -4,5 +4,9 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.feedbackIntegration()], + integrations: [ + Sentry.feedbackIntegration({ + tags: { from: 'integration init' }, + }), + ], }); diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts index fede6aa2c0e2..eb16cf1e1848 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts @@ -59,6 +59,9 @@ sentryTest('should capture feedback', async ({ getLocalTestUrl, page }) => { }, }, level: 'info', + tags: { + from: 'integration init', + }, timestamp: expect.any(Number), event_id: expect.stringMatching(/\w{32}/), environment: 'production', diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts index 4457d64ccd9e..5a88a429e53c 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts @@ -95,6 +95,7 @@ sentryTest('should capture feedback', async ({ forceFlushReplay, getLocalTestUrl }, }, level: 'info', + tags: {}, timestamp: expect.any(Number), event_id: expect.stringMatching(/\w{32}/), environment: 'production', diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts index 7af02f35fa47..dd4633620e8b 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts @@ -17,6 +17,9 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { transaction_info: { source: 'route' }, type: 'transaction', contexts: { + react: { + version: '18.2.0', + }, trace: { span_id: expect.any(String), trace_id: expect.any(String), @@ -60,6 +63,9 @@ test('captures a navigation transcation to Sentry', async ({ page }) => { transaction_info: { source: 'route' }, type: 'transaction', contexts: { + react: { + version: '18.2.0', + }, trace: { span_id: expect.any(String), trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.eslintrc.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.eslintrc.cjs new file mode 100644 index 000000000000..7adbd6f482f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.eslintrc.cjs @@ -0,0 +1,79 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ['eslint:recommended'], + + overrides: [ + // React + { + files: ['**/*.{js,jsx,ts,tsx}'], + plugins: ['react', 'jsx-a11y'], + extends: [ + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + ], + settings: { + react: { + version: 'detect', + }, + formComponents: ['Form'], + linkComponents: [ + { name: 'Link', linkAttribute: 'to' }, + { name: 'NavLink', linkAttribute: 'to' }, + ], + 'import/resolver': { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ['**/*.{ts,tsx}'], + plugins: ['@typescript-eslint', 'import'], + parser: '@typescript-eslint/parser', + settings: { + 'import/internal-regex': '^~/', + 'import/resolver': { + node: { + extensions: ['.ts', '.tsx'], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'], + }, + + // Node + { + files: ['.eslintrc.cjs', 'server.js'], + env: { + node: true, + }, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.gitignore b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.gitignore new file mode 100644 index 000000000000..3f7bf98da3e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.npmrc b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/.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/create-remix-app-express-legacy/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/entry.client.tsx new file mode 100644 index 000000000000..5ee250101be9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/entry.client.tsx @@ -0,0 +1,46 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: window.ENV.SENTRY_DSN, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + Sentry.replayIntegration(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + tunnel: 'http://localhost:3031/', // proxy server +}); + +Sentry.addEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/entry.server.tsx new file mode 100644 index 000000000000..4e1e9e0ba537 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/entry.server.tsx @@ -0,0 +1,141 @@ +import * as Sentry from '@sentry/remix'; + +import { PassThrough } from 'node:stream'; +import * as isbotModule from 'isbot'; + +import type { AppLoadContext, EntryContext } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { installGlobals } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToPipeableStream } from 'react-dom/server'; + +installGlobals(); + +const ABORT_DELAY = 5_000; + +export const handleError = Sentry.wrapRemixHandleError; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + return isBotRequest(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +// We have some Remix apps in the wild already running with isbot@3 so we need +// to maintain backwards compatibility even though we want new apps to use +// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. +function isBotRequest(userAgent: string | null) { + if (!userAgent) { + return false; + } + + // isbot >= 3.8.0, >4 + if ('isbot' in isbotModule && typeof isbotModule.isbot === 'function') { + return isbotModule.isbot(userAgent); + } + + // isbot < 3.8.0 + if ('default' in isbotModule && typeof isbotModule.default === 'function') { + return isbotModule.default(userAgent); + } + + return false; +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/root.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/root.tsx new file mode 100644 index 000000000000..517a37a9d76b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/root.tsx @@ -0,0 +1,80 @@ +import { cssBundleHref } from '@remix-run/css-bundle'; +import { LinksFunction, MetaFunction, json } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from '@remix-run/react'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; +import type { SentryMetaArgs } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +export const loader = () => { + return json({ + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }); +}; + +export const meta = ({ data }: SentryMetaArgs>) => { + return [ + { + env: data.ENV, + }, + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const { ENV } = useLoaderData(); + + return ( + + + + +