From eea57bf0254fa232e7aaee9a01387f8be5e2b50f Mon Sep 17 00:00:00 2001 From: Ivana Kellyerova Date: Wed, 22 May 2024 19:01:13 +0200 Subject: [PATCH 1/8] ref(otel): Remove mentions of hub from comments (#12170) Just noticed we still mention the hub in the `ContextManager`, removing that. --- packages/opentelemetry/src/contextManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts index 43568fb34398..46561e3242fd 100644 --- a/packages/opentelemetry/src/contextManager.ts +++ b/packages/opentelemetry/src/contextManager.ts @@ -11,7 +11,7 @@ import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './u import { setIsSetup } from './utils/setupCheck'; /** - * Wrap an OpenTelemetry ContextManager in a way that ensures the context is kept in sync with the Sentry Hub. + * Wrap an OpenTelemetry ContextManager in a way that ensures the context is kept in sync with the Sentry Scope. * * Usage: * import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; @@ -23,7 +23,7 @@ export function wrapContextManagerClass ReturnType>( context: Context, From 5446c365e38d8ce50bbf27d9d5cddfe0731781df Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 22 May 2024 14:55:23 -0230 Subject: [PATCH 2/8] ref(browser): Refactor `isNativeFetch` -> `isNativeFunction` (#12166) This makes the `isNativeFetch` check a bit more generic/naive. It no longer checks that the function name matches `fetch` explicitly, but I think thats ok. Follow-up to https://github.com/getsentry/sentry-javascript/pull/11924 --- .../browser-utils/src/getNativeImplementation.ts | 12 ++---------- packages/utils/src/supports.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/browser-utils/src/getNativeImplementation.ts b/packages/browser-utils/src/getNativeImplementation.ts index e06fb69f561e..6da58bfba623 100644 --- a/packages/browser-utils/src/getNativeImplementation.ts +++ b/packages/browser-utils/src/getNativeImplementation.ts @@ -1,4 +1,4 @@ -import { logger } from '@sentry/utils'; +import { isNativeFunction, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; import { WINDOW } from './types'; @@ -15,14 +15,6 @@ interface CacheableImplementations { const cachedImplementations: Partial = {}; -/** - * isNative checks if the given function is a native implementation - */ -// eslint-disable-next-line @typescript-eslint/ban-types -function isNative(func: Function): boolean { - return func && /^function\s+\w+\(\)\s+\{\s+\[native code\]\s+\}$/.test(func.toString()); -} - /** * Get the native implementation of a browser function. * @@ -43,7 +35,7 @@ export function getNativeImplementation Date: Thu, 23 May 2024 09:27:01 +0200 Subject: [PATCH 3/8] fix(metrics): Ensure string values are interpreted for metrics (#12165) Closes https://github.com/getsentry/sentry-javascript/issues/12164 --- .../browser-integration-tests/suites/metrics/init.js | 7 ++++--- .../browser-integration-tests/suites/metrics/test.ts | 2 +- packages/core/src/metrics/exports.ts | 11 ++++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/metrics/init.js b/dev-packages/browser-integration-tests/suites/metrics/init.js index 8a5032c05eef..97182a9af6e7 100644 --- a/dev-packages/browser-integration-tests/suites/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/metrics/init.js @@ -7,10 +7,11 @@ Sentry.init({ }); Sentry.metrics.increment('increment'); -Sentry.metrics.increment('increment'); +Sentry.metrics.increment('increment', 2); +Sentry.metrics.increment('increment', '3'); Sentry.metrics.distribution('distribution', 42); -Sentry.metrics.distribution('distribution', 45); +Sentry.metrics.distribution('distribution', '45'); Sentry.metrics.gauge('gauge', 5); -Sentry.metrics.gauge('gauge', 15); +Sentry.metrics.gauge('gauge', '15'); Sentry.metrics.set('set', 'nope'); Sentry.metrics.set('set', 'another'); diff --git a/dev-packages/browser-integration-tests/suites/metrics/test.ts b/dev-packages/browser-integration-tests/suites/metrics/test.ts index d73235d876c8..5c6ff8bb13a4 100644 --- a/dev-packages/browser-integration-tests/suites/metrics/test.ts +++ b/dev-packages/browser-integration-tests/suites/metrics/test.ts @@ -12,6 +12,6 @@ sentryTest('collects metrics', async ({ getLocalTestUrl, page }) => { const normalisedStatsdString = statsdString.replace(/T\d+\n?/g, 'T000000'); expect(normalisedStatsdString).toEqual( - 'increment@none:2|c|T000000distribution@none:42:45|d|T000000gauge@none:15:5:15:20:2|g|T000000set@none:3387254:3443787523|s|T000000', + 'increment@none:6|c|T000000distribution@none:42:45|d|T000000gauge@none:15:5:15:20:2|g|T000000set@none:3387254:3443787523|s|T000000', ); }); diff --git a/packages/core/src/metrics/exports.ts b/packages/core/src/metrics/exports.ts index 4fb088287a40..f062b65f72d9 100644 --- a/packages/core/src/metrics/exports.ts +++ b/packages/core/src/metrics/exports.ts @@ -90,7 +90,7 @@ function addToMetricsAggregator( * @experimental This API is experimental and might have breaking changes in the future. */ function increment(aggregator: MetricsAggregatorConstructor, name: string, value: number = 1, data?: MetricData): void { - addToMetricsAggregator(aggregator, COUNTER_METRIC_TYPE, name, value, data); + addToMetricsAggregator(aggregator, COUNTER_METRIC_TYPE, name, ensureNumber(value), data); } /** @@ -99,7 +99,7 @@ function increment(aggregator: MetricsAggregatorConstructor, name: string, value * @experimental This API is experimental and might have breaking changes in the future. */ function distribution(aggregator: MetricsAggregatorConstructor, name: string, value: number, data?: MetricData): void { - addToMetricsAggregator(aggregator, DISTRIBUTION_METRIC_TYPE, name, value, data); + addToMetricsAggregator(aggregator, DISTRIBUTION_METRIC_TYPE, name, ensureNumber(value), data); } /** @@ -117,7 +117,7 @@ function set(aggregator: MetricsAggregatorConstructor, name: string, value: numb * @experimental This API is experimental and might have breaking changes in the future. */ function gauge(aggregator: MetricsAggregatorConstructor, name: string, value: number, data?: MetricData): void { - addToMetricsAggregator(aggregator, GAUGE_METRIC_TYPE, name, value, data); + addToMetricsAggregator(aggregator, GAUGE_METRIC_TYPE, name, ensureNumber(value), data); } export const metrics = { @@ -130,3 +130,8 @@ export const metrics = { */ getMetricsAggregatorForClient, }; + +// Although this is typed to be a number, we try to handle strings as well here +function ensureNumber(number: number | string): number { + return typeof number === 'string' ? parseInt(number) : number; +} From 6c28d4b6a6a64103438452a00112715016896dbd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 23 May 2024 11:52:31 +0200 Subject: [PATCH 4/8] feat(angular): Add Support for Angular 18 (#12183) Add support for Angular 18 in our Angular SDK - bumped peer dependency version range - added e2e test for Angular 18 --- .github/workflows/build.yml | 1 + .../angular-18/.editorconfig | 16 + .../test-applications/angular-18/.gitignore | 42 +++ .../test-applications/angular-18/.npmrc | 2 + .../test-applications/angular-18/README.md | 3 + .../test-applications/angular-18/angular.json | 95 ++++++ .../test-applications/angular-18/package.json | 51 +++ .../angular-18/playwright.config.ts | 77 +++++ .../angular-18/src/app/app.component.ts | 12 + .../angular-18/src/app/app.config.ts | 25 ++ .../angular-18/src/app/app.routes.ts | 42 +++ .../angular-18/src/app/cancel-guard.guard.ts | 5 + .../src/app/cancel/cancel.components.ts | 8 + .../component-tracking.components.ts | 18 + .../angular-18/src/app/home/home.component.ts | 26 ++ .../sample-component.components.ts | 12 + .../angular-18/src/app/user/user.component.ts | 25 ++ .../angular-18/src/favicon.ico | Bin 0 -> 15086 bytes .../angular-18/src/index.html | 13 + .../test-applications/angular-18/src/main.ts | 15 + .../angular-18/src/styles.css | 1 + .../angular-18/start-event-proxy.mjs | 6 + .../angular-18/tests/errors.test.ts | 65 ++++ .../angular-18/tests/performance.test.ts | 313 ++++++++++++++++++ .../angular-18/tsconfig.app.json | 14 + .../angular-18/tsconfig.json | 33 ++ packages/angular/package.json | 6 +- 27 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/.editorconfig create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/README.md create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/angular.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/package.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/index.html create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/src/styles.css create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcbef8e06a8e..a846c6f90e77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -994,6 +994,7 @@ jobs: test-application: [ 'angular-17', + 'angular-18', 'cloudflare-astro', 'node-express', 'create-react-app', diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig new file mode 100644 index 000000000000..59d9a3a3e73f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.gitignore b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore new file mode 100644 index 000000000000..0711527ef9d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.npmrc b/dev-packages/e2e-tests/test-applications/angular-18/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.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/angular-18/README.md b/dev-packages/e2e-tests/test-applications/angular-18/README.md new file mode 100644 index 000000000000..5a2f2e67c868 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/README.md @@ -0,0 +1,3 @@ +# Angular 18 + +E2E test app for Angular 18 and `@sentry/angular`. diff --git a/dev-packages/e2e-tests/test-applications/angular-18/angular.json b/dev-packages/e2e-tests/test-applications/angular-18/angular.json new file mode 100644 index 000000000000..cb3c0b70cec6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/angular.json @@ -0,0 +1,95 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-18": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/angular-18", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-18:build:production" + }, + "development": { + "buildTarget": "angular-18:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "angular-18:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/package.json b/dev-packages/e2e-tests/test-applications/angular-18/package.json new file mode 100644 index 000000000000..1c2bf8b723cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/package.json @@ -0,0 +1,51 @@ +{ + "name": "angular-18", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "dev": "ng serve", + "proxy": "node start-event-proxy.mjs", + "preview": "http-server dist/angular-18/browser --port 8080", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "playwright test", + "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.0.0", + "@angular/common": "^18.0.0", + "@angular/compiler": "^18.0.0", + "@angular/core": "^18.0.0", + "@angular/forms": "^18.0.0", + "@angular/platform-browser": "^18.0.0", + "@angular/platform-browser-dynamic": "^18.0.0", + "@angular/router": "^18.0.0", + "@sentry/angular": "* || latest", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@angular-devkit/build-angular": "^18.0.0", + "@angular/cli": "^18.0.0", + "@angular/compiler-cli": "^18.0.0", + "@playwright/test": "^1.41.1", + "@types/jasmine": "~5.1.0", + "http-server": "^14.1.1", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.4.5", + "wait-port": "1.0.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts new file mode 100644 index 000000000000..df7c2d9758e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const angularPort = 8080; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + fullyParallel: false, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${angularPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}`, + port: angularPort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts new file mode 100644 index 000000000000..ab3efd7e16f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent { + title = 'angular-18'; +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts new file mode 100644 index 000000000000..8267759c8ba1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts @@ -0,0 +1,25 @@ +import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core'; +import { Router, provideRouter } from '@angular/router'; + +import { TraceService, createErrorHandler } from '@sentry/angular'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + { + provide: ErrorHandler, + useValue: createErrorHandler(), + }, + { + provide: TraceService, + deps: [Router], + }, + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [TraceService], + multi: true, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts new file mode 100644 index 000000000000..24bf8b769051 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts @@ -0,0 +1,42 @@ +import { Routes } from '@angular/router'; +import { cancelGuard } from './cancel-guard.guard'; +import { CancelComponent } from './cancel/cancel.components'; +import { ComponentTrackingComponent } from './component-tracking/component-tracking.components'; +import { HomeComponent } from './home/home.component'; +import { UserComponent } from './user/user.component'; + +export const routes: Routes = [ + { + path: 'users/:id', + component: UserComponent, + }, + { + path: 'home', + component: HomeComponent, + }, + { + path: 'cancel', + component: CancelComponent, + canActivate: [cancelGuard], + }, + { + path: 'component-tracking', + component: ComponentTrackingComponent, + }, + { + path: 'redirect1', + redirectTo: '/redirect2', + }, + { + path: 'redirect2', + redirectTo: '/redirect3', + }, + { + path: 'redirect3', + redirectTo: '/users/456', + }, + { + path: '**', + redirectTo: 'home', + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts new file mode 100644 index 000000000000..16ec4a2ab164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts @@ -0,0 +1,5 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; + +export const cancelGuard: CanActivateFn = (_next: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return false; +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts new file mode 100644 index 000000000000..b6ee1876e035 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-cancel', + standalone: true, + template: `
`, +}) +export class CancelComponent {} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts new file mode 100644 index 000000000000..d437a1d43fdd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts @@ -0,0 +1,18 @@ +import { AfterViewInit, Component, OnInit } from '@angular/core'; +import { TraceClass, TraceMethod, TraceModule } from '@sentry/angular'; +import { SampleComponent } from '../sample-component/sample-component.components'; + +@Component({ + selector: 'app-cancel', + standalone: true, + imports: [TraceModule, SampleComponent], + template: ``, +}) +@TraceClass({ name: 'ComponentTrackingComponent' }) +export class ComponentTrackingComponent implements OnInit, AfterViewInit { + @TraceMethod({ name: 'ngOnInit' }) + ngOnInit() {} + + @TraceMethod() + ngAfterViewInit() {} +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts new file mode 100644 index 000000000000..9f36814d6c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [RouterLink], + template: ` +
+

Welcome to Sentry's Angular 18 E2E test app

+ + +
+`, +}) +export class HomeComponent { + throwError() { + throw new Error('Error thrown from Angular 18 E2E test app'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts new file mode 100644 index 000000000000..da09425c7565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-sample-component', + standalone: true, + template: `
Component
`, +}) +export class SampleComponent implements OnInit { + ngOnInit() { + console.log('SampleComponent'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts new file mode 100644 index 000000000000..db02568d395f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts @@ -0,0 +1,25 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [AsyncPipe], + template: ` +

Hello User {{ userId$ | async }}

+ + `, +}) +export class UserComponent { + public userId$: Observable; + + constructor(private route: ActivatedRoute) { + this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER')); + } + + throwError() { + throw new Error('Error thrown from user page'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 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 Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/index.html b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html new file mode 100644 index 000000000000..075475aa383e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular 18 + + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts new file mode 100644 index 000000000000..947f40691b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +import * as Sentry from '@sentry/angular'; + +Sentry.init({ + dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + tracesSampleRate: 1.0, + integrations: [Sentry.browserTracingIntegration({})], + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); + +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css new file mode 100644 index 000000000000..90d4ee0072ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs new file mode 100644 index 000000000000..696f1807d8f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'angular-18', +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts new file mode 100644 index 000000000000..a255d2130dda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('angular-18', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Angular 18 E2E test app', + mechanism: { + type: 'angular', + handled: false, + }, + }, + ], + }, + transaction: '/home/', + }); +}); + +test('assigns the correct transaction value after a navigation', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorPromise = waitForError('angular-18', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + await page.locator('#navLink').click(); + + const [_, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]); + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from user page', + mechanism: { + type: 'angular', + handled: false, + }, + }, + ], + }, + transaction: '/users/:id/', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts new file mode 100644 index 000000000000..12f0fbd41133 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts @@ -0,0 +1,313 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, pageloadTxn, navigationTxn] = await Promise.all([ + page.locator('#navLink').click(), + pageloadTxnPromise, + navigationTxnPromise, + ]); + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('groups redirects within one navigation root span', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/redirect1'); +}); + +test.describe('finish routing span', () => { + test('finishes routing span on navigation cancel', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#cancelLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/cancel', + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/cancel'); + }); + + test('finishes routing span on navigation error', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#nonExistentLink').click(), navigationTxnPromise]); + + const nonExistentRoute = '/non-existent'; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: nonExistentRoute, + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe(nonExistentRoute); + }); +}); + +test.describe('TraceDirective', () => { + test('creates a child tracingSpan with component name as span name on ngOnInit', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const traceDirectiveSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive', + ); + + expect(traceDirectiveSpan).toBeDefined(); + expect(traceDirectiveSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: '', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceClass Decorator', () => { + test('adds init span for decorated class', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const classDecoratorSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_class_decorator', + ); + + expect(classDecoratorSpan).toBeDefined(); + expect(classDecoratorSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + }, + description: '', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_class_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceMethod Decorator', () => { + test('adds name to span description of decorated method `ngOnInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngOnInit'); + + expect(ngInitSpan).toBeDefined(); + expect(ngInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngOnInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngOnInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); + + test('adds fallback name to span description of decorated method `ngAfterViewInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngAfterViewInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngAfterViewInit'); + + expect(ngAfterViewInitSpan).toBeDefined(); + expect(ngAfterViewInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngAfterViewInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngAfterViewInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json new file mode 100644 index 000000000000..374cc9d294aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json new file mode 100644 index 000000000000..f37b67ff0277 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/packages/angular/package.json b/packages/angular/package.json index 5984a62c62f3..d0a363fc8d45 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -15,9 +15,9 @@ "access": "public" }, "peerDependencies": { - "@angular/common": ">= 14.x <= 17.x", - "@angular/core": ">= 14.x <= 17.x", - "@angular/router": ">= 14.x <= 17.x", + "@angular/common": ">= 14.x <= 18.x", + "@angular/core": ">= 14.x <= 18.x", + "@angular/router": ">= 14.x <= 18.x", "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { From a0332fae3b04f2cfd742ab04d02d9aac5305d870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 May 2024 11:50:51 +0000 Subject: [PATCH 5/8] feat(deps): Bump @opentelemetry/instrumentation-aws-lambda from 0.41.0 to 0.41.1 (#12078) --- packages/aws-serverless/package.json | 2 +- yarn.lock | 21 ++++++++------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index eea668a450ae..f979ac407ec5 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -63,7 +63,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/instrumentation-aws-lambda": "0.41.0", + "@opentelemetry/instrumentation-aws-lambda": "0.41.1", "@opentelemetry/instrumentation-aws-sdk": "0.41.0", "@sentry/core": "8.3.0", "@sentry/node": "8.3.0", diff --git a/yarn.lock b/yarn.lock index 9486c769c2c7..176a6abb92d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6177,15 +6177,15 @@ "@opentelemetry/context-base" "^0.12.0" semver "^7.1.3" -"@opentelemetry/instrumentation-aws-lambda@0.41.0": - version "0.41.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.41.0.tgz#91d50792dc77087f1f81c5c3882c237f5734aae8" - integrity sha512-TeK7ZGtmEDqkfuwyAvlexnG11e7kEux0PncShqdyst2h1k1nVKmwnY/woPCUcTyU08PX6fa9YEyJ9E+G6wZacQ== +"@opentelemetry/instrumentation-aws-lambda@0.41.1": + version "0.41.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.41.1.tgz#dc40aefa4e697be5cf411be6f71c16ba4121beda" + integrity sha512-/BLG+0DQr2tCILFGJKJH2Fg6eyjhqOlVflYpNddUEXnzyQ/PAhTdgirkqbICFgeSW2XYcEY9zXpuRldrVNw9cA== dependencies: "@opentelemetry/instrumentation" "^0.51.0" "@opentelemetry/propagator-aws-xray" "^1.3.1" "@opentelemetry/resources" "^1.8.0" - "@opentelemetry/semantic-conventions" "^1.0.0" + "@opentelemetry/semantic-conventions" "^1.22.0" "@types/aws-lambda" "8.10.122" "@opentelemetry/instrumentation-aws-sdk@0.41.0": @@ -6367,7 +6367,7 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz#906ac8e4d804d4109f3ebd5c224ac988276fdc47" integrity sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g== -"@opentelemetry/resources@1.23.0", "@opentelemetry/resources@^1.23.0", "@opentelemetry/resources@^1.8.0": +"@opentelemetry/resources@1.23.0": version "1.23.0" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.23.0.tgz#4c71430f3e20c4d88b67ef5629759fae108485e5" integrity sha512-iPRLfVfcEQynYGo7e4Di+ti+YQTAY0h5mQEUJcHlU9JOqpb4x965O6PZ+wMcwYVY63G96KtdS86YCM1BF1vQZg== @@ -6375,7 +6375,7 @@ "@opentelemetry/core" "1.23.0" "@opentelemetry/semantic-conventions" "1.23.0" -"@opentelemetry/resources@1.24.1": +"@opentelemetry/resources@1.24.1", "@opentelemetry/resources@^1.23.0", "@opentelemetry/resources@^1.8.0": version "1.24.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.24.1.tgz#5e2cb84814824f3b1e1017e6caeeee8402e0ad6e" integrity sha512-cyv0MwAaPF7O86x5hk3NNgenMObeejZFLJJDVuSeSMIsknlsj3oOZzRv3qSzlwYomXsICfBeFFlxwHQte5mGXQ== @@ -7819,16 +7819,11 @@ resolved "https://registry.yarnpkg.com/@types/array.prototype.flat/-/array.prototype.flat-1.2.1.tgz#5433a141730f8e1d7a8e7486458ceb8144ee5edc" integrity sha512-JOvNJUU/zjfJWcA1aHDnCKHwQjZ7VQ3UNfbcMKXrkQKKyMkJHrQ9vpSVMhgsztrtsbIRJKazMDvg2QggFVwJqw== -"@types/aws-lambda@8.10.122": +"@types/aws-lambda@8.10.122", "@types/aws-lambda@^8.10.62": version "8.10.122" resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.122.tgz#206c8d71b09325d26a458dba27db842afdc54df1" integrity sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw== -"@types/aws-lambda@^8.10.62": - version "8.10.73" - resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.73.tgz#77773c9accb2cec26fcb7c6b510a555805604a53" - integrity sha512-P+a6TRQbRnVQOIjWkmw6F23wiJcF+4Uniasbzx7NAXjLQCVGx/Z4VoMfit81/pxlmcXNxAMGuYPugn6CrJLilQ== - "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" From 4caf92c780f967838613c3ae68f10744323f7a22 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 May 2024 14:11:48 +0200 Subject: [PATCH 6/8] ci(e2e): Use react beta for Next.js canary tests (#12186) --- dev-packages/e2e-tests/test-applications/nextjs-14/package.json | 2 +- .../e2e-tests/test-applications/nextjs-app-dir/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json index c597d55a1f17..b5025e7b1232 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json @@ -8,7 +8,7 @@ "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 && 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@latest && npx playwright install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev" }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index c9f87f9cce33..925ee97e79f7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -9,7 +9,7 @@ "test:dev": "TEST_ENV=development playwright test", "test:build": "pnpm install && npx playwright install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", - "test:build-canary": "pnpm install && pnpm add next@canary && 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@latest && npx playwright install && pnpm build", "test:build-13": "pnpm install && pnpm add next@13.4.19 && npx playwright install && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" From d5a332c1a3ec59f9808c64a5e5cdcf3b7eb9d673 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 May 2024 14:12:41 +0200 Subject: [PATCH 7/8] feat(nextjs): Trace pageloads in App Router (#12157) --- .github/workflows/build.yml | 1 + .github/workflows/canary.yml | 6 ++ .../test-applications/nextjs-15/.gitignore | 45 +++++++++++ .../test-applications/nextjs-15/.npmrc | 2 + .../nextjs-15/app/layout.tsx | 7 ++ .../nextjs-15/app/pageload-tracing/layout.tsx | 8 ++ .../nextjs-15/app/pageload-tracing/page.tsx | 14 ++++ .../test-applications/nextjs-15/globals.d.ts | 4 + .../nextjs-15/instrumentation.ts | 9 +++ .../test-applications/nextjs-15/next-env.d.ts | 5 ++ .../nextjs-15/next.config.js | 8 ++ .../test-applications/nextjs-15/package.json | 46 +++++++++++ .../nextjs-15/playwright.config.ts | 80 +++++++++++++++++++ .../nextjs-15/sentry.client.config.ts | 9 +++ .../nextjs-15/sentry.edge.config.ts | 13 +++ .../nextjs-15/sentry.server.config.ts | 13 +++ .../nextjs-15/start-event-proxy.mjs | 6 ++ .../nextjs-15/tests/pageload-tracing.test.ts | 37 +++++++++ .../test-applications/nextjs-15/tsconfig.json | 25 ++++++ .../wrapGenerationFunctionWithSentry.ts | 23 ++++-- .../common/wrapServerComponentWithSentry.ts | 27 ++++--- packages/nextjs/src/config/types.ts | 1 + .../nextjs/src/config/withSentryConfig.ts | 46 ++++++++++- 23 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a846c6f90e77..9ef18fe35116 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1008,6 +1008,7 @@ jobs: 'node-express-esm-without-loader', 'nextjs-app-dir', 'nextjs-14', + 'nextjs-15', 'react-create-hash-router', 'react-router-6-use-routes', 'react-router-5', diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 2771e84ece6b..25004eee8438 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -83,6 +83,12 @@ jobs: - 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)' - test-application: 'react-create-hash-router' build-command: 'test:build-canary' label: 'react-create-hash-router (canary)' diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore new file mode 100644 index 000000000000..e799cc33c4e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.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-15/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.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-15/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..4d2763b992b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I am page 2

; +} + +export async function generateMetadata() { + (await fetch('http://example.com/')).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts new file mode 100644 index 000000000000..7b89a972e157 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js new file mode 100644 index 000000000000..1098c2ce5a4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js @@ -0,0 +1,8 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json new file mode 100644 index 000000000000..dc1d111ee393 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -0,0 +1,46 @@ +{ + "name": "create-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "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@latest && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@playwright/test": "^1.27.1", + "@sentry/nextjs": "latest || *", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "14.3.0-canary.73", + "react": "beta", + "react-dom": "beta", + "typescript": "4.9.5", + "wait-port": "1.0.4" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@sentry-internal/feedback": "latest || *", + "@sentry-internal/replay-canvas": "latest || *", + "@sentry-internal/browser-utils": "latest || *", + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/nextjs": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/react": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *", + "@sentry/vercel-edge": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts new file mode 100644 index 000000000000..0709f27158b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts @@ -0,0 +1,80 @@ +import os from 'os'; +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const nextPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 30_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Defaults to half the number of CPUs. The tests are not really CPU-bound but rather I/O-bound with all the polling we do so we increase the concurrency to the CPU count. */ + workers: os.cpus().length, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${nextPort}`, + trace: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm next dev -p ${nextPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm next start -p ${nextPort}`, + port: nextPort, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts @@ -0,0 +1,9 @@ +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, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts @@ -0,0 +1,13 @@ +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, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts @@ -0,0 +1,13 @@ +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, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs new file mode 100644 index 000000000000..56744b35c7e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15', +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..7893633d3b48 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('all server component transactions should be attached to the pageload request span', async ({ page }) => { + const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'Page Server Component (/pageload-tracing)'; + }); + + const layoutServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'Layout Server Component (/pageload-tracing)'; + }); + + const metadataTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'Page.generateMetadata (/pageload-tracing)'; + }); + + const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === '/pageload-tracing'; + }); + + await page.goto(`/pageload-tracing`); + + const [pageServerComponentTransaction, layoutServerComponentTransaction, metadataTransaction, pageloadTransaction] = + await Promise.all([ + pageServerComponentTransactionPromise, + layoutServerComponentTransactionPromise, + metadataTransactionPromise, + pageloadTransactionPromise, + ]); + + const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; + + expect(pageloadTraceId).toBeTruthy(); + expect(pageServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); + expect(layoutServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); + expect(metadataTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json new file mode 100644 index 000000000000..ef9e351d7a7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules", "playwright.config.ts"] +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index f3998b693b38..0c76821afa28 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -3,6 +3,7 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, captureException, + getActiveSpan, getClient, handleCallbackErrors, startSpanManual, @@ -10,7 +11,7 @@ import { withScope, } from '@sentry/core'; import type { WebFetchHeaders } from '@sentry/types'; -import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; @@ -32,6 +33,7 @@ export function wrapGenerationFunctionWithSentry a const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; return new Proxy(generationFunction, { apply: (originalFunction, thisArg, args) => { + const requestTraceId = getActiveSpan()?.spanContext().traceId; return escapeNextjsTracing(() => { let headers: WebFetchHeaders | undefined = undefined; // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API @@ -50,23 +52,30 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - const incomingPropagationContext = propagationContextFromHeaders( - headers?.get('sentry-trace') ?? undefined, - headers?.get('baggage'), - ); + const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; const isolationScope = commonObjectToIsolationScope(headers); - const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); + isolationScope.setSDKProcessingMetadata({ request: { - headers: headers ? winterCGHeadersToDict(headers) : undefined, + headers: headersDict, }, }); + const propagationContext = commonObjectToPropagationContext( + headers, + headersDict?.['sentry-trace'] + ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) + : { + traceId: requestTraceId || uuid4(), + spanId: uuid4().substring(16), + }, + ); + scope.setExtra('route_data', data); scope.setPropagationContext(propagationContext); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 1234ea448a3d..fe185679528d 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -3,12 +3,13 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, captureException, + getActiveSpan, handleCallbackErrors, startSpanManual, withIsolationScope, withScope, } from '@sentry/core'; -import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; @@ -34,30 +35,32 @@ export function wrapServerComponentWithSentry any> // hook. 🤯 return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { + const requestTraceId = getActiveSpan()?.spanContext().traceId; return escapeNextjsTracing(() => { const isolationScope = commonObjectToIsolationScope(context.headers); - const completeHeadersDict: Record = context.headers - ? winterCGHeadersToDict(context.headers) - : {}; + const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; isolationScope.setSDKProcessingMetadata({ request: { - headers: completeHeadersDict, + headers: headersDict, }, }); - const incomingPropagationContext = propagationContextFromHeaders( - completeHeadersDict['sentry-trace'], - completeHeadersDict['baggage'], - ); - - const propagationContext = commonObjectToPropagationContext(context.headers, incomingPropagationContext); - return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType} Server Component (${componentRoute})`); + const propagationContext = commonObjectToPropagationContext( + context.headers, + headersDict?.['sentry-trace'] + ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) + : { + traceId: requestTraceId || uuid4(), + spanId: uuid4().substring(16), + }, + ); + scope.setPropagationContext(propagationContext); return startSpanManual( { diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f748c5beb115..57601279997c 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,6 +46,7 @@ export type NextConfigObject = { // Next.js experimental options experimental?: { instrumentationHook?: boolean; + clientTraceMetadata?: string[]; }; }; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index f9c815fe6efb..32a4a885f9d7 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -1,5 +1,7 @@ -import { isThenable } from '@sentry/utils'; +import { isThenable, parseSemver } from '@sentry/utils'; +import * as fs from 'fs'; +import { sync as resolveSync } from 'resolve'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -82,6 +84,24 @@ function getFinalConfigObject( ...incomingUserNextConfigObject.experimental, }; + // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). + // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. + const nextJsVersion = getNextjsVersion(); + if (nextJsVersion) { + const { major, minor } = parseSemver(nextJsVersion); + if (major && minor && (major >= 15 || (major === 14 && minor >= 3))) { + incomingUserNextConfigObject.experimental = { + clientTraceMetadata: ['baggage', 'sentry-trace'], + ...incomingUserNextConfigObject.experimental, + }; + } + } else { + // eslint-disable-next-line no-console + console.log( + "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.", + ); + } + return { ...incomingUserNextConfigObject, webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions), @@ -163,3 +183,27 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s } }; } + +function getNextjsVersion(): string | undefined { + const nextjsPackageJsonPath = resolveNextjsPackageJson(); + if (nextjsPackageJsonPath) { + try { + const nextjsPackageJson: { version: string } = JSON.parse( + fs.readFileSync(nextjsPackageJsonPath, { encoding: 'utf-8' }), + ); + return nextjsPackageJson.version; + } catch { + // noop + } + } + + return undefined; +} + +function resolveNextjsPackageJson(): string | undefined { + try { + return resolveSync('next/package.json', { basedir: process.cwd() }); + } catch { + return undefined; + } +} From f596d092ebb25f00925a302c54a619f4968a7eaa Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 May 2024 12:55:44 +0000 Subject: [PATCH 8/8] meta(changelog): Update changelog for 8.3.1 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a883477367..4ebdb34852e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.3.1 + +### Important Changes + +- **feat(nextjs): Trace pageloads in App Router (#12157)** + +If you are using Next.js version `14.3.0-canary.64` or above, the Sentry Next.js SDK will now trace clientside pageloads +with React Server Components. This means, that client-side errors like +`Error: An error occurred in the Server Components render.`, which previously didn't give you much information on how +that error was caused, can now be traced back to a specific error in a server component. + +### Other Changes + +- feat(angular): Add Support for Angular 18 (#12183) +- feat(deps): Bump @opentelemetry/instrumentation-aws-lambda from 0.41.0 to 0.41.1 (#12078) +- fix(metrics): Ensure string values are interpreted for metrics (#12165) + ## 8.3.0 ### Important Changes