diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcbef8e06a8e..9ef18fe35116 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', @@ -1007,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/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 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/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 000000000000..57614f9c9675 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico differ 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/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-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/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" 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": { 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/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 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; + } +} 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, diff --git a/packages/utils/src/supports.ts b/packages/utils/src/supports.ts index 2931e9b72c4c..d1ae4b5b96d4 100644 --- a/packages/utils/src/supports.ts +++ b/packages/utils/src/supports.ts @@ -76,12 +76,13 @@ export function supportsFetch(): boolean { return false; } } + /** - * isNativeFetch checks if the given function is a native implementation of fetch() + * isNative checks if the given function is a native implementation */ // eslint-disable-next-line @typescript-eslint/ban-types -export function isNativeFetch(func: Function): boolean { - return func && /^function fetch\(\)\s+\{\s+\[native code\]\s+\}$/.test(func.toString()); +export function isNativeFunction(func: Function): boolean { + return func && /^function\s+\w+\(\)\s+\{\s+\[native code\]\s+\}$/.test(func.toString()); } /** @@ -101,7 +102,7 @@ export function supportsNativeFetch(): boolean { // Fast path to avoid DOM I/O // eslint-disable-next-line @typescript-eslint/unbound-method - if (isNativeFetch(WINDOW.fetch)) { + if (isNativeFunction(WINDOW.fetch)) { return true; } @@ -117,7 +118,7 @@ export function supportsNativeFetch(): boolean { doc.head.appendChild(sandbox); if (sandbox.contentWindow && sandbox.contentWindow.fetch) { // eslint-disable-next-line @typescript-eslint/unbound-method - result = isNativeFetch(sandbox.contentWindow.fetch); + result = isNativeFunction(sandbox.contentWindow.fetch); } doc.head.removeChild(sandbox); } catch (err) { 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"