diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 990361d6ae7c..0b254d24022b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,7 @@ jobs: - 'scripts/**' - 'packages/core/**' - 'packages/tracing/**' + - 'packages/tracing-internal/**' - 'packages/utils/**' - 'packages/types/**' - 'packages/integrations/**' @@ -424,7 +425,7 @@ jobs: name: Nextjs (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_nextjs == 'true' || github.event_name != 'pull_request' - timeout-minutes: 15 + timeout-minutes: 25 runs-on: ubuntu-20.04 strategy: fail-fast: false @@ -654,7 +655,9 @@ jobs: yarn test:package job_node_integration_tests: - name: Node (${{ matrix.node }}) Integration Tests + name: + Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration + Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_node == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 @@ -663,6 +666,12 @@ jobs: fail-fast: false matrix: node: [10, 12, 14, 16, 18, 20] + typescript: + - false + include: + # Only check typescript for latest version (to streamline CI) + - node: 20 + typescript: '3.8' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -676,6 +685,11 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Overwrite typescript version + if: matrix.typescript + run: yarn add --dev --ignore-workspace-root-check typescript@${{ matrix.typescript }} + - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} @@ -684,7 +698,7 @@ jobs: yarn test job_remix_integration_tests: - name: Remix (Node ${{ matrix.node }}) Tests + name: Remix v${{ matrix.remix }} (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 @@ -693,6 +707,7 @@ jobs: fail-fast: false matrix: node: [14, 16, 18] + remix: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -709,12 +724,13 @@ jobs: - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} + REMIX_VERSION: ${{ matrix.remix }} run: | cd packages/remix yarn test:integration:ci job_e2e_tests: - name: E2E Tests (Shard ${{ matrix.shard }}) + name: E2E (Shard ${{ matrix.shard }}) Tests # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks # Dependabot PRs sadly also don't have access to secrets, so we skip them as well if: @@ -727,6 +743,7 @@ jobs: fail-fast: false matrix: shard: [1, 2, 3] + steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -743,6 +760,7 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Get node version id: versions run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index da270fb1ccec..f2b1e7168f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.57.0 + +### Important Changes + +- **build: Update typescript from 3.8.3 to 4.9.5 (#8255)** + +This release version [bumps the internally used typescript version from 3.8.x to 4.9.x](https://github.com/getsentry/sentry-javascript/pull/8255). +We use ds-downlevel to generate two versions of our types, one for >=3.8, one for >=4.9. +This means that this change should be fully backwards compatible and not have any noticable user impact, +but if you still encounter issues please let us know. + +- **feat(types): Add tracePropagationTargets to top level options (#8395)** + +Instead of passing `tracePropagationTargets` to the `BrowserTracing` integration, you can now define them on the top level: + +```js +Sentry.init({ + tracePropagationTargets: ['api.site.com'], +}); +``` + +- **fix(angular): Filter out `TryCatch` integration by default (#8367)** + +The Angular and Angular-ivy SDKs will not install the TryCatch integration anymore by default. +This integration conflicted with the `SentryErrorHander`, sometimes leading to duplicated errors and/or missing data on events. + +- **feat(browser): Better event name handling for non-Error objects (#8374)** + +When capturing non-errors via `Sentry.captureException()`, e.g. `Sentry.captureException({ prop: "custom object" })`, +we now generate a more helpful value for the synthetic exception. Instead of e.g. `Non-Error exception captured with keys: currentTarget, isTrusted, target, type`, you'll now get messages like: + +``` +Object captured as exception with keys: prop1, prop2 +Event `MouseEvent` (type=click) captured as exception +Event `ErrorEvent` captured as exception with message `Script error.` +``` + +### Other Changes + +- feat(browser): Send profiles in same envelope as transactions (#8375) +- feat(profiling): Collect timings on profiler stop calls (#8409) +- feat(replay): Do not capture replays < 5 seconds (GA) (#8277) +- feat(tracing): Add experiment to capture http timings (#8371) +- feat(tracing): Add `http.response.status_code` to `span.data` (#8366) +- fix(angular): Stop routing spans on navigation cancel and error events (#8369) +- fix(core): Only start spans in `trace` if tracing is enabled (#8357) +- fix(nextjs): Inject init calls via loader instead of via entrypoints (#8368) +- fix(replay): Mark ui.slowClickDetected `clickCount` as optional (#8376) +- fix(serverless): Export `autoDiscoverNodePerformanceMonitoringIntegrations` from SDK (#8382) +- fix(sveltekit): Check for cached requests in client-side fetch instrumentation (#8391) +- fix(sveltekit): Only instrument SvelteKit `fetch` if the SDK client is valid (#8381) +- fix(tracing): Instrument Prisma client in constructor of integration (#8383) +- ref(replay): More graceful `sessionStorage` check (#8394) +- ref(replay): Remove circular dep in replay eventBuffer (#8389) + ## 7.56.0 - feat(replay): Rework slow click & multi click detection (#8322) diff --git a/nx.json b/nx.json index 096a0b0c9620..2341174dd956 100644 --- a/nx.json +++ b/nx.json @@ -64,7 +64,9 @@ ], "outputs": [ "{projectRoot}/build/types", - "{projectRoot}/build/npm/types" + "{projectRoot}/build/types-ts3.8", + "{projectRoot}/build/npm/types", + "{projectRoot}/build/npm/types-ts3.8" ] }, "lint:eslint": { diff --git a/package.json b/package.json index 8ebd0541f657..feb5be7839d0 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "chai": "^4.1.2", "codecov": "^3.6.5", "deepmerge": "^4.2.2", + "downlevel-dts": "~0.11.0", "es-check": "7.1.0", "eslint": "7.32.0", "jest": "^27.5.1", @@ -114,9 +115,9 @@ "size-limit": "^4.5.5", "ts-jest": "^27.1.4", "ts-node": "10.9.1", - "tslib": "^2.3.1", + "tslib": "2.4.1", "typedoc": "^0.18.0", - "typescript": "3.8.3", + "typescript": "4.9.5", "vitest": "^0.29.2", "yalc": "^1.0.0-pre.53" }, diff --git a/packages/angular-ivy/package.json b/packages/angular-ivy/package.json index b18a7e014fe3..f05c8de96ebf 100644 --- a/packages/angular-ivy/package.json +++ b/packages/angular-ivy/package.json @@ -24,7 +24,7 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^2.3.0" + "tslib": "^2.4.1" }, "devDependencies": { "@angular-devkit/build-angular": "~12.2.18", @@ -37,7 +37,6 @@ "@angular/platform-browser-dynamic": "~12.2.0", "@angular/router": "~12.2.0", "ng-packagr": "^12.1.1", - "typescript": "~4.3.5", "zone.js": "~0.11.4" }, "scripts": { diff --git a/packages/angular-ivy/src/sdk.ts b/packages/angular-ivy/src/sdk.ts index d16d16009ca0..fcbbbce399d0 100644 --- a/packages/angular-ivy/src/sdk.ts +++ b/packages/angular-ivy/src/sdk.ts @@ -1,6 +1,6 @@ import { VERSION } from '@angular/core'; import type { BrowserOptions } from '@sentry/browser'; -import { init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; +import { defaultIntegrations, init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; import { logger } from '@sentry/utils'; import { IS_DEBUG_BUILD } from './flags'; @@ -21,6 +21,18 @@ export function init(options: BrowserOptions): void { version: SDK_VERSION, }; + // Filter out TryCatch integration as it interferes with our Angular `ErrorHandler`: + // TryCatch would catch certain errors before they reach the `ErrorHandler` and thus provide a + // lower fidelity error than what `SentryErrorHandler` (see errorhandler.ts) would provide. + // see: + // - https://github.com/getsentry/sentry-javascript/issues/5417#issuecomment-1453407097 + // - https://github.com/getsentry/sentry-javascript/issues/2744 + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = defaultIntegrations.filter(integration => { + return integration.name !== 'TryCatch'; + }); + } + checkAndSetAngularVersion(); browserInit(options); } diff --git a/packages/angular/package.json b/packages/angular/package.json index 39a12c3303a7..ad1c7d7aa272 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -24,7 +24,7 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^2.0.0" + "tslib": "^2.4.1" }, "devDependencies": { "@angular-devkit/build-angular": "~0.1002.4", @@ -38,7 +38,7 @@ "@angular/router": "~10.2.5", "ng-packagr": "^10.1.0", "rxjs": "6.5.5", - "typescript": "~4.0.2", + "typescript": "4.0.2", "zone.js": "^0.11.8" }, "scripts": { diff --git a/packages/angular/src/errorhandler.ts b/packages/angular/src/errorhandler.ts index 89f3b8b556ac..6940e0cc8cc1 100644 --- a/packages/angular/src/errorhandler.ts +++ b/packages/angular/src/errorhandler.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import type { ErrorHandler as AngularErrorHandler } from '@angular/core'; import { Inject, Injectable } from '@angular/core'; import * as Sentry from '@sentry/browser'; -import { captureException } from '@sentry/browser'; +import type { Event, Scope } from '@sentry/types'; import { addExceptionMechanism, isString } from '@sentry/utils'; import { runOutsideAngular } from './zone'; @@ -101,7 +101,7 @@ class SentryErrorHandler implements AngularErrorHandler { // Capture handled exception and send it to Sentry. const eventId = runOutsideAngular(() => - captureException(extractedError, scope => { + Sentry.captureException(extractedError, (scope: Scope) => { scope.addEventProcessor(event => { addExceptionMechanism(event, { type: 'angular', @@ -126,7 +126,7 @@ class SentryErrorHandler implements AngularErrorHandler { const client = Sentry.getCurrentHub().getClient(); if (client && client.on && !this._registeredAfterSendEventHandler) { - client.on('afterSendEvent', event => { + client.on('afterSendEvent', (event: Event) => { if (!event.type) { Sentry.showReportDialog({ ...this._options.dialogOptions, eventId: event.event_id }); } diff --git a/packages/angular/src/sdk.ts b/packages/angular/src/sdk.ts index 4afce6259c2b..e50cece043d0 100755 --- a/packages/angular/src/sdk.ts +++ b/packages/angular/src/sdk.ts @@ -1,6 +1,6 @@ import { VERSION } from '@angular/core'; import type { BrowserOptions } from '@sentry/browser'; -import { init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; +import { defaultIntegrations, init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; import { logger } from '@sentry/utils'; import { IS_DEBUG_BUILD } from './flags'; @@ -21,6 +21,18 @@ export function init(options: BrowserOptions): void { version: SDK_VERSION, }; + // Filter out TryCatch integration as it interferes with our Angular `ErrorHandler`: + // TryCatch would catch certain errors before they reach the `ErrorHandler` and thus provide a + // lower fidelity error than what `SentryErrorHandler` (see errorhandler.ts) would provide. + // see: + // - https://github.com/getsentry/sentry-javascript/issues/5417#issuecomment-1453407097 + // - https://github.com/getsentry/sentry-javascript/issues/2744 + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = defaultIntegrations.filter(integration => { + return integration.name !== 'TryCatch'; + }); + } + checkAndSetAngularVersion(); browserInit(options); } diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index b206d7fe429d..f2e79adfe9b0 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -5,7 +5,7 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router // Duplicated import to work around a TypeScript bug where it'd complain that `Router` isn't imported as a type. // We need to import it as a value to satisfy Angular dependency injection. So: // eslint-disable-next-line @typescript-eslint/consistent-type-imports, import/no-duplicates -import { Router } from '@angular/router'; +import { NavigationCancel, NavigationError, Router } from '@angular/router'; // eslint-disable-next-line import/no-duplicates import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router'; import { getCurrentHub, WINDOW } from '@sentry/browser'; @@ -131,7 +131,9 @@ export class TraceService implements OnDestroy { ); public navEnd$: Observable = this._router.events.pipe( - filter(event => event instanceof NavigationEnd), + filter( + event => event instanceof NavigationEnd || event instanceof NavigationCancel || event instanceof NavigationError, + ), tap(() => { if (this._routingSpan) { runOutsideAngular(() => { diff --git a/packages/angular/test/errorhandler.test.ts b/packages/angular/test/errorhandler.test.ts index c43ad41629c1..633d4d81f7e9 100644 --- a/packages/angular/test/errorhandler.test.ts +++ b/packages/angular/test/errorhandler.test.ts @@ -57,7 +57,7 @@ describe('SentryErrorHandler', () => { describe('handleError method', () => { it('handleError method assigns the correct mechanism', () => { const addEventProcessorSpy = jest.spyOn(FakeScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); + void (callback as (event: any, hint: any) => void)({}, { event_id: 'fake-event-id' }); return FakeScope; }); diff --git a/packages/angular/test/sdk.test.ts b/packages/angular/test/sdk.test.ts index bf5ecabb0ac5..0a7244d424f7 100644 --- a/packages/angular/test/sdk.test.ts +++ b/packages/angular/test/sdk.test.ts @@ -1,6 +1,6 @@ import * as SentryBrowser from '@sentry/browser'; -import { init } from '../src/sdk'; +import { defaultIntegrations, init } from '../src/index'; describe('init', () => { it('sets the Angular version (if available) in the global scope', () => { @@ -13,4 +13,33 @@ describe('init', () => { expect(setContextSpy).toHaveBeenCalledTimes(1); expect(setContextSpy).toHaveBeenCalledWith('angular', { version: 10 }); }); + + describe('filtering out the `TryCatch` integration', () => { + const browserInitSpy = jest.spyOn(SentryBrowser, 'init'); + + beforeEach(() => { + browserInitSpy.mockClear(); + }); + + it('filters if `defaultIntegrations` is not set', () => { + init({}); + + expect(browserInitSpy).toHaveBeenCalledTimes(1); + + const options = browserInitSpy.mock.calls[0][0] || {}; + expect(options.defaultIntegrations).not.toContainEqual(expect.objectContaining({ name: 'TryCatch' })); + }); + + it.each([false as const, defaultIntegrations])( + "doesn't filter if `defaultIntegrations` is set to %s", + defaultIntegrations => { + init({ defaultIntegrations }); + + expect(browserInitSpy).toHaveBeenCalledTimes(1); + + const options = browserInitSpy.mock.calls[0][0] || {}; + expect(options.defaultIntegrations).toEqual(defaultIntegrations); + }, + ); + }); }); diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index 0afef2771add..a3375518466a 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import type { ActivatedRouteSnapshot } from '@angular/router'; +import type { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; import type { Hub } from '@sentry/types'; import { instrumentAngularRouting, TraceClassDecorator, TraceDirective, TraceMethodDecorator } from '../src'; @@ -185,6 +185,66 @@ describe('Angular Tracing', () => { env.destroy(); }); + it('finishes routing span on navigation error', async () => { + const customStartTransaction = jest.fn(defaultStartTransaction); + + const env = await TestEnv.setup({ + customStartTransaction, + routes: [ + { + path: '', + component: AppComponent, + }, + ], + useTraceService: true, + }); + + const finishMock = jest.fn(); + transaction.startChild = jest.fn(() => ({ + finish: finishMock, + })); + + await env.navigateInAngular('/somewhere'); + + expect(finishMock).toHaveBeenCalledTimes(1); + + env.destroy(); + }); + + it('finishes routing span on navigation cancel', async () => { + const customStartTransaction = jest.fn(defaultStartTransaction); + + class CanActivateGuard implements CanActivate { + canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean { + return false; + } + } + + const env = await TestEnv.setup({ + customStartTransaction, + routes: [ + { + path: 'cancel', + component: AppComponent, + canActivate: [CanActivateGuard], + }, + ], + useTraceService: true, + additionalProviders: [{ provide: CanActivateGuard, useClass: CanActivateGuard }], + }); + + const finishMock = jest.fn(); + transaction.startChild = jest.fn(() => ({ + finish: finishMock, + })); + + await env.navigateInAngular('/cancel'); + + expect(finishMock).toHaveBeenCalledTimes(1); + + env.destroy(); + }); + describe('URL parameterization', () => { it.each([ [ diff --git a/packages/angular/test/utils/index.ts b/packages/angular/test/utils/index.ts index b15ad2028560..daa23155d931 100644 --- a/packages/angular/test/utils/index.ts +++ b/packages/angular/test/utils/index.ts @@ -1,3 +1,4 @@ +import type { Provider } from '@angular/core'; import { Component, NgModule } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; @@ -47,6 +48,7 @@ export class TestEnv { startTransactionOnPageLoad?: boolean; startTransactionOnNavigation?: boolean; useTraceService?: boolean; + additionalProviders?: Provider[]; }): Promise { instrumentAngularRouting( conf.customStartTransaction || jest.fn(), @@ -60,14 +62,16 @@ export class TestEnv { TestBed.configureTestingModule({ imports: [AppModule, RouterTestingModule.withRoutes(routes)], declarations: [...(conf.components || []), AppComponent], - providers: useTraceService + providers: (useTraceService ? [ { provide: TraceService, deps: [Router], }, + ...(conf.additionalProviders || []), ] - : [], + : [] + ).concat(...(conf.additionalProviders || [])), }); const router: Router = TestBed.inject(Router); @@ -80,10 +84,16 @@ export class TestEnv { public async navigateInAngular(url: string): Promise { return new Promise(resolve => { return this.fixture.ngZone?.run(() => { - void this.router.navigateByUrl(url).then(() => { - this.fixture.detectChanges(); - resolve(); - }); + void this.router + .navigateByUrl(url) + .then(() => { + this.fixture.detectChanges(); + resolve(); + }) + .catch(() => { + this.fixture.detectChanges(); + resolve(); + }); }); }); } diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index 63e61ebf57b7..9d95ca877c9c 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -53,7 +53,6 @@ "html-webpack-plugin": "^5.5.0", "pako": "^2.1.0", "playwright": "^1.31.1", - "typescript": "^4.5.2", "webpack": "^5.52.0" }, "devDependencies": { diff --git a/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js new file mode 100644 index 000000000000..d2d2b96a87fe --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js @@ -0,0 +1,6 @@ +class MyTestClass { + prop1 = 'value1'; + prop2 = 2; +} + +Sentry.captureException(new MyTestClass()); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts new file mode 100644 index 000000000000..3a8865ec3672 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an POJO', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Object captured as exception with keys: prop1, prop2', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/empty_obj/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/emptyObj/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts similarity index 91% rename from packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts index 6ce86bfe7aeb..fa6b1dcb1562 100644 --- a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts +++ b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts @@ -12,7 +12,7 @@ sentryTest('should capture an empty object', async ({ getLocalTestPath, page }) expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'Error', - value: 'Non-Error exception captured with keys: [object has no keys]', + value: 'Object captured as exception with keys: [object has no keys]', mechanism: { type: 'generic', handled: true, diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js new file mode 100644 index 000000000000..3796a084234a --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js new file mode 100644 index 000000000000..207f9d1d58f6 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js @@ -0,0 +1,5 @@ +window.addEventListener('error', function (event) { + Sentry.captureException(event); +}); + +window.thisDoesNotExist(); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts new file mode 100644 index 000000000000..dbcaaf24a1cf --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an ErrorEvent', async ({ getLocalTestPath, page, browserName }) => { + // On Firefox, the ErrorEvent has the `error` property and thus is handled separately + if (browserName === 'firefox') { + sentryTest.skip(); + } + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'ErrorEvent', + value: 'Event `ErrorEvent` captured as exception with message `Script error.`', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js new file mode 100644 index 000000000000..b5855af22829 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js @@ -0,0 +1 @@ +Sentry.captureException(new Event('custom')); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts new file mode 100644 index 000000000000..65c46a776731 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an Event', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Event', + value: 'Event `Event` (type=custom) captured as exception', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js new file mode 100644 index 000000000000..ea827971bed4 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js @@ -0,0 +1,4 @@ +Sentry.captureException({ + prop1: 'value1', + prop2: 2, +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts new file mode 100644 index 000000000000..e81fe0125906 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an class instance', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Object captured as exception with keys: prop1, prop2', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/simple_error/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/simpleError/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/simple_error/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/simpleError/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/simple_error/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/simpleError/test.ts similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/simple_error/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/simpleError/test.ts diff --git a/packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/test.ts similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/test.ts diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts index a9a9dbbe86e5..4785c1a5b158 100644 --- a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -25,7 +25,6 @@ sentryTest( let errorEventId: string | undefined; const reqPromise0 = waitForReplayRequest(page, 0); const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); const reqErrorPromise = waitForErrorRequest(page); await page.route('https://dsn.ingest.sentry.io/**/*', route => { @@ -101,8 +100,7 @@ sentryTest( // Switches to session mode and then goes to background const req1 = await reqPromise1; - const req2 = await reqPromise2; - expect(callsToSentry).toBeGreaterThanOrEqual(5); + expect(callsToSentry).toBeGreaterThanOrEqual(4); const event0 = getReplayEvent(req0); const content0 = getReplayRecordingContent(req0); @@ -110,9 +108,6 @@ sentryTest( const event1 = getReplayEvent(req1); const content1 = getReplayRecordingContent(req1); - const event2 = getReplayEvent(req2); - const content2 = getReplayRecordingContent(req2); - expect(event0).toEqual( getExpectedReplayEvent({ error_ids: [errorEventId!], @@ -157,17 +152,7 @@ sentryTest( // From switching to session mode expect(content1.fullSnapshots).toHaveLength(1); - - expect(event2).toEqual( - getExpectedReplayEvent({ - replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type - segment_id: 2, - urls: [], - }), - ); - - expect(content2.fullSnapshots).toHaveLength(0); - expect(content2.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); + expect(content1.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); }, ); diff --git a/packages/browser-integration-tests/suites/replay/fileInput/test.ts b/packages/browser-integration-tests/suites/replay/fileInput/test.ts index 685c626ec470..e0827538ba56 100644 --- a/packages/browser-integration-tests/suites/replay/fileInput/test.ts +++ b/packages/browser-integration-tests/suites/replay/fileInput/test.ts @@ -25,7 +25,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -39,7 +38,7 @@ sentryTest( await page.goto(url); - await reqPromise0; + const res = await reqPromise0; await page.setInputFiles('#file-input', { name: 'file.csv', @@ -49,9 +48,7 @@ sentryTest( await forceFlushReplay(); - const res1 = await reqPromise1; - - const snapshots = getIncrementalRecordingSnapshots(res1).filter(isInputMutation); + const snapshots = getIncrementalRecordingSnapshots(res).filter(isInputMutation); expect(snapshots).toEqual([]); }, diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts index 29d0f3ada164..c0d8e8234da8 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts @@ -11,7 +11,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -24,10 +23,7 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await forceFlushReplay(); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts index 84f0113263d7..b826daafe6b4 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -16,7 +16,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -30,8 +29,6 @@ sentryTest( await page.goto(url); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index deb394ebac2d..bab50e12938c 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; -sentryTest('mutation after threshold results in slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('mutation after threshold results in slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -21,6 +21,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { @@ -125,59 +126,63 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100); }); -sentryTest('immediate mutation does not trigger slow click', async ({ browserName, getLocalTestUrl, page }) => { - // This test seems to only be flakey on firefox - if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { - sentryTest.skip(); - } - - const reqPromise0 = waitForReplayRequest(page, 0); - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), +sentryTest( + 'immediate mutation does not trigger slow click', + async ({ forceFlushReplay, browserName, getLocalTestUrl, page }) => { + // This test seems to only be flakey on firefox + if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); }); - }); - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await page.goto(url); + await forceFlushReplay(); + await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); - - await page.click('#mutationButtonImmediately'); - - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); - expect(breadcrumbs).toEqual([ - { - category: 'ui.click', - data: { - node: { - attributes: { - id: 'mutationButtonImmediately', + await page.click('#mutationButtonImmediately'); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs).toEqual([ + { + category: 'ui.click', + data: { + node: { + attributes: { + id: 'mutationButtonImmediately', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ******** ***********', }, - id: expect.any(Number), - tagName: 'button', - textContent: '******* ******** ***********', + nodeId: expect.any(Number), }, - nodeId: expect.any(Number), + message: 'body > button#mutationButtonImmediately', + timestamp: expect.any(Number), + type: 'default', }, - message: 'body > button#mutationButtonImmediately', - timestamp: expect.any(Number), - type: 'default', - }, - ]); -}); + ]); + }, +); -sentryTest('inline click handler does not trigger slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('inline click handler does not trigger slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -195,6 +200,7 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { diff --git a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts index 17f4210624a0..e025c90a77e0 100644 --- a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts +++ b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts @@ -26,16 +26,19 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); - await reqPromise0; + await forceFlushReplay(); + const res0 = getCustomRecordingEvents(await reqPromise0); await page.click('[data-console]'); await forceFlushReplay(); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const res1 = getCustomRecordingEvents(await reqPromise1); - // 1 click breadcrumb + 1 throttled breadcrumb is why console logs are less - // than throttle limit - expect(breadcrumbs.length).toBe(THROTTLE_LIMIT); + const breadcrumbs = [...res0.breadcrumbs, ...res1.breadcrumbs]; + const spans = [...res0.performanceSpans, ...res1.performanceSpans]; expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'replay.throttled').length).toBe(1); + // replay.throttled breadcrumb does *not* use the throttledAddEvent as we + // alwants want that breadcrumb to be present in replay + expect(breadcrumbs.length + spans.length).toBe(THROTTLE_LIMIT + 1); }, ); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js new file mode 100644 index 000000000000..efe1e2ef9778 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; +import { Integrations } from '@sentry/tracing'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + new Integrations.BrowserTracing({ + idleTimeout: 1000, + _experiments: { + enableHTTPTimings: true, + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js new file mode 100644 index 000000000000..f62499b1e9c5 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js @@ -0,0 +1 @@ +fetch('http://example.com/0').then(fetch('http://example.com/1').then(fetch('http://example.com/2'))); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts new file mode 100644 index 000000000000..c4b5d3e92e62 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should create fetch spans with http timing', async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + await page.route('http://example.com/*', async route => { + const request = route.request(); + const postData = await request.postDataJSON(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Object.assign({ id: 1 }, postData)), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + const requestSpans = tracingEvent.spans?.filter(({ op }) => op === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + await page.pause(); + requestSpans?.forEach((span, index) => + expect(span).toMatchObject({ + description: `GET http://example.com/${index}`, + parent_span_id: tracingEvent.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: tracingEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.request.connect_start': expect.any(Number), + 'http.request.request_start': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'network.protocol.version': expect.any(String), + }), + }), + ); +}); diff --git a/packages/browser-integration-tests/utils/helpers.ts b/packages/browser-integration-tests/utils/helpers.ts index 296b2fcdba91..525877e9763f 100644 --- a/packages/browser-integration-tests/utils/helpers.ts +++ b/packages/browser-integration-tests/utils/helpers.ts @@ -268,7 +268,7 @@ async function injectScriptAndGetEvents(page: Page, url: string, scriptPath: str await page.goto(url); await runScriptInSandbox(page, scriptPath); - return await getSentryEvents(page); + return getSentryEvents(page); } export { diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts index ec332edea74d..bf070b395cf6 100644 --- a/packages/browser-integration-tests/utils/replayHelpers.ts +++ b/packages/browser-integration-tests/utils/replayHelpers.ts @@ -132,14 +132,14 @@ export async function waitForReplayRunning(page: Page): Promise { * Note that due to how this works with playwright, this is a POJO copy of replay. * This means that we cannot access any methods on it, and also not mutate it in any way. */ -export async function getReplaySnapshot(page: Page): Promise<{ +export function getReplaySnapshot(page: Page): Promise<{ _isPaused: boolean; _isEnabled: boolean; _context: InternalEventContext; session: Session | undefined; recordingMode: ReplayRecordingMode; }> { - return await page.evaluate(() => { + return page.evaluate(() => { const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; const replay = replayIntegration._replay; diff --git a/packages/browser/package.json b/packages/browser/package.json index d59b14ce1b48..6ec505295c27 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -12,6 +12,9 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -21,7 +24,7 @@ "@sentry/replay": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@sentry-internal/integration-shims": "7.56.0", @@ -53,7 +56,9 @@ "build:bundle:es5": "JS_VERSION=es5 rollup -c rollup.bundle.config.js", "build:bundle:es6": "JS_VERSION=es6 rollup -c rollup.bundle.config.js", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:bundle:watch": "rollup -c rollup.bundle.config.js --watch", diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 4db58b9b7b43..e361f1366cf3 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -14,6 +14,8 @@ import { resolvedSyncPromise, } from '@sentry/utils'; +type Prototype = { constructor: (...args: unknown[]) => unknown }; + /** * This function creates an exception from a JavaScript Error */ @@ -55,9 +57,7 @@ export function eventFromPlainObject( values: [ { type: isEvent(exception) ? exception.constructor.name : isUnhandledRejection ? 'UnhandledRejection' : 'Error', - value: `Non-Error ${ - isUnhandledRejection ? 'promise rejection' : 'exception' - } captured with keys: ${extractExceptionKeysForMessage(exception)}`, + value: getNonErrorObjectExceptionValue(exception, { isUnhandledRejection }), }, ], }, @@ -208,7 +208,7 @@ export function eventFromUnknownInput( // https://developer.mozilla.org/en-US/docs/Web/API/DOMError // https://developer.mozilla.org/en-US/docs/Web/API/DOMException // https://webidl.spec.whatwg.org/#es-DOMException-specialness - if (isDOMError(exception as DOMError) || isDOMException(exception as DOMException)) { + if (isDOMError(exception) || isDOMException(exception as DOMException)) { const domException = exception as DOMException; if ('stack' in (exception as Error)) { @@ -220,6 +220,7 @@ export function eventFromUnknownInput( addExceptionTypeValue(event, message); } if ('code' in domException) { + // eslint-disable-next-line deprecation/deprecation event.tags = { ...event.tags, 'DOMException.code': `${domException.code}` }; } @@ -283,3 +284,33 @@ export function eventFromString( return event; } + +function getNonErrorObjectExceptionValue( + exception: Record, + { isUnhandledRejection }: { isUnhandledRejection?: boolean }, +): string { + const keys = extractExceptionKeysForMessage(exception); + const captureType = isUnhandledRejection ? 'promise rejection' : 'exception'; + + // Some ErrorEvent instances do not have an `error` property, which is why they are not handled before + // We still want to try to get a decent message for these cases + if (isErrorEvent(exception)) { + return `Event \`ErrorEvent\` captured as ${captureType} with message \`${exception.message}\``; + } + + if (isEvent(exception)) { + const className = getObjectClassName(exception); + return `Event \`${className}\` (type=${exception.type}) captured as ${captureType}`; + } + + return `Object captured as ${captureType} with keys: ${keys}`; +} + +function getObjectClassName(obj: unknown): string | undefined | void { + try { + const prototype: Prototype | null = Object.getPrototypeOf(obj); + return prototype ? prototype.constructor.name : undefined; + } catch (e) { + // ignore errors here + } +} diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 3d019945b53b..b5f734ce939c 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -348,5 +348,5 @@ function _historyBreadcrumb(handlerData: HandlerData & { from: string; to: strin } function _isEvent(event: unknown): event is Event { - return event && !!(event as Record).target; + return !!event && !!(event as Record).target; } diff --git a/packages/browser/src/profiling/hubextensions.ts b/packages/browser/src/profiling/hubextensions.ts index e2d94a11d33f..49763ac35659 100644 --- a/packages/browser/src/profiling/hubextensions.ts +++ b/packages/browser/src/profiling/hubextensions.ts @@ -1,29 +1,17 @@ -import { getCurrentHub, getMainCarrier } from '@sentry/core'; -import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; +/* eslint-disable complexity */ +import { getCurrentHub } from '@sentry/core'; +import type { Transaction } from '@sentry/types'; import { logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { - JSSelfProfile, - JSSelfProfiler, - JSSelfProfilerConstructor, - ProcessedJSSelfProfile, -} from './jsSelfProfiling'; -import { sendProfile } from './sendProfile'; +import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor } from './jsSelfProfiling'; +import { addProfileToMap, isValidSampleRate } from './utils'; -// Max profile duration. -const MAX_PROFILE_DURATION_MS = 30_000; +export const MAX_PROFILE_DURATION_MS = 30_000; // Keep a flag value to avoid re-initializing the profiler constructor. If it fails // once, it will always fail and this allows us to early return. let PROFILING_CONSTRUCTOR_FAILED = false; -// While we experiment, per transaction sampling interval will be more flexible to work with. -type StartTransaction = ( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, -) => Transaction | undefined; - /** * Check if profiler constructor is available. * @param maybeProfiler @@ -55,7 +43,7 @@ export function onProfilingStartRouteTransaction(transaction: Transaction | unde * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ -function wrapTransactionWithProfiling(transaction: Transaction): Transaction { +export function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // Feature support check first const JSProfilerConstructor = WINDOW.Profiler; @@ -68,14 +56,6 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { return transaction; } - // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. - if (!transaction.sampled) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Transaction is not sampled, skipping profiling'); - } - return transaction; - } - // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (__DEBUG_BUILD__) { @@ -86,21 +66,41 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { const client = getCurrentHub().getClient(); const options = client && client.getOptions(); + if (!options) { + __DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.'); + return transaction; + } - // @ts-ignore not part of the browser options yet - const profilesSampleRate = (options && options.profilesSampleRate) || 0; - if (profilesSampleRate === undefined) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.'); - } + // @ts-ignore profilesSampleRate is not part of the browser options yet + const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + + // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The + // only valid values are booleans or numbers between 0 and 1.) + if (!isValidSampleRate(profilesSampleRate)) { + __DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); + return transaction; + } + + // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped + if (!profilesSampleRate) { + __DEBUG_BUILD__ && + logger.log( + '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0', + ); return transaction; } + // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is + // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. + const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate; // Check if we should sample this profile - if (Math.random() > profilesSampleRate) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Skip profiling transaction due to sampling.'); - } + if (!sampled) { + __DEBUG_BUILD__ && + logger.log( + `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( + profilesSampleRate, + )})`, + ); return transaction; } @@ -147,19 +147,19 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - let processedProfile: ProcessedJSSelfProfile | null = null; + const processedProfile: JSSelfProfile | null = null; /** * Idempotent handler for profile stop */ - function onProfileHandler(): void { + async function onProfileHandler(): Promise { // Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times. if (!transaction) { - return; + return null; } // Satisfy the type checker, but profiler will always be defined here. if (!profiler) { - return; + return null; } if (processedProfile) { if (__DEBUG_BUILD__) { @@ -169,12 +169,18 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 'already exists, returning early', ); } - return; + return null; } - profiler + // This is temporary - we will use the collected span data to evaluate + // if deferring txn.finish until profiler resolves is a viable approach. + const stopProfilerSpan = transaction.startChild({ description: 'profiler.stop', op: 'profiler' }); + + return profiler .stop() - .then((p: JSSelfProfile): void => { + .then((p: JSSelfProfile): null => { + stopProfilerSpan.finish(); + if (maxDurationTimeoutID) { WINDOW.clearTimeout(maxDurationTimeoutID); maxDurationTimeoutID = undefined; @@ -192,18 +198,14 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', ); } - return; - } - - // If a profile has less than 2 samples, it is not useful and should be discarded. - if (p.samples.length < 2) { - return; + return null; } - processedProfile = { ...p, profile_id: profileId }; - sendProfile(profileId, processedProfile); + addProfileToMap(profileId, p); + return null; }) .catch(error => { + stopProfilerSpan.finish(); if (__DEBUG_BUILD__) { logger.log('[Profiling] error while stopping profiler:', error); } @@ -219,6 +221,7 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { transaction.name || transaction.description, ); } + // If the timeout exceeds, we want to stop profiling, but not finish the transaction void onProfileHandler(); }, MAX_PROFILE_DURATION_MS); @@ -230,73 +233,26 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ - function profilingWrappedTransactionFinish(): Promise { + function profilingWrappedTransactionFinish(): Transaction { if (!transaction) { return originalFinish(); } // onProfileHandler should always return the same profile even if this is called multiple times. // Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared. - onProfileHandler(); - - // Set profile context - transaction.setContext('profile', { profile_id: profileId }); + void onProfileHandler().then( + () => { + transaction.setContext('profile', { profile_id: profileId }); + originalFinish(); + }, + () => { + // If onProfileHandler fails, we still want to call the original finish method. + originalFinish(); + }, + ); - return originalFinish(); + return transaction; } transaction.finish = profilingWrappedTransactionFinish; return transaction; } - -/** - * Wraps startTransaction with profiling logic. This is done automatically by the profiling integration. - */ -function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction { - return function wrappedStartTransaction( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, - ): Transaction | undefined { - const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext); - if (transaction === undefined) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Transaction is undefined, skipping profiling'); - } - return transaction; - } - - return wrapTransactionWithProfiling(transaction); - }; -} - -/** - * Patches startTransaction and stopTransaction with profiling logic. - */ -export function addProfilingExtensionMethods(): void { - const carrier = getMainCarrier(); - if (!carrier.__SENTRY__) { - if (__DEBUG_BUILD__) { - logger.log("[Profiling] Can't find main carrier, profiling won't work."); - } - return; - } - carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; - - if (!carrier.__SENTRY__.extensions['startTransaction']) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.', - ); - } - return; - } - - if (__DEBUG_BUILD__) { - logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...'); - } - - carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling( - // This is already patched by sentry/tracing, we are going to re-patch it... - carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction, - ); -} diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 9a9751c50d61..36fb6432e6df 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,8 +1,16 @@ -import type { Event, EventProcessor, Integration } from '@sentry/types'; +import type { EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; +import type { Profile } from '@sentry/types/src/profiling'; import { logger } from '@sentry/utils'; -import { PROFILING_EVENT_CACHE } from './cache'; -import { addProfilingExtensionMethods } from './hubextensions'; +import type { BrowserClient } from './../client'; +import { wrapTransactionWithProfiling } from './hubextensions'; +import type { ProfiledEvent } from './utils'; +import { + addProfilesToEnvelope, + createProfilingEvent, + findProfiledTransactionsFromEnvelope, + PROFILE_MAP, +} from './utils'; /** * Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"] @@ -15,34 +23,66 @@ import { addProfilingExtensionMethods } from './hubextensions'; */ export class BrowserProfilingIntegration implements Integration { public readonly name: string = 'BrowserProfilingIntegration'; + public getCurrentHub?: () => Hub = undefined; /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { - // Patching the hub to add the extension methods. - // Warning: we have an implicit dependency on import order and we will fail patching if the constructor of - // BrowserProfilingIntegration is called before @sentry/tracing is imported. This is because we need to patch - // the methods of @sentry/tracing which are patched as a side effect of importing @sentry/tracing. - addProfilingExtensionMethods(); - - // Add our event processor - addGlobalEventProcessor(this.handleGlobalEvent.bind(this)); - } + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this.getCurrentHub = getCurrentHub; + const client = this.getCurrentHub().getClient() as BrowserClient; - /** - * @inheritDoc - */ - public handleGlobalEvent(event: Event): Event { - const profileId = event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']; - - if (profileId && typeof profileId === 'string') { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Profiling event found, caching it.'); - } - PROFILING_EVENT_CACHE.add(profileId, event); - } + if (client && typeof client.on === 'function') { + client.on('startTransaction', (transaction: Transaction) => { + wrapTransactionWithProfiling(transaction); + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_MAP['size']) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; - return event; + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction && profiledTransaction.contexts; + const profile_id = context && context['profile'] && (context['profile']['profile_id'] as string); + + if (!profile_id) { + __DEBUG_BUILD__ && + logger.log('[Profiling] cannot find profile for a transaction without a profile context'); + continue; + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (context && context['profile']) { + delete context.profile; + } + + const profile = PROFILE_MAP.get(profile_id); + if (!profile) { + __DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + PROFILE_MAP.delete(profile_id); + const profileEvent = createProfilingEvent(profile_id, profile, profiledTransaction as ProfiledEvent); + + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); + } else { + logger.warn('[Profiling] Client does not support hooks, profiling will be disabled'); + } } } diff --git a/packages/browser/src/profiling/jsSelfProfiling.ts b/packages/browser/src/profiling/jsSelfProfiling.ts index af9ebada8cbf..8dc981d3d5d3 100644 --- a/packages/browser/src/profiling/jsSelfProfiling.ts +++ b/packages/browser/src/profiling/jsSelfProfiling.ts @@ -26,10 +26,6 @@ export type JSSelfProfile = { samples: JSSelfProfileSample[]; }; -export interface ProcessedJSSelfProfile extends JSSelfProfile { - profile_id: string; -} - type BufferFullCallback = (trace: JSSelfProfile) => void; export interface JSSelfProfiler { @@ -49,67 +45,3 @@ declare global { Profiler: typeof JSSelfProfilerConstructor | undefined; } } - -export interface RawThreadCpuProfile extends JSSelfProfile { - profile_id: string; -} -export interface ThreadCpuProfile { - samples: { - stack_id: number; - thread_id: string; - elapsed_since_start_ns: string; - }[]; - stacks: number[][]; - frames: { - function: string; - file: string | undefined; - line: number | undefined; - column: number | undefined; - }[]; - thread_metadata: Record; - queue_metadata?: Record; -} - -export interface SentryProfile { - event_id: string; - version: string; - os: { - name: string; - version: string; - build_number: string; - }; - runtime: { - name: string; - version: string; - }; - device: { - architecture: string; - is_emulator: boolean; - locale: string; - manufacturer: string; - model: string; - }; - timestamp: string; - release: string; - environment: string; - platform: string; - profile: ThreadCpuProfile; - debug_meta?: { - images: { - debug_id: string; - image_addr: string; - code_file: string; - type: string; - image_size: number; - image_vmaddr: string; - }[]; - }; - transactions: { - name: string; - trace_id: string; - id: string; - active_thread_id: string; - relative_start_ns: string; - relative_end_ns: string; - }[]; -} diff --git a/packages/browser/src/profiling/sendProfile.ts b/packages/browser/src/profiling/sendProfile.ts deleted file mode 100644 index 83ca990c516e..000000000000 --- a/packages/browser/src/profiling/sendProfile.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { getCurrentHub } from '@sentry/core'; -import { logger } from '@sentry/utils'; - -import { PROFILING_EVENT_CACHE } from './cache'; -import type { ProcessedJSSelfProfile } from './jsSelfProfiling'; -import type { ProfiledEvent } from './utils'; -import { createProfilingEventEnvelope } from './utils'; -/** - * Performs lookup in the event cache and sends the profile to Sentry. - * If the profiled transaction event is found, we use the profiled transaction event and profile - * to construct a profile type envelope and send it to Sentry. - */ -export function sendProfile(profileId: string, profile: ProcessedJSSelfProfile): void { - const event = PROFILING_EVENT_CACHE.get(profileId); - - if (!event) { - // We could not find a corresponding transaction event for this profile. - // Opt to do nothing for now, but in the future we should implement a simple retry mechanism. - if (__DEBUG_BUILD__) { - logger.log("[Profiling] Couldn't find a transaction event for this profile, dropping it."); - } - return; - } - - event.sdkProcessingMetadata = event.sdkProcessingMetadata || {}; - if (event.sdkProcessingMetadata && !event.sdkProcessingMetadata['profile']) { - event.sdkProcessingMetadata['profile'] = profile; - } - - // Client, Dsn and Transport are all required to be able to send the profiling event to Sentry. - // If either of them is not available, we remove the profile from the transaction event. - // and forward it to the next event processor. - const hub = getCurrentHub(); - const client = hub.getClient(); - - if (!client) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - const dsn = client.getDsn(); - if (!dsn) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - const transport = client.getTransport(); - if (!transport) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - // If all required components are available, we construct a profiling event envelope and send it to Sentry. - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Preparing envelope and sending a profiling event'); - } - const envelope = createProfilingEventEnvelope(event as ProfiledEvent, dsn); - - // Evict event from the cache - we want to prevent the LRU cache from prioritizing already sent events over new ones. - PROFILING_EVENT_CACHE.delete(profileId); - - if (!envelope) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Failed to construct envelope'); - } - return; - } - - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Envelope constructed, sending it'); - } - - // Wrap in try/catch because send will throw in case of a network error. - transport.send(envelope).then(null, reason => { - __DEBUG_BUILD__ && logger.log('[Profiling] Error while sending event:', reason); - }); -} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 9fc068f7c0ee..6c9b4d8ed6b9 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,24 +1,12 @@ -import { DEFAULT_ENVIRONMENT } from '@sentry/core'; -import type { - DsnComponents, - DynamicSamplingContext, - Event, - EventEnvelope, - EventEnvelopeHeaders, - EventItem, - SdkInfo, - SdkMetadata, -} from '@sentry/types'; -import { createEnvelope, dropUndefinedKeys, dsnToString, logger, uuid4 } from '@sentry/utils'; +/* eslint-disable max-lines */ + +import { DEFAULT_ENVIRONMENT, getCurrentHub } from '@sentry/core'; +import type { DebugImage, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; +import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; +import { forEachEnvelopeItem, GLOBAL_OBJ, logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { - JSSelfProfile, - JSSelfProfileStack, - RawThreadCpuProfile, - SentryProfile, - ThreadCpuProfile, -} from './jsSelfProfiling'; +import type { JSSelfProfile, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; // Use 0 as main thread id which is identical to threadId in node:worker_threads @@ -27,9 +15,9 @@ const THREAD_ID_STRING = String(0); const THREAD_NAME = 'main'; // Machine properties (eval only once) -let OS_PLATFORM = ''; // macos -let OS_PLATFORM_VERSION = ''; // 13.2 -let OS_ARCH = ''; // arm64 +let OS_PLATFORM = ''; +let OS_PLATFORM_VERSION = ''; +let OS_ARCH = ''; let OS_BROWSER = (WINDOW.navigator && WINDOW.navigator.userAgent) || ''; let OS_MODEL = ''; const OS_LOCALE = @@ -76,7 +64,7 @@ if (isUserAgentData(userAgentData)) { .catch(e => void e); } -function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { +function isProcessedJSSelfProfile(profile: ThreadCpuProfile | JSSelfProfile): profile is JSSelfProfile { return !('thread_metadata' in profile); } @@ -85,8 +73,8 @@ function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): /** * */ -export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { - if (!isRawThreadCpuProfile(profile)) { +export function enrichWithThreadInformation(profile: ThreadCpuProfile | JSSelfProfile): ThreadCpuProfile { + if (!isProcessedJSSelfProfile(profile)) { return profile; } @@ -97,52 +85,7 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea // by the integration before the event is processed by other integrations. export interface ProfiledEvent extends Event { sdkProcessingMetadata: { - profile?: RawThreadCpuProfile; - }; -} - -/** Extract sdk info from from the API metadata */ -function getSdkMetadataForEnvelopeHeader(metadata?: SdkMetadata): SdkInfo | undefined { - if (!metadata || !metadata.sdk) { - return undefined; - } - - return { name: metadata.sdk.name, version: metadata.sdk.version } as SdkInfo; -} - -/** - * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. - * Merge with existing data if any. - **/ -function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { - if (!sdkInfo) { - return event; - } - event.sdk = event.sdk || {}; - event.sdk.name = event.sdk.name || sdkInfo.name || 'unknown sdk'; - event.sdk.version = event.sdk.version || sdkInfo.version || 'unknown sdk version'; - event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; - event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; - return event; -} - -function createEventEnvelopeHeaders( - event: Event, - sdkInfo: SdkInfo | undefined, - tunnel: string | undefined, - dsn: DsnComponents, -): EventEnvelopeHeaders { - const dynamicSamplingContext = event.sdkProcessingMetadata && event.sdkProcessingMetadata['dynamicSamplingContext']; - - return { - event_id: event.event_id as string, - sent_at: new Date().toISOString(), - ...(sdkInfo && { sdk: sdkInfo }), - ...(!!tunnel && { dsn: dsnToString(dsn) }), - ...(event.type === 'transaction' && - dynamicSamplingContext && { - trace: dropUndefinedKeys({ ...dynamicSamplingContext }) as DynamicSamplingContext, - }), + profile?: JSSelfProfile; }; } @@ -175,50 +118,30 @@ function getTraceId(event: Event): string { /** * Creates a profiling event envelope from a Sentry event. */ -export function createProfilingEventEnvelope( +export function createProfilePayload( event: ProfiledEvent, - dsn: DsnComponents, - metadata?: SdkMetadata, - tunnel?: string, -): EventEnvelope | null { + processedProfile: JSSelfProfile, + profile_id: string, +): Profile { if (event.type !== 'transaction') { // createProfilingEventEnvelope should only be called for transactions, // we type guard this behavior with isProfiledTransactionEvent. throw new TypeError('Profiling events may only be attached to transactions, this should never occur.'); } - const rawProfile = event.sdkProcessingMetadata['profile']; - - if (rawProfile === undefined || rawProfile === null) { + if (processedProfile === undefined || processedProfile === null) { throw new TypeError( - `Cannot construct profiling event envelope without a valid profile. Got ${rawProfile} instead.`, + `Cannot construct profiling event envelope without a valid profile. Got ${processedProfile} instead.`, ); } - if (!rawProfile.profile_id) { - throw new TypeError('Profile is missing profile_id'); - } - - if (rawProfile.samples.length <= 1) { - if (__DEBUG_BUILD__) { - // Log a warning if the profile has less than 2 samples so users can know why - // they are not seeing any profiling data and we cant avoid the back and forth - // of asking them to provide us with a dump of the profile data. - logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); - } - return null; - } - const traceId = getTraceId(event); - const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); - enhanceEventWithSdkInfo(event, metadata && metadata.sdk); - const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn); - const enrichedThreadProfile = enrichWithThreadInformation(rawProfile); + const enrichedThreadProfile = enrichWithThreadInformation(processedProfile); const transactionStartMs = typeof event.start_timestamp === 'number' ? event.start_timestamp * 1000 : Date.now(); const transactionEndMs = typeof event.timestamp === 'number' ? event.timestamp * 1000 : Date.now(); - const profile: SentryProfile = { - event_id: rawProfile.profile_id, + const profile: Profile = { + event_id: profile_id, timestamp: new Date(transactionStartMs).toISOString(), platform: 'javascript', version: '1', @@ -240,6 +163,9 @@ export function createProfilingEventEnvelope( architecture: OS_ARCH, is_emulator: false, }, + debug_meta: { + images: applyDebugMetadata(processedProfile.resources), + }, profile: enrichedThreadProfile, transactions: [ { @@ -253,15 +179,7 @@ export function createProfilingEventEnvelope( ], }; - const envelopeItem: EventItem = [ - { - type: 'profile', - }, - // @ts-ignore this is missing in typedef - profile, - ]; - - return createEnvelope(envelopeHeaders, [envelopeItem]); + return profile; } /** @@ -271,31 +189,16 @@ export function isProfiledTransactionEvent(event: Event): event is ProfiledEvent return !!(event.sdkProcessingMetadata && event.sdkProcessingMetadata['profile']); } -// Due to how profiles are attached to event metadata, we may sometimes want to remove them to ensure -// they are not processed by other Sentry integrations. This can be the case when we cannot construct a valid -// profile from the data we have or some of the mechanisms to send the event (Hub, Transport etc) are not available to us. -/** - * - */ -export function maybeRemoveProfileFromSdkMetadata(event: Event | ProfiledEvent): Event { - if (!isProfiledTransactionEvent(event)) { - return event; - } - - delete event.sdkProcessingMetadata.profile; - return event; -} - /** * Converts a JSSelfProfile to a our sampled format. * Does not currently perform stack indexing. */ -export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): ThreadCpuProfile { +export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profile['profile'] { let EMPTY_STACK_ID: undefined | number = undefined; let STACK_ID = 0; // Initialize the profile that we will fill with data - const profile: ThreadCpuProfile = { + const profile: Profile['profile'] = { samples: [], stacks: [], frames: [], @@ -355,7 +258,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId]; } - const sample: ThreadCpuProfile['samples'][0] = { + const sample: Profile['profile']['samples'][0] = { // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, @@ -369,3 +272,195 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa return profile; } + +/** + * Adds items to envelope if they are not already present - mutates the envelope. + * @param envelope + */ +export function addProfilesToEnvelope(envelope: Envelope, profiles: Profile[]): Envelope { + if (!profiles.length) { + return envelope; + } + + for (const profile of profiles) { + // @ts-ignore untyped envelope + envelope[1].push([{ type: 'profile' }, profile]); + } + return envelope; +} + +/** + * Finds transactions with profile_id context in the envelope + * @param envelope + * @returns + */ +export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[] { + const events: Event[] = []; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type !== 'transaction') { + return; + } + + for (let j = 1; j < item.length; j++) { + const event = item[j] as Event; + + if (event && event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']) { + events.push(item[j] as Event); + } + } + }); + + return events; +} + +const debugIdStackParserCache = new WeakMap>(); +/** + * Applies debug meta data to an event from a list of paths to resources (sourcemaps) + */ +export function applyDebugMetadata(resource_paths: ReadonlyArray): DebugImage[] { + const debugIdMap = GLOBAL_OBJ._sentryDebugIds; + + if (!debugIdMap) { + return []; + } + + const hub = getCurrentHub(); + if (!hub) { + return []; + } + const client = hub.getClient(); + if (!client) { + return []; + } + const options = client.getOptions(); + if (!options) { + return []; + } + const stackParser = options.stackParser; + if (!stackParser) { + return []; + } + + let debugIdStackFramesCache: Map; + const cachedDebugIdStackFrameCache = debugIdStackParserCache.get(stackParser); + if (cachedDebugIdStackFrameCache) { + debugIdStackFramesCache = cachedDebugIdStackFrameCache; + } else { + debugIdStackFramesCache = new Map(); + debugIdStackParserCache.set(stackParser, debugIdStackFramesCache); + } + + // Build a map of filename -> debug_id + const filenameDebugIdMap = Object.keys(debugIdMap).reduce>((acc, debugIdStackTrace) => { + let parsedStack: StackFrame[]; + + const cachedParsedStack = debugIdStackFramesCache.get(debugIdStackTrace); + if (cachedParsedStack) { + parsedStack = cachedParsedStack; + } else { + parsedStack = stackParser(debugIdStackTrace); + debugIdStackFramesCache.set(debugIdStackTrace, parsedStack); + } + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const file = stackFrame && stackFrame.filename; + + if (stackFrame && file) { + acc[file] = debugIdMap[debugIdStackTrace] as string; + break; + } + } + return acc; + }, {}); + + const images: DebugImage[] = []; + for (const path of resource_paths) { + if (path && filenameDebugIdMap[path]) { + images.push({ + type: 'sourcemap', + code_file: path, + debug_id: filenameDebugIdMap[path] as string, + }); + } + } + + return images; +} + +/** + * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). + */ +export function isValidSampleRate(rate: unknown): boolean { + // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck + if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { + __DEBUG_BUILD__ && + logger.warn( + `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( + rate, + )} of type ${JSON.stringify(typeof rate)}.`, + ); + return false; + } + + // Boolean sample rates are always valid + if (rate === true || rate === false) { + return true; + } + + // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false + if (rate < 0 || rate > 1) { + __DEBUG_BUILD__ && + logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); + return false; + } + return true; +} + +function isValidProfile(profile: JSSelfProfile): profile is JSSelfProfile & { profile_id: string } { + if (profile.samples.length < 2) { + if (__DEBUG_BUILD__) { + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); + } + return false; + } + + if (!profile.frames.length) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Discarding profile because it contains no frames'); + } + return false; + } + + return true; +} + +/** + * Creates a profiling envelope item, if the profile does not pass validation, returns null. + * @param event + * @returns {Profile | null} + */ +export function createProfilingEvent(profile_id: string, profile: JSSelfProfile, event: ProfiledEvent): Profile | null { + if (!isValidProfile(profile)) { + return null; + } + + return createProfilePayload(event, profile, profile_id); +} + +export const PROFILE_MAP: Map = new Map(); +/** + * + */ +export function addProfileToMap(profile_id: string, profile: JSSelfProfile): void { + PROFILE_MAP.set(profile_id, profile); + + if (PROFILE_MAP.size > 30) { + const last: string = PROFILE_MAP.keys().next().value; + PROFILE_MAP.delete(last); + } +} diff --git a/packages/browser/test/integration/suites/onerror.js b/packages/browser/test/integration/suites/onerror.js index d5a7f155d723..3db1a755061a 100644 --- a/packages/browser/test/integration/suites/onerror.js +++ b/packages/browser/test/integration/suites/onerror.js @@ -61,7 +61,7 @@ describe('window.onerror', function () { } else { assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error exception captured with keys: error, somekey' + 'Object captured as exception with keys: error, somekey' ); } assert.equal(summary.events[0].exception.values[0].stacktrace.frames.length, 1); // always 1 because thrown objects can't provide > 1 frame @@ -119,7 +119,7 @@ describe('window.onerror', function () { assert.equal(summary.events[0].exception.values[0].type, 'Error'); assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error exception captured with keys: otherKey, type' + 'Object captured as exception with keys: otherKey, type' ); assert.deepEqual(summary.events[0].extra.__serialized__, { type: 'error', diff --git a/packages/browser/test/integration/suites/onunhandledrejection.js b/packages/browser/test/integration/suites/onunhandledrejection.js index d32708eb88cd..f9095d7c7333 100644 --- a/packages/browser/test/integration/suites/onunhandledrejection.js +++ b/packages/browser/test/integration/suites/onunhandledrejection.js @@ -77,7 +77,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: currentTarget, isTrusted, target, type' + 'Event `Event` (type=unhandledrejection) captured as promise rejection' ); assert.equal(summary.events[0].exception.values[0].type, 'Event'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); @@ -144,7 +144,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: a, b, c' + 'Object captured as promise rejection with keys: a, b, c' ); assert.equal(summary.events[0].exception.values[0].type, 'UnhandledRejection'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); @@ -172,7 +172,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: a, b, c, d, e' + 'Object captured as promise rejection with keys: a, b, c, d, e' ); assert.equal(summary.events[0].exception.values[0].type, 'UnhandledRejection'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); diff --git a/packages/browser/test/unit/eventbuilder.test.ts b/packages/browser/test/unit/eventbuilder.test.ts index ac9b564e99e0..d7a2ab712959 100644 --- a/packages/browser/test/unit/eventbuilder.test.ts +++ b/packages/browser/test/unit/eventbuilder.test.ts @@ -23,6 +23,11 @@ jest.mock('@sentry/core', () => { }; }); +class MyTestClass { + prop1 = 'hello'; + prop2 = 2; +} + afterEach(() => { jest.resetAllMocks(); }); @@ -61,4 +66,18 @@ describe('eventFromPlainObject', () => { }, }); }); + + it.each([ + ['empty object', {}, 'Object captured as exception with keys: [object has no keys]'], + ['pojo', { prop1: 'hello', prop2: 2 }, 'Object captured as exception with keys: prop1, prop2'], + ['Custom Class', new MyTestClass(), 'Object captured as exception with keys: prop1, prop2'], + ['Event', new Event('custom'), 'Event `Event` (type=custom) captured as exception'], + ['MouseEvent', new MouseEvent('click'), 'Event `MouseEvent` (type=click) captured as exception'], + ] as [string, Record, string][])( + 'has correct exception value for %s', + (_name, exception, expected) => { + const actual = eventFromPlainObject(defaultStackParser, exception); + expect(actual.exception?.values?.[0]?.value).toEqual(expected); + }, + ); }); diff --git a/packages/browser/test/unit/integrations/helpers.test.ts b/packages/browser/test/unit/integrations/helpers.test.ts index a3fe734d79d4..5b06835f834d 100644 --- a/packages/browser/test/unit/integrations/helpers.test.ts +++ b/packages/browser/test/unit/integrations/helpers.test.ts @@ -157,7 +157,7 @@ describe('internal wrap()', () => { try { wrapped(); } catch (error) { - expect(error.message).toBe('boom'); + expect((error as Error).message).toBe('boom'); } }); diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts deleted file mode 100644 index 1ea59ee7068e..000000000000 --- a/packages/browser/test/unit/profiling/integration.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getCurrentHub } from '@sentry/browser'; -import type { Event } from '@sentry/types'; -import { TextDecoder, TextEncoder } from 'util'; - -// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) -const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; -// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) -const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; - -import { JSDOM } from 'jsdom'; - -import { PROFILING_EVENT_CACHE } from '../../../src/profiling/cache'; -import { BrowserProfilingIntegration } from '../../../src/profiling/integration'; -import { sendProfile } from '../../../src/profiling/sendProfile'; - -// @ts-ignore store a reference so we can reset it later -const globalDocument = global.document; -// @ts-ignore store a reference so we can reset it later -const globalWindow = global.window; -// @ts-ignore store a reference so we can reset it later -const globalLocation = global.location; - -describe('BrowserProfilingIntegration', () => { - beforeEach(() => { - // Clear profiling event cache - PROFILING_EVENT_CACHE.clear(); - - const dom = new JSDOM(); - // @ts-ignore need to override global document - global.document = dom.window.document; - // @ts-ignore need to override global document - global.window = dom.window; - // @ts-ignore need to override global document - global.location = dom.window.location; - }); - - // Reset back to previous values - afterEach(() => { - // @ts-ignore need to override global document - global.document = globalDocument; - // @ts-ignore need to override global document - global.window = globalWindow; - // @ts-ignore need to override global document - global.location = globalLocation; - }); - - afterAll(() => { - // @ts-ignore patch the encoder on the window, else importing JSDOM fails - patchedEncoder && delete global.window.TextEncoder; - // @ts-ignore patch the encoder on the window, else importing JSDOM fails - patchedDecoder && delete global.window.TextDecoder; - }); - - it('does not store event in profiling event cache if context["profile"]["profile_id"] is not present', () => { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: {}, - }; - integration.handleGlobalEvent(event); - expect(PROFILING_EVENT_CACHE.size()).toBe(0); - }); - - it('stores event in profiling event cache if context["profile"]["profile_id"] is present', () => { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: { - profile: { - profile_id: 'profile_id', - }, - }, - }; - integration.handleGlobalEvent(event); - expect(PROFILING_EVENT_CACHE.get(event.contexts!.profile!.profile_id as string)).toBe(event); - }); - - it('sending profile evicts it from the LRU cache', () => { - const hub = getCurrentHub(); - const client: any = { - getDsn() { - return {}; - }, - getTransport() { - return { - send() {}, - }; - }, - }; - - hub.bindClient(client); - - const integration = new BrowserProfilingIntegration(); - const event: Event = { - type: 'transaction', - contexts: { - profile: { - profile_id: 'profile_id', - }, - }, - }; - - integration.handleGlobalEvent(event); - - sendProfile('profile_id', { - resources: [], - samples: [], - stacks: [], - frames: [], - profile_id: 'profile_id', - }); - - expect(PROFILING_EVENT_CACHE.get('profile_id')).toBe(undefined); - }); -}); - -describe('ProfilingEventCache', () => { - beforeEach(() => { - PROFILING_EVENT_CACHE.clear(); - }); - - it('caps the size of the profiling event cache', () => { - for (let i = 0; i <= 21; i++) { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: { - profile: { - profile_id: `profile_id_${i}`, - }, - }, - }; - integration.handleGlobalEvent(event); - } - expect(PROFILING_EVENT_CACHE.size()).toBe(20); - // Evicts the first item in the cache - expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(undefined); - }); - - it('handles collision by replacing the value', () => { - PROFILING_EVENT_CACHE.add('profile_id_0', {}); - const second = {}; - PROFILING_EVENT_CACHE.add('profile_id_0', second); - - expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(second); - expect(PROFILING_EVENT_CACHE.size()).toBe(1); - }); - - it('clears cache', () => { - PROFILING_EVENT_CACHE.add('profile_id_0', {}); - PROFILING_EVENT_CACHE.clear(); - expect(PROFILING_EVENT_CACHE.size()).toBe(0); - }); -}); diff --git a/packages/browser/test/unit/transports/offline.test.ts b/packages/browser/test/unit/transports/offline.test.ts index b224df725bc6..aa5819fe155a 100644 --- a/packages/browser/test/unit/transports/offline.test.ts +++ b/packages/browser/test/unit/transports/offline.test.ts @@ -47,7 +47,7 @@ export const createTestTransport = (...sendResults: MockResult( const scope = hub.getScope(); const parentSpan = scope.getSpan(); - const activeSpan = parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + + function getActiveSpan(): Span | undefined { + if (!hasTracingEnabled()) { + return undefined; + } + return parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + } + + const activeSpan = getActiveSpan(); scope.setSpan(activeSpan); function finishAndSetSpan(): void { diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index e318793624ce..c3864ead7ec5 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -131,10 +131,10 @@ export function makeOfflineTransport( retryDelay = START_DELAY; return result; } catch (e) { - if (store && (await shouldQueue(envelope, e, retryDelay))) { + if (store && (await shouldQueue(envelope, e as Error, retryDelay))) { await store.insert(envelope); flushWithBackOff(); - log('Error sending. Event queued', e); + log('Error sending. Event queued', e as Error); return {}; } else { throw e; diff --git a/packages/core/test/lib/hint.test.ts b/packages/core/test/lib/hint.test.ts index a975174dcd78..bd795ed79c8e 100644 --- a/packages/core/test/lib/hint.test.ts +++ b/packages/core/test/lib/hint.test.ts @@ -16,6 +16,7 @@ describe('Hint', () => { afterEach(() => { jest.clearAllMocks(); + // @ts-ignore for testing delete GLOBAL_OBJ.__SENTRY__; }); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 064c41dc123a..bff1c425c2a0 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -185,5 +185,33 @@ describe('trace', () => { } expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); }); + + it("doesn't create spans but calls onError if tracing is disabled", async () => { + const options = getDefaultTestClientOptions({ + /* we don't set tracesSampleRate or tracesSampler */ + }); + client = new TestClient(options); + hub = new Hub(client); + makeMain(hub); + + const startTxnSpy = jest.spyOn(hub, 'startTransaction'); + + const onError = jest.fn(); + try { + await trace( + { name: 'GET users/[id]' }, + () => { + return callback(); + }, + onError, + ); + } catch (e) { + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(e); + } + expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); + + expect(startTxnSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 056d11aac90a..6a6474d51ef9 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -98,7 +98,7 @@ const createTestTransport = ( reject(next); } else { sendCount += 1; - resolve(next as TransportMakeRequestResponse | undefined); + resolve(next as TransportMakeRequestResponse); } }); }), diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 13560617e1b2..6bacdf4d2159 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -26,7 +26,6 @@ "fs-extra": "11.1.0", "glob": "8.0.3", "ts-node": "10.9.1", - "typescript": "3.8.3", "yaml": "2.2.2" }, "volta": { diff --git a/packages/e2e-tests/test-applications/create-next-app/package.json b/packages/e2e-tests/test-applications/create-next-app/package.json index af2a7830f3d8..3232c1eca7fe 100644 --- a/packages/e2e-tests/test-applications/create-next-app/package.json +++ b/packages/e2e-tests/test-applications/create-next-app/package.json @@ -16,7 +16,7 @@ "next": "13.0.7", "react": "18.2.0", "react-dom": "18.2.0", - "typescript": "4.9.4" + "typescript": "4.9.5" }, "devDependencies": { "@playwright/test": "^1.27.1" diff --git a/packages/e2e-tests/test-applications/create-react-app/package.json b/packages/e2e-tests/test-applications/create-react-app/package.json index e0fc502238d0..062eef12aa45 100644 --- a/packages/e2e-tests/test-applications/create-react-app/package.json +++ b/packages/e2e-tests/test-applications/create-react-app/package.json @@ -15,7 +15,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/create-react-app/test-recipe.json b/packages/e2e-tests/test-applications/create-react-app/test-recipe.json index a45dde0d9c07..f0f5e19553cc 100644 --- a/packages/e2e-tests/test-applications/create-react-app/test-recipe.json +++ b/packages/e2e-tests/test-applications/create-react-app/test-recipe.json @@ -3,6 +3,13 @@ "testApplicationName": "create-react-app", "buildCommand": "pnpm install && pnpm build", "tests": [], + "versions": [ + { + "dependencyOverrides": { + "typescript": "3.8.3" + } + } + ], "canaryVersions": [ { "dependencyOverrides": { diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx index a357439bee1f..384b06ab5528 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx @@ -29,7 +29,7 @@ export function TransactionContextProvider({ children }: PropsWithChildren) { transactionActive: false, start: (transactionName: string) => { const t = startTransaction({ name: transactionName }); - getCurrentHub().getScope()?.setSpan(t); + getCurrentHub().getScope().setSpan(t); setTransaction(t); }, } diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts new file mode 100644 index 000000000000..bb9db27b50d7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware'], +}; diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/packages/e2e-tests/test-applications/nextjs-app-dir/package.json index b4e58ced63a2..45f79250d05f 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -16,7 +16,7 @@ "next": "13.2.4", "react": "18.2.0", "react-dom": "18.2.0", - "typescript": "4.9.4", + "typescript": "4.9.5", "wait-port": "1.0.4", "ts-node": "10.9.1", "@playwright/test": "^1.27.1" diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts index d8af89f2e9d5..b2b2dfdf4fc3 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts @@ -1,3 +1,17 @@ -export const config = { runtime: 'edge' }; +export const config = { + runtime: 'edge', +}; -export default () => new Response('Hello world!'); +export default async function handler() { + return new Response( + JSON.stringify({ + name: 'Jim Halpert', + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }, + ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts new file mode 100644 index 000000000000..2ca75a33ba7e --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +type Data = { + name: string; +}; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ name: 'John Doe' }); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts new file mode 100644 index 000000000000..043112494c23 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts @@ -0,0 +1,5 @@ +export const config = { runtime: 'edge' }; + +export default () => { + throw new Error('Edge Route Error'); +}; diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts new file mode 100644 index 000000000000..8f73764a919f --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction, waitForError } from '../../../test-utils/event-proxy-server'; + +test('Should create a transaction for edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/edge-endpoint' && transactionEvent?.contexts?.trace?.status === 'ok' + ); + }); + + const response = await request.get('/api/edge-endpoint'); + expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); + + const edgerouteTransaction = await edgerouteTransactionPromise; + + expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should create a transaction with error status for faulty edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && + transactionEvent?.contexts?.trace?.status === 'internal_error' + ); + }); + + request.get('/api/error-edge-endpoint').catch(() => { + // Noop + }); + + const edgerouteTransaction = await edgerouteTransactionPromise; + + expect(edgerouteTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should record exceptions for faulty edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error'; + }); + + request.get('/api/error-edge-endpoint').catch(() => { + // Noop + }); + + expect(await errorEventPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts new file mode 100644 index 000000000000..268a55f1f481 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction, waitForError } from '../../../test-utils/event-proxy-server'; + +test('Should create a transaction for middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'ok'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should create a transaction with error status for faulty middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'internal_error' + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Records exceptions happening in middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + expect(await errorEventPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore b/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/package.json b/packages/e2e-tests/test-applications/react-create-hash-router/package.json new file mode 100644 index 000000000000..bac46c9562d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -0,0 +1,53 @@ +{ + "name": "react-create-hash-router-test", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "*", + "@testing-library/jest-dom": "5.14.1", + "@testing-library/react": "13.0.0", + "@testing-library/user-event": "13.2.1", + "@types/jest": "27.0.1", + "@types/node": "16.7.13", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.4.1", + "react-scripts": "5.0.1", + "typescript": "4.4.2", + "web-vitals": "2.1.0" + }, + "scripts": { + "build": "react-scripts build", + "start": "serve -s build", + "test": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@playwright/test": "1.26.1", + "axios": "1.1.2", + "serve": "14.0.1" + }, + "volta": { + "node": "16.19.0", + "yarn": "1.22.19" + } +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts new file mode 100644 index 000000000000..a24d7bc1c742 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts @@ -0,0 +1,70 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* 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, + + /* 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'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm start', + port: Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO), + env: { + PORT: String(Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO)), + }, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx new file mode 100644 index 000000000000..aef574bce3c4 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import * as Sentry from '@sentry/react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + RouterProvider, + createHashRouter, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = new Sentry.Replay(); + +Sentry.init({ + // environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, +}); + +Object.defineProperty(window, 'sentryReplayId', { + get() { + return replay['_replay'].session.id; + }, +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter); + +const router = sentryCreateHashRouter([ + { + path: '/', + element: , + }, + { + path: '/user/:id', + element: , + }, +]); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render(); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx new file mode 100644 index 000000000000..2f683c63ed84 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as Sentry from '@sentry/react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json new file mode 100644 index 000000000000..7955a96ea1d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../test-recipe-schema.json", + "testApplicationName": "react-create-hash-router", + "buildCommand": "pnpm install && npx playwright install && pnpm build", + "tests": [ + { + "testName": "Playwright tests", + "testCommand": "pnpm test" + } + ], + "canaryVersions": [ + { + "dependencyOverrides": { + "react": "latest", + "react-dom": "latest" + } + } + ] +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts new file mode 100644 index 000000000000..c57beb61c8db --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts @@ -0,0 +1,256 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; + +const EVENT_POLLING_TIMEOUT = 30_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends an exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + expect(response.data.title).toBe('/'); + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + expect(response.data.title).toBe('/user/:id'); + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a Replay recording to Sentry', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/'); + + const replayId = await page.waitForFunction(() => { + return window.sentryReplayId; + }); + + // Wait for replay to be sent + + if (replayId === undefined) { + throw new Error("Application didn't set a replayId"); + } + + console.log(`Polling for replay with ID: ${replayId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + + // now fetch the first recording segment + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/recording-segments/?cursor=100%3A0%3A1`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return { + status: response.status, + data: response.data, + }; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toEqual({ + status: 200, + data: ReplayRecordingData, + }); +}); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts new file mode 100644 index 000000000000..0da2e1b2e327 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts @@ -0,0 +1,243 @@ +import { expect } from '@playwright/test'; + +export const ReplayRecordingData = [ + [ + { + type: 4, + data: { href: expect.stringMatching(/http:\/\/localhost:\d+\//), width: 1280, height: 720 }, + timestamp: expect.any(Number), + }, + { + data: { + payload: { + blockAllMedia: true, + errorSampleRate: 0, + maskAllInputs: true, + maskAllText: true, + networkCaptureBodies: true, + networkDetailHasUrls: false, + networkRequestHasHeaders: true, + networkResponseHasHeaders: true, + sessionSampleRate: 1, + useCompression: false, + useCompressionOption: true, + }, + tag: 'options', + }, + timestamp: expect.any(Number), + type: 5, + }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 2, tagName: 'meta', attributes: { charset: 'utf-8' }, childNodes: [], id: 5 }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + childNodes: [], + id: 6, + }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'theme-color', content: '#000000' }, + childNodes: [], + id: 7, + }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [{ type: 3, textContent: '***** ***', id: 9 }], + id: 8, + }, + ], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'noscript', + attributes: {}, + childNodes: [{ type: 3, textContent: '*** **** ** ****** ********** ** *** **** ****', id: 12 }], + id: 11, + }, + { type: 2, tagName: 'div', attributes: { id: 'root' }, childNodes: [], id: 13 }, + ], + id: 10, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { + source: 0, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 13, + nextId: null, + node: { + type: 2, + tagName: 'a', + attributes: { id: 'navigation', href: expect.stringMatching(/http:\/\/localhost:\d+\/user\/5/) }, + childNodes: [], + id: 14, + }, + }, + { parentId: 14, nextId: null, node: { type: 3, textContent: '********', id: 15 } }, + { + parentId: 13, + nextId: 14, + node: { + type: 2, + tagName: 'input', + attributes: { type: 'button', id: 'exception-button', value: '******* *********' }, + childNodes: [], + id: 16, + }, + }, + ], + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, + timestamp: expect.any(Number), + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'navigation.navigate', + description: expect.stringMatching(/http:\/\/localhost:\d+\//), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + duration: expect.any(Number), + domInteractive: expect.any(Number), + domContentLoadedEventEnd: expect.any(Number), + domContentLoadedEventStart: expect.any(Number), + loadEventStart: expect.any(Number), + loadEventEnd: expect.any(Number), + domComplete: expect.any(Number), + redirectCount: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'resource.script', + description: expect.stringMatching(/http:\/\/localhost:\d+\/static\/js\/main.(\w+).js/), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'largest-contentful-paint', + description: 'largest-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { value: expect.any(Number), size: expect.any(Number), nodeId: 16 }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + ], +]; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json new file mode 100644 index 000000000000..c8df41dcf4b5 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "tests"] +} diff --git a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json index f1e97a01925a..abfb3bfea914 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json @@ -16,7 +16,7 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/package.json b/packages/e2e-tests/test-applications/standard-frontend-react/package.json index d8f6db397dcd..8ea59b80e55a 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/package.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react/package.json @@ -15,7 +15,7 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json b/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json index c3f5f2b89166..2f2e72c8f501 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json @@ -8,6 +8,13 @@ "testCommand": "pnpm test" } ], + "versions": [ + { + "dependencyOverrides": { + "typescript": "3.8.3" + } + } + ], "canaryVersions": [ { "dependencyOverrides": { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index a22694a64304..0da2e1b2e327 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -95,26 +95,6 @@ export const ReplayRecordingData = [ }, timestamp: expect.any(Number), }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'memory', - description: 'memory', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - memory: { - jsHeapSizeLimit: expect.any(Number), - totalJSHeapSize: expect.any(Number), - usedJSHeapSize: expect.any(Number), - }, - }, - }, - }, - }, { type: 3, data: { @@ -155,8 +135,6 @@ export const ReplayRecordingData = [ data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, timestamp: expect.any(Number), }, - ], - [ { type: 5, timestamp: expect.any(Number), diff --git a/packages/e2e-tests/test-applications/sveltekit/package.json b/packages/e2e-tests/test-applications/sveltekit/package.json index ab1a2c9cac2e..5c4139365599 100644 --- a/packages/e2e-tests/test-applications/sveltekit/package.json +++ b/packages/e2e-tests/test-applications/sveltekit/package.json @@ -22,10 +22,16 @@ "svelte": "^3.54.0", "svelte-check": "^3.0.1", "ts-node": "10.9.1", - "tslib": "^2.4.1", + "tslib": "2.4.1", "typescript": "^5.0.0", "vite": "^4.2.0", "wait-port": "1.0.4" }, + "pnpm": { + "overrides": { + "@sentry/node": "*", + "@sentry/tracing": "*" + } + }, "type": "module" } diff --git a/packages/e2e-tests/test-utils/event-proxy-server.ts b/packages/e2e-tests/test-utils/event-proxy-server.ts index c61e20d4081d..b32910480f38 100644 --- a/packages/e2e-tests/test-utils/event-proxy-server.ts +++ b/packages/e2e-tests/test-utils/event-proxy-server.ts @@ -243,7 +243,7 @@ async function registerCallbackServerPort(serverName: string, port: string): Pro await writeFile(tmpFilePath, port, { encoding: 'utf8' }); } -async function retrieveCallbackServerPort(serverName: string): Promise { +function retrieveCallbackServerPort(serverName: string): Promise { const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); - return await readFile(tmpFilePath, 'utf8'); + return readFile(tmpFilePath, 'utf8'); } diff --git a/packages/ember/addon/index.ts b/packages/ember/addon/index.ts index cf1f7e2f23b9..2db7ac4192f6 100644 --- a/packages/ember/addon/index.ts +++ b/packages/ember/addon/index.ts @@ -63,7 +63,7 @@ export function InitSentryForEmber(_runtimeConfig?: BrowserOptions) { } export const getActiveTransaction = () => { - return Sentry.getCurrentHub()?.getScope()?.getTransaction(); + return Sentry.getCurrentHub().getScope().getTransaction(); }; export const instrumentRoutePerformance = (BaseRoute: any) => { diff --git a/packages/ember/package.json b/packages/ember/package.json index 2e184fc17a7e..d4e13264be24 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -75,7 +75,6 @@ "loader.js": "~4.7.0", "qunit": "~2.19.2", "qunit-dom": "~2.0.0", - "typescript": "~4.5.2", "webpack": "~5.74.0" }, "engines": { diff --git a/packages/eslint-config-sdk/src/index.js b/packages/eslint-config-sdk/src/index.js index 05ec68cff509..c070195ed083 100644 --- a/packages/eslint-config-sdk/src/index.js +++ b/packages/eslint-config-sdk/src/index.js @@ -261,5 +261,8 @@ module.exports = { 'array-callback-return': ['error', { allowImplicit: true }], quotes: ['error', 'single', { avoidEscape: true }], + + // Remove uncessary usages of async await to prevent extra micro-tasks + 'no-return-await': 'error', }, }; diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 337af4a11f32..4bc327bd98f9 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -16,6 +16,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -40,7 +43,9 @@ "build:plugin": "tsc -p tsconfig.plugin.json", "build:transpile": "run-p build:rollup build:plugin", "build:rollup": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/hub/package.json b/packages/hub/package.json index ca34143369db..947b4aad6da1 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,13 +22,15 @@ "@sentry/core": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", @@ -40,7 +45,8 @@ "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts && yalc publish build --push" }, "volta": { "extends": "../../package.json" diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 472198466754..eb7e242de4da 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -5,12 +5,17 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "sideEffects": false, "private": true, "scripts": { "build": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:dev": "yarn build", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:watch", diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 13e1f5a04343..4025485a3e68 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -15,11 +15,14 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "dependencies": { "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", "localforage": "^1.8.1", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@sentry/browser": "7.56.0", @@ -30,7 +33,9 @@ "build:bundle": "ts-node scripts/buildBundles.ts --parallel", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/integrations/test/captureconsole.test.ts b/packages/integrations/test/captureconsole.test.ts index 6ff08df2e46f..be766d5f6c8d 100644 --- a/packages/integrations/test/captureconsole.test.ts +++ b/packages/integrations/test/captureconsole.test.ts @@ -113,7 +113,7 @@ describe('CaptureConsole setup', () => { it('setup should fail gracefully when console is not available', () => { const consoleRef = global.console; - // remove console + // @ts-ignore remove console delete global.console; expect(() => { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 69b24dcfbb44..09588b12491d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -13,6 +13,9 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -28,7 +31,7 @@ "chalk": "3.0.0", "rollup": "2.78.0", "stacktrace-parser": "^0.1.10", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/webpack": "^4.41.31", @@ -49,7 +52,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "ts-node scripts/buildRollup.ts", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "nodemon --ext ts --watch src scripts/buildRollup.ts", diff --git a/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts index 8c757be6a3c8..e5f8c40847ff 100644 --- a/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts @@ -8,8 +8,8 @@ type AppGetInitialProps = (typeof App)['getInitialProps']; */ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetInitialProps): AppGetInitialProps { return new Proxy(origAppGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts index 0af40a1f3f84..20669a0af9f6 100644 --- a/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts @@ -10,8 +10,8 @@ export function wrapDocumentGetInitialPropsWithSentry( origDocumentGetInitialProps: DocumentGetInitialProps, ): DocumentGetInitialProps { return new Proxy(origDocumentGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts index 605efa58eff9..ab32a2bf93cc 100644 --- a/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts @@ -11,8 +11,8 @@ export function wrapErrorGetInitialPropsWithSentry( origErrorGetInitialProps: ErrorGetInitialProps, ): ErrorGetInitialProps { return new Proxy(origErrorGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts index 1fbbd8707063..37004f04bc6e 100644 --- a/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts @@ -8,8 +8,8 @@ type GetInitialProps = Required['getInitialProps']; */ export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialProps): GetInitialProps { return new Proxy(origGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts index 2235016856f4..50450c053a15 100644 --- a/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts @@ -6,8 +6,8 @@ import type { GetServerSideProps } from 'next'; */ export function wrapGetServerSidePropsWithSentry(origGetServerSideProps: GetServerSideProps): GetServerSideProps { return new Proxy(origGetServerSideProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts index 735a3cd8a936..3b99737bcf20 100644 --- a/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts @@ -8,8 +8,8 @@ type Props = { [key: string]: unknown }; */ export function wrapGetStaticPropsWithSentry(origGetStaticProps: GetStaticProps): GetStaticProps { return new Proxy(origGetStaticProps, { - apply: async (wrappingTarget, thisArg, args: Parameters>) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters>) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 7e3fd8baae24..d516780c0257 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -116,7 +116,7 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev const frames = stackTraceParser.parse(hint.originalException.stack); const resolvedFrames = await Promise.all( - frames.map(async frame => await resolveStackFrame(frame, hint.originalException as Error)), + frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)), ); if (event.exception?.values?.[0].stacktrace?.frames) { diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts index 3b9bc8ca7045..d324cc2de7c3 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts @@ -16,13 +16,13 @@ export function wrapApiHandlerWithSentryVercelCrons { + apply: (originalFunction, thisArg, args: any[]) => { return runWithAsyncContext(() => { if (!args || !args[0]) { return originalFunction.apply(thisArg, args); } - const [req] = args; + const [req] = args as [NextApiRequest | EdgeRequest]; let maybePromiseResult; const cronsKey = 'nextUrl' in req ? req.nextUrl.pathname : req.url; diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 3157d41df71f..e4d58c579420 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -201,21 +201,21 @@ export default function wrappingLoader( } else { templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, 'Unknown'); } - - // We check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute, - // however we can only create relative paths to the sentry config from absolute paths.Examples where this could possibly be non - absolute are virtual modules. - if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) { - const sentryConfigImportPath = path - .relative(path.dirname(this.resourcePath), sentryConfigFilePath) // Absolute paths do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133 - .replace(/\\/g, '/'); - templateCode = `import "${sentryConfigImportPath}";\n`.concat(templateCode); - } } else if (wrappingTargetKind === 'middleware') { templateCode = middlewareWrapperTemplateCode; } else { throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); } + // We check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute, + // however we can only create relative paths to the sentry config from absolute paths.Examples where this could possibly be non - absolute are virtual modules. + if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) { + const sentryConfigImportPath = path + .relative(path.dirname(this.resourcePath), sentryConfigFilePath) // Absolute paths do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133 + .replace(/\\/g, '/'); + templateCode = `import "${sentryConfigImportPath}";\n`.concat(templateCode); + } + // Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand. templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME); diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0bb42f98b7ec..bb5ef56ce4ad 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ /* eslint-disable max-lines */ import { getSentryRelease } from '@sentry/node'; -import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger, stringMatchesSomePattern } from '@sentry/utils'; +import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger } from '@sentry/utils'; import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin'; import * as chalk from 'chalk'; import * as fs from 'fs'; @@ -180,8 +180,7 @@ export function constructWebpackConfigFunction( } } } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (e.code === 'ENOENT') { + if ((e as { code: string }).code === 'ENOENT') { // noop if file does not exist } else { // log but noop @@ -441,7 +440,7 @@ async function addSentryToEntryProperty( // inject into all entry points which might contain user's code for (const entryPointName in newEntryProperty) { - if (shouldAddSentryToEntryPoint(entryPointName, runtime, userSentryOptions.excludeServerRoutes ?? [])) { + if (shouldAddSentryToEntryPoint(entryPointName, runtime)) { addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject); } else { if ( @@ -589,39 +588,13 @@ function checkWebpackPluginOverrides( * @param excludeServerRoutes A list of excluded serverside entrypoints provided by the user * @returns `true` if sentry code should be injected, and `false` otherwise */ -function shouldAddSentryToEntryPoint( - entryPointName: string, - runtime: 'node' | 'browser' | 'edge', - excludeServerRoutes: Array, -): boolean { - // On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions). - if (runtime === 'node') { - // User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes, - // which don't have the `pages` prefix.) - const entryPointRoute = entryPointName.replace(/^pages/, ''); - if (stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true)) { - return false; - } - - // This expression will implicitly include `pages/_app` which is called for all serverside routes and pages - // regardless whether or not the user has a`_app` file. - return entryPointName.startsWith('pages/'); - } else if (runtime === 'browser') { - return ( - // entrypoint for `/pages` pages - this is included on all clientside pages - // It's important that we inject the SDK into this file and not into 'main' because in 'main' - // some important Next.js code (like the setup code for getCongig()) is located and some users - // may need this code inside their Sentry configs - entryPointName === 'pages/_app' || +function shouldAddSentryToEntryPoint(entryPointName: string, runtime: 'node' | 'browser' | 'edge'): boolean { + return ( + runtime === 'browser' && + (entryPointName === 'pages/_app' || // entrypoint for `/app` pages - entryPointName === 'main-app' - ); - } else { - // User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes, - // which don't have the `pages` prefix.) - const entryPointRoute = entryPointName.replace(/^pages/, ''); - return !stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true); - } + entryPointName === 'main-app') + ); } /** diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index ef228abc40e9..f903d77f46c4 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -11,10 +11,10 @@ export function wrapApiHandlerWithSentry( parameterizedRoute: string, ): (...params: Parameters) => Promise> { return new Proxy(handler, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { const req = args[0]; - const activeSpan = !!getCurrentHub().getScope()?.getSpan(); + const activeSpan = getCurrentHub().getScope().getSpan(); const wrappedHandler = withEdgeWrapping(wrappingTarget, { spanDescription: @@ -25,7 +25,7 @@ export function wrapApiHandlerWithSentry( mechanismFunctionName: 'wrapApiHandlerWithSentry', }); - return await wrappedHandler.apply(thisArg, args); + return wrappedHandler.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts index 18c16f1a4198..831a50eb8629 100644 --- a/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts @@ -11,7 +11,7 @@ export function wrapMiddlewareWithSentry( middleware: H, ): (...params: Parameters) => Promise> { return new Proxy(middleware, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { return withEdgeWrapping(wrappingTarget, { spanDescription: 'middleware', spanOp: 'middleware.nextjs', diff --git a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts index 3ebf97d4b614..9aecbd9a6c6b 100644 --- a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts @@ -26,7 +26,7 @@ import { autoEndTransactionOnResponseEnd, finishTransaction, flushQueue } from ' */ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameterizedRoute: string): NextApiHandler { return new Proxy(apiHandler, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { // eslint-disable-next-line deprecation/deprecation return withSentry(wrappingTarget, parameterizedRoute).apply(thisArg, args); }, @@ -49,7 +49,7 @@ export const withSentryAPI = wrapApiHandlerWithSentry; */ export function withSentry(apiHandler: NextApiHandler, parameterizedRoute?: string): NextApiHandler { return new Proxy(apiHandler, { - apply: async (wrappingTarget, thisArg, args: [AugmentedNextApiRequest, AugmentedNextApiResponse]) => { + apply: (wrappingTarget, thisArg, args: [AugmentedNextApiRequest, AugmentedNextApiResponse]) => { const [req, res] = args; // We're now auto-wrapping API route handlers using `wrapApiHandlerWithSentry` (which uses `withSentry` under the hood), but diff --git a/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts index e7d0d6eac621..1d821d86dcda 100644 --- a/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts @@ -19,7 +19,7 @@ export function wrapDocumentGetInitialPropsWithSentry( origDocumentGetInitialProps: DocumentGetInitialProps, ): DocumentGetInitialProps { return new Proxy(origDocumentGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { if (isBuild()) { return wrappingTarget.apply(thisArg, args); } @@ -41,7 +41,7 @@ export function wrapDocumentGetInitialPropsWithSentry( dataFetchingMethodName: 'getInitialProps', }); - return await tracedGetInitialProps.apply(thisArg, args); + return tracedGetInitialProps.apply(thisArg, args); } else { return errorWrappedGetInitialProps.apply(thisArg, args); } diff --git a/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts index d8c7cc8f68ab..78f910dfb0e4 100644 --- a/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts @@ -19,7 +19,7 @@ export function wrapGetStaticPropsWithSentry( parameterizedRoute: string, ): GetStaticProps { return new Proxy(origGetStaticPropsa, { - apply: async (wrappingTarget, thisArg, args: Parameters>) => { + apply: (wrappingTarget, thisArg, args: Parameters>) => { if (isBuild()) { return wrappingTarget.apply(thisArg, args); } diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 01f89bed1077..86bb2d03d3fd 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -7,10 +7,7 @@ import { CLIENT_SDK_CONFIG_FILE, clientBuildContext, clientWebpackConfig, - EDGE_SDK_CONFIG_FILE, - edgeBuildContext, exportedNextConfig, - SERVER_SDK_CONFIG_FILE, serverBuildContext, serverWebpackConfig, userNextConfig, @@ -88,74 +85,15 @@ describe('constructWebpackConfigFunction()', () => { }); describe('webpack `entry` property config', () => { - const serverConfigFilePath = `./${SERVER_SDK_CONFIG_FILE}`; const clientConfigFilePath = `./${CLIENT_SDK_CONFIG_FILE}`; - const edgeConfigFilePath = `./${EDGE_SDK_CONFIG_FILE}`; - - it('handles various entrypoint shapes', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - // original entrypoint value is a string - // (was 'private-next-pages/_error.js') - 'pages/_error': [serverConfigFilePath, 'private-next-pages/_error.js'], - - // original entrypoint value is a string array - // (was ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js']) - 'pages/sniffTour': [ - serverConfigFilePath, - './node_modules/smellOVision/index.js', - 'private-next-pages/sniffTour.js', - ], - - // original entrypoint value is an object containing a string `import` value - // (was { import: 'private-next-pages/api/simulator/dogStats/[name].js' }) - 'pages/api/simulator/dogStats/[name]': { - import: [serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'], - }, - - // original entrypoint value is an object containing a string array `import` value - // (was { import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'] }) - 'pages/simulator/leaderboard': { - import: [ - serverConfigFilePath, - './node_modules/dogPoints/converter.js', - 'private-next-pages/simulator/leaderboard.js', - ], - }, - - // original entrypoint value is an object containg properties besides `import` - // (was { import: 'private-next-pages/api/tricks/[trickName].js', dependOn: 'treats', }) - 'pages/api/tricks/[trickName]': { - import: [serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'], - dependOn: 'treats', // untouched - }, - }), - ); - }); it('injects user config file into `_app` in server bundle and in the client bundle', async () => { - const finalServerWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); const finalClientWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, incomingWebpackConfig: clientWebpackConfig, incomingWebpackBuildContext: clientBuildContext, }); - expect(finalServerWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_app': expect.arrayContaining([serverConfigFilePath]), - }), - ); expect(finalClientWebpackConfig.entry).toEqual( expect.objectContaining({ 'pages/_app': expect.arrayContaining([clientConfigFilePath]), @@ -163,68 +101,6 @@ describe('constructWebpackConfigFunction()', () => { ); }); - it('injects user config file into `_error` in server bundle but not client bundle', async () => { - const finalServerWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - const finalClientWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - }); - - expect(finalServerWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_error': expect.arrayContaining([serverConfigFilePath]), - }), - ); - expect(finalClientWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_error': expect.not.arrayContaining([clientConfigFilePath]), - }), - ); - }); - - it('injects user config file into both API routes and non-API routes', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/api/simulator/dogStats/[name]': { - import: expect.arrayContaining([serverConfigFilePath]), - }, - - 'pages/api/tricks/[trickName]': expect.objectContaining({ - import: expect.arrayContaining([serverConfigFilePath]), - }), - - 'pages/simulator/leaderboard': { - import: expect.arrayContaining([serverConfigFilePath]), - }, - }), - ); - }); - - it('injects user config file into API middleware', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: edgeBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - middleware: [edgeConfigFilePath, 'private-next-pages/middleware.js'], - }), - ); - }); - it('does not inject anything into non-_app pages during client build', async () => { const finalWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, @@ -244,30 +120,5 @@ describe('constructWebpackConfigFunction()', () => { simulatorBundle: './src/simulator/index.ts', }); }); - - it('does not inject into routes included in `excludeServerRoutes`', async () => { - const nextConfigWithExcludedRoutes = { - ...exportedNextConfig, - sentry: { - excludeServerRoutes: [/simulator/], - }, - }; - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig: nextConfigWithExcludedRoutes, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/simulator/leaderboard': { - import: expect.not.arrayContaining([serverConfigFilePath]), - }, - 'pages/api/simulator/dogStats/[name]': { - import: expect.not.arrayContaining([serverConfigFilePath]), - }, - }), - ); - }); }); }); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 08a91e0c5e11..a991ecf88e6b 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -73,7 +73,7 @@ describe('wrapApiHandlerWithSentry', () => { it('should return a function that starts a span on the current transaction with the correct description when there is an active transaction and no request is being passed', async () => { const testTransaction = coreSdk.startTransaction({ name: 'testTransaction' }); - coreSdk.getCurrentHub().getScope()?.setSpan(testTransaction); + coreSdk.getCurrentHub().getScope().setSpan(testTransaction); const startChildSpy = jest.spyOn(testTransaction, 'startChild'); @@ -92,6 +92,6 @@ describe('wrapApiHandlerWithSentry', () => { ); testTransaction.finish(); - coreSdk.getCurrentHub().getScope()?.setSpan(undefined); + coreSdk.getCurrentHub().getScope().setSpan(undefined); }); }); diff --git a/packages/nextjs/test/integration/package.json b/packages/nextjs/test/integration/package.json index 5c55363fe714..a382ac88f62c 100644 --- a/packages/nextjs/test/integration/package.json +++ b/packages/nextjs/test/integration/package.json @@ -21,7 +21,7 @@ "@types/react": "17.0.47", "@types/react-dom": "17.0.17", "nock": "^13.1.0", - "typescript": "^4.2.4", + "typescript": "4.9.5", "yargs": "^16.2.0" }, "resolutions": { diff --git a/packages/nextjs/test/integration/test/client/tracingFetch.test.ts b/packages/nextjs/test/integration/test/client/tracingFetch.test.ts index 88634c92012e..b1eb8a5f1bb8 100644 --- a/packages/nextjs/test/integration/test/client/tracingFetch.test.ts +++ b/packages/nextjs/test/integration/test/client/tracingFetch.test.ts @@ -36,6 +36,7 @@ test('should correctly instrument `fetch` for performance tracing', async ({ pag url: 'http://example.com', type: 'fetch', 'http.response_content_length': expect.any(Number), + 'http.response.status_code': 200, }, description: 'GET http://example.com', op: 'http.client', diff --git a/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts b/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts index fc0ae186f64b..dfbf3b620aa8 100644 --- a/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts +++ b/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts @@ -18,6 +18,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${unwrappedRoute}`, @@ -51,6 +54,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${wrappedRoute}`, @@ -84,6 +90,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${route}`, diff --git a/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts b/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts index 45168eeeee33..d259db3f2801 100644 --- a/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts +++ b/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts @@ -45,6 +45,9 @@ describe('Error API Endpoints', () => { op: 'http.server', status: 'internal_error', tags: { 'http.status_code': '500' }, + data: { + 'http.response.status_code': 500, + }, }, }, transaction: 'GET /api/error', diff --git a/packages/nextjs/test/integration/test/server/tracing200.test.ts b/packages/nextjs/test/integration/test/server/tracing200.test.ts index f68279138558..ac6b2db163aa 100644 --- a/packages/nextjs/test/integration/test/server/tracing200.test.ts +++ b/packages/nextjs/test/integration/test/server/tracing200.test.ts @@ -16,6 +16,9 @@ describe('Tracing 200', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: 'GET /api/users', diff --git a/packages/nextjs/test/integration/test/server/tracing500.test.ts b/packages/nextjs/test/integration/test/server/tracing500.test.ts index 79b23dcfb786..b94fe781e2d2 100644 --- a/packages/nextjs/test/integration/test/server/tracing500.test.ts +++ b/packages/nextjs/test/integration/test/server/tracing500.test.ts @@ -16,6 +16,9 @@ describe('Tracing 500', () => { op: 'http.server', status: 'internal_error', tags: { 'http.status_code': '500' }, + data: { + 'http.response.status_code': 500, + }, }, }, transaction: 'GET /api/broken', diff --git a/packages/nextjs/test/integration/test/server/tracingHttp.test.ts b/packages/nextjs/test/integration/test/server/tracingHttp.test.ts index 912c54e0996b..0f8615c4b7f0 100644 --- a/packages/nextjs/test/integration/test/server/tracingHttp.test.ts +++ b/packages/nextjs/test/integration/test/server/tracingHttp.test.ts @@ -20,6 +20,9 @@ describe('Tracing HTTP', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, spans: [ @@ -28,6 +31,9 @@ describe('Tracing HTTP', () => { op: 'http.client', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, ], transaction: 'GET /api/http', diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 1d2dd60d053c..8e73f71b3771 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -21,6 +21,7 @@ function findIntegrationByName(integrations: Integration[] = [], name: string): describe('Server init()', () => { afterEach(() => { jest.clearAllMocks(); + // @ts-ignore for testing delete GLOBAL_OBJ.__SENTRY__; delete process.env.VERCEL; }); diff --git a/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts b/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts index 16f5371bc1ee..f582288f8314 100644 --- a/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts +++ b/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts @@ -25,7 +25,7 @@ app.use(Sentry.Handlers.tracingHandler()); app.use(cors()); app.get('/test/express', (_req, res) => { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const transaction = Sentry.getCurrentHub().getScope().getTransaction(); if (transaction) { transaction.traceId = '86f39e84263a4de99c326acab3bfe3bd'; } diff --git a/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts b/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts index 3d8afeaed2f8..78e24ffa9f10 100644 --- a/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts +++ b/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts @@ -27,7 +27,7 @@ app.use(Sentry.Handlers.tracingHandler()); app.use(cors()); app.get('/test/express', (_req, res) => { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const transaction = Sentry.getCurrentHub().getScope().getTransaction(); if (transaction) { transaction.traceId = '86f39e84263a4de99c326acab3bfe3bd'; transaction.setMetadata({ source: 'route' }); diff --git a/packages/node-integration-tests/suites/express/tracing/test.ts b/packages/node-integration-tests/suites/express/tracing/test.ts index ae9d619c48cc..835c2938ae5b 100644 --- a/packages/node-integration-tests/suites/express/tracing/test.ts +++ b/packages/node-integration-tests/suites/express/tracing/test.ts @@ -11,6 +11,7 @@ test('should create and send transactions for Express routes and spans for middl trace: { data: { url: '/test/express', + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -43,6 +44,7 @@ test('should set a correct transaction name for routes specified in RegEx', asyn trace: { data: { url: '/test/regex', + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -71,6 +73,7 @@ test.each([['array1'], ['array5']])( trace: { data: { url: `/test/${segment}`, + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -107,6 +110,7 @@ test.each([ trace: { data: { url: `/test/${segment}`, + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', diff --git a/packages/node-integration-tests/utils/run-tests.ts b/packages/node-integration-tests/utils/run-tests.ts index f29aa1a2399b..2c6715f451ab 100644 --- a/packages/node-integration-tests/utils/run-tests.ts +++ b/packages/node-integration-tests/utils/run-tests.ts @@ -13,7 +13,7 @@ const workers = os.cpus().map(async (_, i) => { while (testPaths.length > 0) { const testPath = testPaths.pop(); console.log(`(Worker ${i}) Running test "${testPath}"`); - await new Promise(resolve => { + await new Promise(resolve => { const jestProcess = childProcess.spawn('jest', ['--runTestsByPath', testPath as string, '--forceExit']); // We're collecting the output and logging it all at once instead of inheriting stdout and stderr, so that diff --git a/packages/node/package.json b/packages/node/package.json index c24c033ab70f..7601f80d172a 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -23,7 +26,7 @@ "cookie": "^0.4.1", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/cookie": "0.3.2", @@ -38,7 +41,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 3e10c09eb903..95a4cfe65e38 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -163,7 +163,7 @@ export function requestHandler( // If Scope contains a Single mode Session, it is removed in favor of using Session Aggregates mode const scope = currentHub.getScope(); - if (scope && scope.getSession()) { + if (scope.getSession()) { scope.setSession(); } } @@ -339,7 +339,7 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { return function ({ path, type, next, rawInput }: TrpcMiddlewareArguments): T { const hub = getCurrentHub(); const clientOptions = hub.getClient()?.getOptions(); - const sentryTransaction = hub.getScope()?.getTransaction(); + const sentryTransaction = hub.getScope().getTransaction(); if (sentryTransaction) { sentryTransaction.setName(`trpc/${path}`, 'route'); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 5e2ce9e253a2..54d761861348 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -20,6 +20,12 @@ interface TracingOptions { * requests. If this option is provided, the SDK will match the * request URL of outgoing requests against the items in this * array, and only attach tracing headers if a match was found. + * + * @deprecated Use top level `tracePropagationTargets` option instead. + * ``` + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }) */ tracePropagationTargets?: TracePropagationTargets; @@ -156,6 +162,7 @@ function _createWrappedRequestMethodFactory( }; const shouldAttachTraceData = (url: string): boolean => { + // eslint-disable-next-line deprecation/deprecation if (tracingOptions?.tracePropagationTargets === undefined) { return true; } @@ -165,6 +172,7 @@ function _createWrappedRequestMethodFactory( return cachedDecision; } + // eslint-disable-next-line deprecation/deprecation const decision = stringMatchesSomePattern(url, tracingOptions.tracePropagationTargets); headersUrlMap.set(url, decision); return decision; @@ -184,9 +192,7 @@ function _createWrappedRequestMethodFactory( } let requestSpan: Span | undefined; - let parentSpan: Span | undefined; - - const scope = getCurrentHub().getScope(); + const parentSpan = getCurrentHub().getScope().getSpan(); const method = requestOptions.method || 'GET'; const requestSpanData: SanitizedRequestData = { @@ -202,9 +208,7 @@ function _createWrappedRequestMethodFactory( requestSpanData['http.query'] = requestOptions.search.substring(1); } - if (scope && tracingOptions && shouldCreateSpan(rawRequestUrl)) { - parentSpan = scope.getSpan(); - + if (tracingOptions && shouldCreateSpan(rawRequestUrl)) { if (parentSpan) { requestSpan = parentSpan.startChild({ description: `${method} ${requestSpanData.url}`, diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index d0a02c746247..9e55fd4b5a84 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -276,7 +276,7 @@ function startSessionTracking(): void { // such as calling process.exit() or uncaught exceptions. // Ref: https://nodejs.org/api/process.html#process_event_beforeexit process.on('beforeExit', () => { - const session = hub.getScope()?.getSession(); + const session = hub.getScope().getSession(); const terminalStates: SessionStatus[] = ['exited', 'crashed']; // Only call endSession, if the Session exists on Scope and SessionStatus is not a // Terminal Status i.e. Exited or Crashed because diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3e464d1c6457..b0ecb354dd82 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, SamplingContext, TracePropagationTargets } from '@sentry/types'; +import type { ClientOptions, Options, SamplingContext } from '@sentry/types'; import type { NodeTransportOptions } from './transports'; @@ -32,24 +32,6 @@ export interface BaseNodeOptions { */ includeLocalVariables?: boolean; - /** - * List of strings/regex controlling to which outgoing requests - * the SDK will attach tracing headers. - * - * By default the SDK will attach those headers to all outgoing - * requests. If this option is provided, the SDK will match the - * request URL of outgoing requests against the items in this - * array, and only attach tracing headers if a match was found. - * - * @example - * ```js - * Sentry.init({ - * tracePropagationTargets: ['api.site.com'], - * }); - * ``` - */ - tracePropagationTargets?: TracePropagationTargets; - // TODO (v8): Remove this in v8 /** * @deprecated Moved to constructor options of the `Http` and `Undici` integration. diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 3cf5127ce848..298d61cf1aac 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -319,7 +319,7 @@ describe('tracingHandler', () => { sentryTracingMiddleware(req, res, next); - const transaction = sentryCore.getCurrentHub().getScope()?.getTransaction(); + const transaction = sentryCore.getCurrentHub().getScope().getTransaction(); expect(transaction).toBeDefined(); expect(transaction).toEqual( @@ -351,6 +351,7 @@ describe('tracingHandler', () => { expect(finishTransaction).toHaveBeenCalled(); expect(transaction.status).toBe('ok'); expect(transaction.tags).toEqual(expect.objectContaining({ 'http.status_code': '200' })); + expect(transaction.data).toEqual(expect.objectContaining({ 'http.response.status_code': 200 })); done(); }); }); @@ -439,7 +440,7 @@ describe('tracingHandler', () => { sentryTracingMiddleware(req, res, next); - const transaction = sentryCore.getCurrentHub().getScope()?.getTransaction(); + const transaction = sentryCore.getCurrentHub().getScope().getTransaction(); expect(transaction?.metadata.request).toEqual(req); }); diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 8b035dff7b03..ab9bd41adaf9 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -239,7 +239,7 @@ describe('SentryNode', () => { try { throw new Error('cause'); } catch (c) { - e.cause = c; + (e as any).cause = c; captureException(e); } } diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index 6481e9481bf2..3f5a87d15363 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -49,7 +49,7 @@ describe('tracing', () => { ...customContext, }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } @@ -266,7 +266,7 @@ describe('tracing', () => { function createTransactionAndPutOnScope(hub: Hub) { addTracingExtensions(); const transaction = hub.startTransaction({ name: 'dogpark' }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } diff --git a/packages/node/test/integrations/requestdata.test.ts b/packages/node/test/integrations/requestdata.test.ts index 91d9870f8292..52e20c9d6e4b 100644 --- a/packages/node/test/integrations/requestdata.test.ts +++ b/packages/node/test/integrations/requestdata.test.ts @@ -126,7 +126,7 @@ describe('`RequestData` integration', () => { type GCPHandler = (req: PolymorphicRequest, res: http.ServerResponse) => void; const mockGCPWrapper = (origHandler: GCPHandler, options: Record): GCPHandler => { const wrappedHandler: GCPHandler = (req, res) => { - getCurrentHub().getScope()?.setSDKProcessingMetadata({ + getCurrentHub().getScope().setSDKProcessingMetadata({ request: req, requestDataOptionsFromGCPWrapper: options, }); diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index f4925f7046a2..46756cbe88cd 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -56,9 +56,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { description: 'GET http://localhost:18099/', op: 'http.client', - data: { + data: expect.objectContaining({ 'http.method': 'GET', - }, + }), }, ], [ @@ -68,10 +68,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { { description: 'GET http://localhost:18099/', op: 'http.client', - data: { + data: expect.objectContaining({ 'http.method': 'GET', 'http.query': '?foo=bar', - }, + }), }, ], [ @@ -80,9 +80,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { method: 'POST' }, { description: 'POST http://localhost:18099/', - data: { + data: expect.objectContaining({ 'http.method': 'POST', - }, + }), }, ], [ @@ -91,9 +91,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { method: 'POST' }, { description: 'POST http://localhost:18099/', - data: { + data: expect.objectContaining({ 'http.method': 'POST', - }, + }), }, ], [ diff --git a/packages/node/test/utils.test.ts b/packages/node/test/utils.test.ts index 6c8bb3627852..0a62cf011c92 100644 --- a/packages/node/test/utils.test.ts +++ b/packages/node/test/utils.test.ts @@ -38,7 +38,7 @@ describe('deepReadDirSync', () => { expect(deepReadDirSync(dirPath)).toEqual([]); done(); } catch (error) { - done(error); + done(error as Error); } }); }); diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index 4cd6cde98e76..d2264e50c1d1 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -38,7 +41,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index 97256bd867a4..cde0c7338d00 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -114,7 +114,7 @@ describe('SentrySpanProcessor', () => { expect(sentrySpan?.spanId).toEqual(childOtelSpan.spanContext().spanId); expect(sentrySpan?.parentSpanId).toEqual(sentrySpanTransaction?.spanId); - expect(hub.getScope()?.getSpan()).toBeUndefined(); + expect(hub.getScope().getSpan()).toBeUndefined(); child.end(endTime); diff --git a/packages/overhead-metrics/package.json b/packages/overhead-metrics/package.json index 910f6b0e001c..2d158435af7b 100644 --- a/packages/overhead-metrics/package.json +++ b/packages/overhead-metrics/package.json @@ -32,7 +32,7 @@ "playwright-core": "^1.29.1", "simple-git": "^3.16.0", "simple-statistics": "^7.8.0", - "typescript": "^4.9.4" + "typescript": "4.9.5" }, "devDependencies": { "ts-node": "^10.9.1" diff --git a/packages/react/package.json b/packages/react/package.json index b0f360019949..c947b4ad4fb1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -20,7 +23,7 @@ "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", "hoist-non-react-statics": "^3.3.2", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { "react": "15.x || 16.x || 17.x || 18.x" @@ -54,7 +57,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx index fe91dab07979..a01da694ce2f 100644 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ b/packages/react/test/reactrouterv6.4.test.tsx @@ -63,6 +63,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(1); @@ -98,6 +99,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -135,6 +137,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -172,6 +175,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -221,6 +225,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -254,6 +259,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(1); diff --git a/packages/remix/package.json b/packages/remix/package.json index e964b237149c..97722d1b9bbc 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -16,6 +16,9 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -26,7 +29,7 @@ "@sentry/react": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3", + "tslib": "^2.4.1 || ^1.9.3", "yargs": "^17.6.0" }, "devDependencies": { @@ -44,7 +47,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", @@ -59,7 +64,9 @@ "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", "test": "yarn test:unit", - "test:integration": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server", + "test:integration": "run-s test:integration:v1 test:integration:v2", + "test:integration:v1": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server", + "test:integration:v2": "export REMIX_VERSION=2 && run-s test:integration:v1", "test:integration:ci": "run-s test:integration:clean test:integration:prepare test:integration:client:ci test:integration:server", "test:integration:prepare": "(cd test/integration && yarn)", "test:integration:clean": "(cd test/integration && rimraf .cache node_modules build)", diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index ef5449067df9..5e0f300bf82e 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -19,6 +19,7 @@ import type { CreateRequestHandlerFunction, DataFunction, DataFunctionArgs, + EntryContext, HandleDocumentRequestFunction, ReactRouterDomPkg, RemixRequest, @@ -56,7 +57,7 @@ async function extractResponseError(response: Response): Promise { return responseData; } -async function captureRemixServerException(err: Error, name: string, request: Request): Promise { +async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { // Skip capturing if the thrown error is not a 5xx response // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders if (isResponse(err) && err.status < 500) { @@ -112,7 +113,7 @@ function makeWrappedDocumentRequestFunction( request: Request, responseStatusCode: number, responseHeaders: Headers, - context: Record, + context: EntryContext, loadContext?: Record, ): Promise { let res: Response; @@ -306,7 +307,7 @@ export function startRequestHandlerTransaction( }, }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts index 59cb299e489b..000ad3a00b15 100644 --- a/packages/remix/src/utils/serverAdapters/express.ts +++ b/packages/remix/src/utils/serverAdapters/express.ts @@ -142,7 +142,7 @@ async function finishSentryProcessing(res: AugmentedExpressResponse): Promise { + await new Promise(resolve => { setImmediate(() => { transaction.finish(); resolve(); diff --git a/packages/remix/test/integration/app/entry.client.tsx b/packages/remix/test/integration/app_v1/entry.client.tsx similarity index 100% rename from packages/remix/test/integration/app/entry.client.tsx rename to packages/remix/test/integration/app_v1/entry.client.tsx diff --git a/packages/remix/test/integration/app/entry.server.tsx b/packages/remix/test/integration/app_v1/entry.server.tsx similarity index 100% rename from packages/remix/test/integration/app/entry.server.tsx rename to packages/remix/test/integration/app_v1/entry.server.tsx diff --git a/packages/remix/test/integration/app/root.tsx b/packages/remix/test/integration/app_v1/root.tsx similarity index 100% rename from packages/remix/test/integration/app/root.tsx rename to packages/remix/test/integration/app_v1/root.tsx diff --git a/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx b/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx new file mode 100644 index 000000000000..ed034a14c52a --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/action-json-response.$id'; +export { default } from '../../../common/routes/action-json-response.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/capture-exception.tsx b/packages/remix/test/integration/app_v1/routes/capture-exception.tsx new file mode 100644 index 000000000000..1ba745d2e63d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/capture-exception.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-exception'; +export { default } from '../../common/routes/capture-exception'; diff --git a/packages/remix/test/integration/app_v1/routes/capture-message.tsx b/packages/remix/test/integration/app_v1/routes/capture-message.tsx new file mode 100644 index 000000000000..9dae2318cc14 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/capture-message.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-message'; +export { default } from '../../common/routes/capture-message'; diff --git a/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx b/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx new file mode 100644 index 000000000000..2c287dfe9696 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/error-boundary-capture.$id'; +export { default } from '../../../common/routes/error-boundary-capture.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/index.tsx b/packages/remix/test/integration/app_v1/routes/index.tsx new file mode 100644 index 000000000000..22c086a4c2cf --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/index.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/index'; +export { default } from '../../common/routes/index'; diff --git a/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx b/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx new file mode 100644 index 000000000000..fd3a7b3f898d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/loader-defer-response'; +export { default } from '../../../common/routes/loader-defer-response'; diff --git a/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx b/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx new file mode 100644 index 000000000000..ddf33953d77d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/loader-json-response.$id'; +export { default } from '../../../common/routes/loader-json-response.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx b/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx new file mode 100644 index 000000000000..9979714818ff --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/manual-tracing.$id'; +export { default } from '../../../common/routes/manual-tracing.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx b/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx new file mode 100644 index 000000000000..d86864dccb9b --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/scope-bleed.$id'; +export { default } from '../../../common/routes/scope-bleed.$id'; diff --git a/packages/remix/test/integration/app_v2/entry.client.tsx b/packages/remix/test/integration/app_v2/entry.client.tsx new file mode 100644 index 000000000000..f9cfc14f2507 --- /dev/null +++ b/packages/remix/test/integration/app_v2/entry.client.tsx @@ -0,0 +1,16 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { hydrate } from 'react-dom'; +import * as Sentry from '@sentry/remix'; +import { useEffect } from 'react'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + ], +}); + +hydrate(, document); diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx new file mode 100644 index 000000000000..ae879492e236 --- /dev/null +++ b/packages/remix/test/integration/app_v2/entry.server.tsx @@ -0,0 +1,27 @@ +import type { EntryContext } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToString } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Disabling to test series of envelopes deterministically. + autoSessionTracking: false, +}); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + let markup = renderToString(); + + responseHeaders.set('Content-Type', 'text/html'); + + return new Response('' + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix/test/integration/app_v2/root.tsx b/packages/remix/test/integration/app_v2/root.tsx new file mode 100644 index 000000000000..faf075951d69 --- /dev/null +++ b/packages/remix/test/integration/app_v2/root.tsx @@ -0,0 +1,60 @@ +import { V2_MetaFunction, LoaderFunction, json, defer, redirect } from '@remix-run/node'; +import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; +import { withSentry } from '@sentry/remix'; + +export const meta: V2_MetaFunction = ({ data }) => [ + { charset: 'utf-8' }, + { title: 'New Remix App' }, + { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + { name: 'sentry-trace', content: data.sentryTrace }, + { name: 'baggage', content: data.sentryBaggage }, +]; + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const type = url.searchParams.get('type'); + + switch (type) { + case 'empty': + return {}; + case 'plain': + return { + data_one: [], + data_two: 'a string', + }; + case 'json': + return json({ data_one: [], data_two: 'a string' }, { headers: { 'Cache-Control': 'max-age=300' } }); + case 'defer': + return defer({ data_one: [], data_two: 'a string' }); + case 'null': + return null; + case 'undefined': + return undefined; + case 'throwRedirect': + throw redirect('/?type=plain'); + case 'returnRedirect': + return redirect('/?type=plain'); + default: { + return {}; + } + } +}; + +function App() { + return ( + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx b/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx new file mode 100644 index 000000000000..7a00bfb2bfe7 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/action-json-response.$id'; +export { default } from '../../common/routes/action-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/capture-exception.tsx b/packages/remix/test/integration/app_v2/routes/capture-exception.tsx new file mode 100644 index 000000000000..1ba745d2e63d --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/capture-exception.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-exception'; +export { default } from '../../common/routes/capture-exception'; diff --git a/packages/remix/test/integration/app_v2/routes/capture-message.tsx b/packages/remix/test/integration/app_v2/routes/capture-message.tsx new file mode 100644 index 000000000000..9dae2318cc14 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/capture-message.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-message'; +export { default } from '../../common/routes/capture-message'; diff --git a/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx b/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx new file mode 100644 index 000000000000..011f92462069 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/error-boundary-capture.$id'; +export { default } from '../../common/routes/error-boundary-capture.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/index.tsx b/packages/remix/test/integration/app_v2/routes/index.tsx new file mode 100644 index 000000000000..22c086a4c2cf --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/index.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/index'; +export { default } from '../../common/routes/index'; diff --git a/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx b/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx new file mode 100644 index 000000000000..38415a9a3781 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-defer-response'; +export { default } from '../../common/routes/loader-defer-response'; diff --git a/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx b/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx new file mode 100644 index 000000000000..7761875bdb76 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-json-response.$id'; +export { default } from '../../common/routes/loader-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx b/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx new file mode 100644 index 000000000000..a7cfebe4ed46 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/manual-tracing.$id'; +export { default } from '../../common/routes/manual-tracing.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx b/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx new file mode 100644 index 000000000000..5ba2376f0339 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/scope-bleed.$id'; +export { default } from '../../common/routes/scope-bleed.$id'; diff --git a/packages/remix/test/integration/app/routes/action-json-response/$id.tsx b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/action-json-response/$id.tsx rename to packages/remix/test/integration/common/routes/action-json-response.$id.tsx diff --git a/packages/remix/test/integration/app/routes/capture-exception.tsx b/packages/remix/test/integration/common/routes/capture-exception.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/capture-exception.tsx rename to packages/remix/test/integration/common/routes/capture-exception.tsx diff --git a/packages/remix/test/integration/app/routes/capture-message.tsx b/packages/remix/test/integration/common/routes/capture-message.tsx similarity index 89% rename from packages/remix/test/integration/app/routes/capture-message.tsx rename to packages/remix/test/integration/common/routes/capture-message.tsx index 459e25e1b4ee..06e92f79e931 100644 --- a/packages/remix/test/integration/app/routes/capture-message.tsx +++ b/packages/remix/test/integration/common/routes/capture-message.tsx @@ -3,5 +3,5 @@ import * as Sentry from '@sentry/remix'; export default function ErrorBoundaryCapture() { Sentry.captureMessage('Sentry Manually Captured Message'); - return
; + return
; } diff --git a/packages/remix/test/integration/app/routes/error-boundary-capture/$id.tsx b/packages/remix/test/integration/common/routes/error-boundary-capture.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/error-boundary-capture/$id.tsx rename to packages/remix/test/integration/common/routes/error-boundary-capture.$id.tsx diff --git a/packages/remix/test/integration/app/routes/index.tsx b/packages/remix/test/integration/common/routes/index.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/index.tsx rename to packages/remix/test/integration/common/routes/index.tsx diff --git a/packages/remix/test/integration/app/routes/loader-defer-response/index.tsx b/packages/remix/test/integration/common/routes/loader-defer-response.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/loader-defer-response/index.tsx rename to packages/remix/test/integration/common/routes/loader-defer-response.tsx diff --git a/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx b/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/loader-json-response/$id.tsx rename to packages/remix/test/integration/common/routes/loader-json-response.$id.tsx diff --git a/packages/remix/test/integration/app/routes/manual-tracing/$id.tsx b/packages/remix/test/integration/common/routes/manual-tracing.$id.tsx similarity index 91% rename from packages/remix/test/integration/app/routes/manual-tracing/$id.tsx rename to packages/remix/test/integration/common/routes/manual-tracing.$id.tsx index 75cf8574819a..2f925881b9cf 100644 --- a/packages/remix/test/integration/app/routes/manual-tracing/$id.tsx +++ b/packages/remix/test/integration/common/routes/manual-tracing.$id.tsx @@ -3,5 +3,5 @@ import * as Sentry from '@sentry/remix'; export default function ManualTracing() { const transaction = Sentry.startTransaction({ name: 'test_transaction_1' }); transaction.finish(); - return
; + return
; } diff --git a/packages/remix/test/integration/app/routes/scope-bleed/$id.tsx b/packages/remix/test/integration/common/routes/scope-bleed.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/scope-bleed/$id.tsx rename to packages/remix/test/integration/common/routes/scope-bleed.$id.tsx diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 821a4242a04d..fa6db4823643 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -20,7 +20,7 @@ "@types/react": "^17.0.47", "@types/react-dom": "^17.0.17", "nock": "^13.1.0", - "typescript": "^4.2.4" + "typescript": "4.9.5" }, "resolutions": { "@sentry/browser": "file:../../../browser", diff --git a/packages/remix/test/integration/remix.config.js b/packages/remix/test/integration/remix.config.js index 02f847cbf1ca..b4c7ac0837b8 100644 --- a/packages/remix/test/integration/remix.config.js +++ b/packages/remix/test/integration/remix.config.js @@ -1,7 +1,16 @@ /** @type {import('@remix-run/dev').AppConfig} */ +const useV2 = process.env.REMIX_VERSION === '2'; + module.exports = { - appDirectory: 'app', + appDirectory: useV2 ? 'app_v2' : 'app_v1', assetsBuildDirectory: 'public/build', serverBuildPath: 'build/index.js', publicPath: '/build/', + future: { + v2_errorBoundary: useV2, + v2_headers: useV2, + v2_meta: useV2, + v2_normalizeFormMethod: useV2, + v2_routeConvention: useV2, + }, }; diff --git a/packages/remix/test/integration/test/client/errorboundary.test.ts b/packages/remix/test/integration/test/client/errorboundary.test.ts index 6bf6314095fd..b90b3e8d3eaa 100644 --- a/packages/remix/test/integration/test/client/errorboundary.test.ts +++ b/packages/remix/test/integration/test/client/errorboundary.test.ts @@ -2,6 +2,8 @@ import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + test('should capture React component errors.', async ({ page }) => { const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url: '/error-boundary-capture/0', @@ -12,7 +14,9 @@ test('should capture React component errors.', async ({ page }) => { expect(pageloadEnvelope.contexts?.trace.op).toBe('pageload'); expect(pageloadEnvelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(pageloadEnvelope.type).toBe('transaction'); - expect(pageloadEnvelope.transaction).toBe('routes/error-boundary-capture/$id'); + expect(pageloadEnvelope.transaction).toBe( + useV2 ? 'routes/error-boundary-capture.$id' : 'routes/error-boundary-capture/$id', + ); expect(errorEnvelope.level).toBe('error'); expect(errorEnvelope.sdk?.name).toBe('sentry.javascript.remix'); diff --git a/packages/remix/test/integration/test/client/manualtracing.test.ts b/packages/remix/test/integration/test/client/manualtracing.test.ts index edc919d2d4a9..424408e7be9d 100644 --- a/packages/remix/test/integration/test/client/manualtracing.test.ts +++ b/packages/remix/test/integration/test/client/manualtracing.test.ts @@ -2,6 +2,8 @@ import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + test('should report a manually created / finished transaction.', async ({ page }) => { const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url: '/manual-tracing/0', @@ -17,5 +19,5 @@ test('should report a manually created / finished transaction.', async ({ page } expect(pageloadEnvelope.contexts?.trace?.op).toBe('pageload'); expect(pageloadEnvelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(pageloadEnvelope.type).toBe('transaction'); - expect(pageloadEnvelope.transaction).toBe('routes/manual-tracing/$id'); + expect(pageloadEnvelope.transaction).toBe(useV2 ? 'routes/manual-tracing.$id' : 'routes/manual-tracing/$id'); }); diff --git a/packages/remix/test/integration/test/client/pageload.test.ts b/packages/remix/test/integration/test/client/pageload.test.ts index 1543bd2a342c..7c49e4ac9c8c 100644 --- a/packages/remix/test/integration/test/client/pageload.test.ts +++ b/packages/remix/test/integration/test/client/pageload.test.ts @@ -1,3 +1,5 @@ +const useV2 = process.env.REMIX_VERSION === '2'; + import { getFirstSentryEnvelopeRequest } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; @@ -8,5 +10,6 @@ test('should add `pageload` transaction on load.', async ({ page }) => { expect(envelope.contexts?.trace.op).toBe('pageload'); expect(envelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(envelope.type).toBe('transaction'); - expect(envelope.transaction).toBe('routes/index'); + + expect(envelope.transaction).toBe(useV2 ? 'root' : 'routes/index'); }); diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index 6bb4b74d3540..cc25a87611d4 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -1,5 +1,7 @@ import { assertSentryTransaction, assertSentryEvent, RemixTestEnv } from './utils/helpers'; +const useV2 = process.env.REMIX_VERSION === '2'; + jest.spyOn(console, 'error').mockImplementation(); // Repeat tests for each adapter @@ -11,10 +13,10 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, spans: [ { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.action', }, { @@ -22,11 +24,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada op: 'function.remix.loader', }, { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.loader', }, { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.document_request', }, ], @@ -63,6 +65,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada tags: { 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, }); @@ -102,7 +107,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); assertSentryTransaction(transaction[2], { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, request: { method: 'POST', url, @@ -158,10 +163,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '302', }, + data: { + 'http.response.status_code': 302, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -174,10 +182,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'GET', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -224,10 +235,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -274,10 +288,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -324,10 +341,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -374,10 +394,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 2545f63d6e92..8a99c699cc37 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -1,6 +1,8 @@ import { assertSentryTransaction, RemixTestEnv, assertSentryEvent } from './utils/helpers'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + jest.spyOn(console, 'error').mockImplementation(); // Repeat tests for each adapter @@ -21,6 +23,9 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada tags: { 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, }); @@ -52,7 +57,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, transaction_info: { source: 'route', }, @@ -62,11 +67,11 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada op: 'function.remix.loader', }, { - description: 'routes/loader-json-response/$id', + description: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.loader', }, { - description: 'routes/loader-json-response/$id', + description: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.document_request', }, ], @@ -95,10 +100,13 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada method: 'GET', 'http.status_code': '302', }, + data: { + 'http.response.status_code': 302, + }, }, }, tags: { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -111,10 +119,13 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada method: 'GET', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -195,24 +206,39 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'root', + transaction: useV2 ? 'routes/loader-defer-response' : 'root', transaction_info: { source: 'route', }, - spans: [ - { - description: 'root', - op: 'function.remix.loader', - }, - { - description: 'routes/loader-defer-response/index', - op: 'function.remix.loader', - }, - { - description: 'root', - op: 'function.remix.document_request', - }, - ], + spans: useV2 + ? [ + { + description: 'root', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response', + op: 'function.remix.document_request', + }, + ] + : [ + { + description: 'root', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response/index', + op: 'function.remix.loader', + }, + { + description: 'root', + op: 'function.remix.document_request', + }, + ], }); }); }); diff --git a/packages/remix/test/integration/tsconfig.json b/packages/remix/test/integration/tsconfig.json index 2129c1a599f6..f190b5da307f 100644 --- a/packages/remix/test/integration/tsconfig.json +++ b/packages/remix/test/integration/tsconfig.json @@ -13,7 +13,7 @@ "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { - "~/*": ["./app/*"] + "~/*": ["app_v1/*", "app_v2/*"] }, "noEmit": true } diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 3641e152f07e..5a20cafa56f4 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -5,12 +5,17 @@ "main": "build/npm/esm/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "sideEffects": false, "private": true, "scripts": { "build": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.worker.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:dev": "yarn build", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:watch", @@ -38,7 +43,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@types/pako": "^2.0.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "dependencies": { "pako": "^2.1.0" diff --git a/packages/replay-worker/src/handleMessage.ts b/packages/replay-worker/src/handleMessage.ts index 958797e82cbb..2a00f54a581f 100644 --- a/packages/replay-worker/src/handleMessage.ts +++ b/packages/replay-worker/src/handleMessage.ts @@ -54,7 +54,7 @@ export function handleMessage(e: MessageEvent): void { id, method, success: false, - response: err.message, + response: (err as Error).message, }); // eslint-disable-next-line no-console diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index c76c6c2d64d1..82f028e93347 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -4,3 +4,5 @@ demo/build/ # TODO: Check if we can re-introduce linting in demo demo metrics +# For whatever reason, the eslint-ignore comment in this file is not working, so skipping this file +src/types/rrweb.ts diff --git a/packages/replay/package.json b/packages/replay/package.json index f3b1cc69d2b9..2fc49e4e73d6 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -5,13 +5,18 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "sideEffects": false, "scripts": { "build": "run-p build:transpile build:types build:bundle", "build:transpile": "rollup -c rollup.npm.config.js", "build:bundle": "rollup -c rollup.bundle.config.js", "build:dev": "run-p build:transpile build:types", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "yarn build:transpile --watch", @@ -48,7 +53,7 @@ "@sentry-internal/rrweb": "1.108.0", "@sentry-internal/rrweb-snapshot": "1.108.0", "jsdom-worker": "^0.2.1", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "dependencies": { "@sentry/core": "7.56.0", diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 1358701ab8f3..36ec538f1b28 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -21,7 +21,7 @@ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerform name: url, data: { method, - statusCode: response && (response as Response).status, + statusCode: response ? (response as Response).status : undefined, }, }; } diff --git a/packages/replay/src/eventBuffer/EventBufferArray.ts b/packages/replay/src/eventBuffer/EventBufferArray.ts index a7b363891026..a4a823269ece 100644 --- a/packages/replay/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay/src/eventBuffer/EventBufferArray.ts @@ -1,7 +1,7 @@ import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; -import { EventBufferSizeExceededError } from '.'; +import { EventBufferSizeExceededError } from './error'; /** * A basic event buffer that does not do any compression. diff --git a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts index 5b4c0eb4487a..695114ebec77 100644 --- a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts +++ b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts @@ -3,7 +3,7 @@ import type { ReplayRecordingData } from '@sentry/types'; import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; -import { EventBufferSizeExceededError } from '.'; +import { EventBufferSizeExceededError } from './error'; import { WorkerHandler } from './WorkerHandler'; /** diff --git a/packages/replay/src/eventBuffer/error.ts b/packages/replay/src/eventBuffer/error.ts new file mode 100644 index 000000000000..1d60388d42d7 --- /dev/null +++ b/packages/replay/src/eventBuffer/error.ts @@ -0,0 +1,8 @@ +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; + +/** This error indicates that the event buffer size exceeded the limit.. */ +export class EventBufferSizeExceededError extends Error { + public constructor() { + super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); + } +} diff --git a/packages/replay/src/eventBuffer/index.ts b/packages/replay/src/eventBuffer/index.ts index fe58b76f3c7b..f0eb83c68243 100644 --- a/packages/replay/src/eventBuffer/index.ts +++ b/packages/replay/src/eventBuffer/index.ts @@ -1,7 +1,6 @@ import { getWorkerURL } from '@sentry-internal/replay-worker'; import { logger } from '@sentry/utils'; -import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { EventBuffer } from '../types'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferProxy } from './EventBufferProxy'; @@ -31,10 +30,3 @@ export function createEventBuffer({ useCompression }: CreateEventBufferParams): __DEBUG_BUILD__ && logger.log('[Replay] Using simple buffer'); return new EventBufferArray(); } - -/** This error indicates that the event buffer size exceeded the limit.. */ -export class EventBufferSizeExceededError extends Error { - public constructor() { - super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); - } -} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index acb2980e608c..8fab410a0c34 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -547,6 +547,13 @@ export class ReplayContainer implements ReplayContainerInterface { return this.flushImmediate(); } + /** + * Flush using debounce flush + */ + public flush(): Promise { + return this._debouncedFlush() as Promise; + } + /** * Always flush via `_debouncedFlush` so that we do not have flushes triggered * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be diff --git a/packages/replay/src/session/clearSession.ts b/packages/replay/src/session/clearSession.ts index d084764c2fb9..78f50255e363 100644 --- a/packages/replay/src/session/clearSession.ts +++ b/packages/replay/src/session/clearSession.ts @@ -1,5 +1,6 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; /** * Removes the session from Session Storage and unsets session in replay instance @@ -13,9 +14,7 @@ export function clearSession(replay: ReplayContainer): void { * Deletes a session from storage */ function deleteSession(): void { - const hasSessionStorage = 'sessionStorage' in WINDOW; - - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return; } diff --git a/packages/replay/src/session/fetchSession.ts b/packages/replay/src/session/fetchSession.ts index 4b4b1eccf530..3e89a9cbd049 100644 --- a/packages/replay/src/session/fetchSession.ts +++ b/packages/replay/src/session/fetchSession.ts @@ -1,14 +1,13 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import type { Session } from '../types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; import { makeSession } from './Session'; /** * Fetches a session from storage */ export function fetchSession(): Session | null { - const hasSessionStorage = 'sessionStorage' in WINDOW; - - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return null; } diff --git a/packages/replay/src/session/saveSession.ts b/packages/replay/src/session/saveSession.ts index 8f75d0ab50ed..d868fd6ea8a1 100644 --- a/packages/replay/src/session/saveSession.ts +++ b/packages/replay/src/session/saveSession.ts @@ -1,12 +1,12 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import type { Session } from '../types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; /** * Save a session to session storage. */ export function saveSession(session: Session): void { - const hasSessionStorage = 'sessionStorage' in WINDOW; - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return; } diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index f058b2c9011a..e2cfddd0f525 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -194,7 +194,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { _experiments: Partial<{ captureExceptions: boolean; traceInternals: boolean; - delayFlushOnCheckout: number; }>; } @@ -438,6 +437,7 @@ export interface ReplayContainer { stopRecording(): boolean; sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise; conditionalFlush(): Promise; + flush(): Promise; flushImmediate(): Promise; cancelFlush(): void; triggerUserActivity(): void; diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts index 379dbab91605..f3fb594829d3 100644 --- a/packages/replay/src/types/replayFrame.ts +++ b/packages/replay/src/types/replayFrame.ts @@ -91,7 +91,7 @@ interface SlowClickFrameData extends ClickFrameData { route?: string; timeAfterClickMs: number; endReason: string; - clickCount: number; + clickCount?: number; } export interface SlowClickFrame extends BaseBreadcrumbFrame { category: 'ui.slowClickDetected'; diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index 79bf4ff2f362..9533c1690dd8 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -2,7 +2,7 @@ import { EventType } from '@sentry-internal/rrweb'; import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; -import { EventBufferSizeExceededError } from '../eventBuffer'; +import { EventBufferSizeExceededError } from '../eventBuffer/error'; import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types'; import { timestampToMs } from './timestampToMs'; diff --git a/packages/replay/src/util/createPerformanceEntries.ts b/packages/replay/src/util/createPerformanceEntries.ts index 1dbb07829fb3..f7b02aa324be 100644 --- a/packages/replay/src/util/createPerformanceEntries.ts +++ b/packages/replay/src/util/createPerformanceEntries.ts @@ -9,8 +9,6 @@ import type { LargestContentfulPaintData, NavigationData, PaintData, - PerformanceNavigationTiming, - PerformancePaintTiming, ReplayPerformanceEntry, ResourceData, } from '../types'; diff --git a/packages/replay/src/util/dedupePerformanceEntries.ts b/packages/replay/src/util/dedupePerformanceEntries.ts index 17933710c91f..22ac369e90a2 100644 --- a/packages/replay/src/util/dedupePerformanceEntries.ts +++ b/packages/replay/src/util/dedupePerformanceEntries.ts @@ -1,5 +1,3 @@ -import type { PerformanceNavigationTiming, PerformancePaintTiming } from '../types'; - const NAVIGATION_ENTRY_KEYS: Array = [ 'name', 'type', diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index cc7c87afed48..987f589412ed 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -80,41 +80,12 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa } } - const options = replay.getOptions(); - - // TODO: We want this as an experiment so that we can test - // internally and create metrics before making this the default - if (options._experiments.delayFlushOnCheckout) { + if (replay.recordingMode === 'session') { // If the full snapshot is due to an initial load, we will not have // a previous session ID. In this case, we want to buffer events // for a set amount of time before flushing. This can help avoid // capturing replays of users that immediately close the window. - // TODO: We should check `recordingMode` here and do nothing if it's - // buffer, instead of checking inside of timeout, this will make our - // tests a bit cleaner as we will need to wait on the delay in order to - // do nothing. - setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout); - - // Cancel any previously debounced flushes to ensure there are no [near] - // simultaneous flushes happening. The latter request should be - // insignificant in this case, so wait for additional user interaction to - // trigger a new flush. - // - // This can happen because there's no guarantee that a recording event - // happens first. e.g. a mouse click can happen and trigger a debounced - // flush before the checkout. - replay.cancelFlush(); - - return true; - } - - // Flush immediately so that we do not miss the first segment, otherwise - // it can prevent loading on the UI. This will cause an increase in short - // replays (e.g. opening and closing a tab quickly), but these can be - // filtered on the UI. - if (replay.recordingMode === 'session') { - // We want to ensure the worker is ready, as otherwise we'd always send the first event uncompressed - void replay.flushImmediate(); + void replay.flush(); } return true; diff --git a/packages/replay/src/util/hasSessionStorage.ts b/packages/replay/src/util/hasSessionStorage.ts new file mode 100644 index 000000000000..f242df101c25 --- /dev/null +++ b/packages/replay/src/util/hasSessionStorage.ts @@ -0,0 +1,6 @@ +import { WINDOW } from '../constants'; + +/** If sessionStorage is available. */ +export function hasSessionStorage(): boolean { + return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage; +} diff --git a/packages/replay/src/util/sendReplay.ts b/packages/replay/src/util/sendReplay.ts index f10bb223d3d9..5f10011182c6 100644 --- a/packages/replay/src/util/sendReplay.ts +++ b/packages/replay/src/util/sendReplay.ts @@ -57,7 +57,7 @@ export async function sendReplay( // will retry in intervals of 5, 10, 30 retryConfig.interval *= ++retryConfig.count; - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { setTimeout(async () => { try { await sendReplay(replayData, retryConfig); diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 65f217f857cd..b6f49c0b9c9a 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -34,7 +34,7 @@ export async function sendReplayRequest({ const transport = client && client.getTransport(); const dsn = client && client.getDsn(); - if (!client || !scope || !transport || !dsn || !session.sampled) { + if (!client || !transport || !dsn || !session.sampled) { return; } diff --git a/packages/replay/test/fixtures/performanceEntry/lcp.ts b/packages/replay/test/fixtures/performanceEntry/lcp.ts index 4db2eb53f565..891e133a981f 100644 --- a/packages/replay/test/fixtures/performanceEntry/lcp.ts +++ b/packages/replay/test/fixtures/performanceEntry/lcp.ts @@ -1,5 +1,3 @@ -import type { PerformancePaintTiming } from '../../../src/types'; - export function PerformanceEntryLcp(obj?: Partial): PerformancePaintTiming { const entry = { name: '', diff --git a/packages/replay/test/fixtures/performanceEntry/navigation.ts b/packages/replay/test/fixtures/performanceEntry/navigation.ts index d76ebce86538..476ae1d29098 100644 --- a/packages/replay/test/fixtures/performanceEntry/navigation.ts +++ b/packages/replay/test/fixtures/performanceEntry/navigation.ts @@ -1,5 +1,3 @@ -import type { PerformanceNavigationTiming } from '../../../src/types'; - export function PerformanceEntryNavigation(obj?: Partial): PerformanceNavigationTiming { const entry = { activationStart: 0, @@ -33,10 +31,10 @@ export function PerformanceEntryNavigation(obj?: Partial): any { method: 'GET', url: '/api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/', type: 'fetch', + 'http.response.status_code': 200, }, description: 'GET /api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/', op: 'http.client', diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts index 1e59a4f7eef0..cd367a2d04f5 100644 --- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -148,9 +148,13 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { jest.runAllTimers(); await new Promise(process.nextTick); - // Send twice, one for the error & one right after for the session conversion + expect(mockSend).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + await new Promise(process.nextTick); expect(mockSend).toHaveBeenCalledTimes(2); + // This is removed now, because it has been converted to a "session" session expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); diff --git a/packages/replay/test/integration/coreHandlers/handleScope.test.ts b/packages/replay/test/integration/coreHandlers/handleScope.test.ts index d9d30d710a6a..2ccaafdefff7 100644 --- a/packages/replay/test/integration/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleScope.test.ts @@ -23,7 +23,7 @@ describe('Integration | coreHandlers | handleScope', () => { expect(mockHandleScopeListener).toHaveBeenCalledTimes(1); - getCurrentHub().getScope()?.addBreadcrumb({ category: 'console', message: 'testing' }); + getCurrentHub().getScope().addBreadcrumb({ category: 'console', message: 'testing' }); expect(mockHandleScope).toHaveBeenCalledTimes(1); expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ category: 'console', message: 'testing' })); @@ -32,7 +32,7 @@ describe('Integration | coreHandlers | handleScope', () => { // This will trigger breadcrumb/scope listener, but handleScope should return // null because breadcrumbs has not changed - getCurrentHub().getScope()?.setUser({ email: 'foo@foo.com' }); + getCurrentHub().getScope().setUser({ email: 'foo@foo.com' }); expect(mockHandleScope).toHaveBeenCalledTimes(1); expect(mockHandleScope).toHaveReturnedWith(null); }); diff --git a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts deleted file mode 100644 index f691d8e953c1..000000000000 --- a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import { captureException, getCurrentHub } from '@sentry/core'; - -import { - BUFFER_CHECKOUT_TIME, - DEFAULT_FLUSH_MIN_DELAY, - MAX_SESSION_LIFE, - REPLAY_SESSION_KEY, - SESSION_IDLE_EXPIRE_DURATION, - WINDOW, -} from '../../src/constants'; -import type { ReplayContainer } from '../../src/replay'; -import { clearSession } from '../../src/session/clearSession'; -import { addEvent } from '../../src/util/addEvent'; -import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; -import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; -import type { RecordMock } from '../index'; -import { BASE_TIMESTAMP } from '../index'; -import { resetSdkMock } from '../mocks/resetSdkMock'; -import type { DomHandler } from '../types'; -import { useFakeTimers } from '../utils/use-fake-timers'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -async function waitForBufferFlush() { - await new Promise(process.nextTick); - await new Promise(process.nextTick); -} - -async function waitForFlush() { - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); -} - -describe('Integration | errorSampleRate with delayed flush', () => { - let replay: ReplayContainer; - let mockRecord: RecordMock; - let domHandler: DomHandler; - - beforeEach(async () => { - ({ mockRecord, domHandler, replay } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysSessionSampleRate: 0.0, - replaysOnErrorSampleRate: 1.0, - }, - })); - }); - - afterEach(async () => { - clearSession(replay); - replay.stop(); - }); - - it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - await waitForFlush(); - - // This is from when we stop recording and start a session recording - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - // Check that click will get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 80, - data: { - tag: 'breadcrumb', - payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - it('manually flushes replay and does not continue to record', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - replay.sendBufferedReplayOrFlush({ continueRecording: false }); - - await waitForBufferFlush(); - - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // Check that click will not get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - // This is still the last replay sent since we passed `continueRecording: - // false`. - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - // This tests a regression where we were calling flush indiscriminantly in `stop()` - it('does not upload a replay event if error is not sampled', async () => { - // We are trying to replicate the case where error rate is 0 and session - // rate is > 0, we can't set them both to 0 otherwise - // `_loadAndCheckSession` is not called when initializing the plugin. - replay.stop(); - replay['_options']['errorSampleRate'] = 0; - replay['_loadAndCheckSession'](); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_EXPIRE_DURATION]ms', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay if user hides the tab and comes back within 60 seconds', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 100); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event when document becomes hidden', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - addEvent(replay, TEST_EVENT); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 15 seconds have elapsed since the last replay upload', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // Fire a new event every 4 seconds, 4 times - [...Array(4)].forEach(() => { - mockRecord._emitter(TEST_EVENT); - jest.advanceTimersByTime(4000); - }); - - // We are at time = +16seconds now (relative to BASE_TIMESTAMP) - // The next event should cause an upload immediately - mockRecord._emitter(TEST_EVENT); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // There should also not be another attempt at an upload 5 seconds after the last replay event - await waitForFlush(); - expect(replay).not.toHaveLastSentReplay(); - - // Let's make sure it continues to work - mockRecord._emitter(TEST_EVENT); - await waitForFlush(); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - // When the error session records as a normal session, we want to stop - // recording after the session ends. Otherwise, we get into a state where the - // new session is a session type replay (this could conflict with the session - // sample rate of 0.0), or an error session that has no errors. Instead we - // simply stop the session replay completely and wait for a new page load to - // resample. - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])( - 'stops replay if session had an error and exceeds %s and does not start a new session thereafter', - async (_label, waitTime) => { - expect(replay.session?.shouldRefresh).toBe(true); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - await waitForFlush(); - - // segment_id is 1 because it sends twice on error - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - expect(replay.session?.shouldRefresh).toBe(false); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after 15 minutes of inactivity in error mode - - // still no new replay sent - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(false); - - domHandler({ - name: 'click', - }); - - // Remains disabled! - expect(replay.isEnabled()).toBe(false); - }, - ); - - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => { - expect(replay).not.toHaveLastSentReplay(); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // still no new replay sent - expect(replay).not.toHaveLastSentReplay(); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - // Should behave the same as above test - it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => { - // Idle for 15 minutes - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - it('has the correct timestamps with deferred root event and last replay update', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveSentReplay({ - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - replayEventPayload: expect.objectContaining({ - replay_start_timestamp: BASE_TIMESTAMP / 1000, - // the exception happens roughly 10 seconds after BASE_TIMESTAMP - // (advance timers + waiting for flush after the checkout) and - // extra time is likely due to async of `addMemoryEntry()` - - timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + DEFAULT_FLUSH_MIN_DELAY + 40) / 1000, - error_ids: [expect.any(String)], - trace_ids: [], - urls: ['http://localhost/'], - replay_id: expect.any(String), - }), - recordingPayloadHeader: { segment_id: 0 }, - }); - }); - - it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { - const ELAPSED = BUFFER_CHECKOUT_TIME; - const TICK = 20; - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - // add a mock performance event - replay.performanceEvents.push(PerformanceEntryResource()); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.advanceTimersByTime(ELAPSED); - - // in production, this happens at a time interval - // session started time should be updated to this current timestamp - mockRecord.takeFullSnapshot(true); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - // See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a - // noop when it can be skipped altogether - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK); - - // Does not capture mouse click - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, - }), - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + TICK, - type: 2, - }, - optionsEvent, - ]), - }); - }); - - it('stops replay when user goes idle', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - // Go idle - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); - - it('stops replay when session exceeds max length after latest captured error', async () => { - const sessionId = replay.session?.id; - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(2 * MAX_SESSION_LIFE); - - captureException(new Error('testing')); - - // Flush due to exception - await new Promise(process.nextTick); - await waitForFlush(); - - expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - }); - - // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` - await waitForFlush(); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { - data: { - isCheckout: true, - }, - timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - jest.advanceTimersByTime(MAX_SESSION_LIFE); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - - // Once the session is stopped after capturing a replay already - // (buffer-mode), another error will not trigger a new replay - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not stop replay based on earliest event in buffer', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - const TICKS = 80; - - // We advance time so that we are on the border of expiring, taking into - // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The - // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has - // happened, and for the next two that will happen. The first following - // `waitForFlush` does not expire session, but the following one will. - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - // It's hard to test, but if we advance the below time less 1 ms, it should - // be enabled, but we can't trigger a session check via flush without - // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. - jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); -}); - -/** - * This is testing a case that should only happen with error-only sessions. - * Previously we had assumed that loading a session from session storage meant - * that the session was not new. However, this is not the case with error-only - * sampling since we can load a saved session that did not have an error (and - * thus no replay was created). - */ -it('sends a replay after loading the session from storage', async () => { - // Pretend that a session is already saved before loading replay - WINDOW.sessionStorage.setItem( - REPLAY_SESSION_KEY, - `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"buffer","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`, - ); - const { mockRecord, replay, integration } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysOnErrorSampleRate: 1.0, - }, - autoStart: false, - }); - integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - - await new Promise(process.nextTick); - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - // 2 ticks to send replay from an error - await waitForBufferFlush(); - - // Buffered events before error - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - }); - - // `startRecording()` after switching to session mode to continue recording - await waitForFlush(); - - // Latest checkout when we call `startRecording` again after uploading segment - // after an error occurs (e.g. when we switch to session replay recording) - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), - }); -}); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index ea1825dd8429..fe3049f9704f 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -26,6 +26,15 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } +async function waitForBufferFlush() { + await new Promise(process.nextTick); + await new Promise(process.nextTick); +} + +async function waitForFlush() { + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); +} + describe('Integration | errorSampleRate', () => { let replay: ReplayContainer; let mockRecord: RecordMock; @@ -66,11 +75,9 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -96,48 +103,35 @@ describe('Integration | errorSampleRate', () => { ]), }); + await waitForFlush(); + // This is from when we stop recording and start a session recording expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // New checkout when we call `startRecording` again after uploading segment - // after an error occurs - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - // Check that click will get captured domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([ { type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 60, + timestamp: BASE_TIMESTAMP + 10000 + 80, data: { tag: 'breadcrumb', payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 60) / 1000, + timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, type: 'default', category: 'ui.click', message: '', @@ -167,9 +161,7 @@ describe('Integration | errorSampleRate', () => { replay.sendBufferedReplayOrFlush({ continueRecording: false }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, @@ -202,8 +194,8 @@ describe('Integration | errorSampleRate', () => { domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + + await waitForFlush(); // This is still the last replay sent since we passed `continueRecording: // false`. @@ -353,12 +345,12 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); // There should also not be another attempt at an upload 5 seconds after the last replay event - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); // Let's make sure it continues to work mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); @@ -380,9 +372,16 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); + + await waitForFlush(); // segment_id is 1 because it sends twice on error expect(replay).toHaveLastSentReplay({ @@ -462,9 +461,7 @@ describe('Integration | errorSampleRate', () => { name: 'click', }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(replay.isEnabled()).toBe(true); @@ -474,23 +471,12 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay.session?.id).toBe(oldSessionId); - // Flush of buffered events - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - // Checkout from `startRecording` expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -546,7 +532,7 @@ describe('Integration | errorSampleRate', () => { // `startRecording` full checkout expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -602,6 +588,7 @@ describe('Integration | errorSampleRate', () => { it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { const ELAPSED = BUFFER_CHECKOUT_TIME; + const TICK = 20; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -624,25 +611,25 @@ describe('Integration | errorSampleRate', () => { jest.runAllTimers(); await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.runAllTimers(); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 40); + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + TICK + TICK); // Does not capture mouse click expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000, + replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + 20, + timestamp: BASE_TIMESTAMP + ELAPSED + TICK, type: 2, }, optionsEvent, @@ -664,12 +651,13 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveLastSentReplay(); + // Flush from calling `stopRecording` + await waitForFlush(); + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -684,8 +672,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); @@ -711,12 +698,29 @@ describe('Integration | errorSampleRate', () => { // Flush due to exception await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); + expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + }); + + // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` + await waitForFlush(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + recordingData: JSON.stringify([ + { + data: { + isCheckout: true, + }, + timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, + type: 2, + }, + ]), + }); - // Now wait after session expires - should re-start into buffering mode + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -732,7 +736,7 @@ describe('Integration | errorSampleRate', () => { expect(replay.isEnabled()).toBe(false); // Once the session is stopped after capturing a replay already - // (buffer-mode), another error should trigger a new replay + // (buffer-mode), another error will not trigger a new replay captureException(new Error('testing')); await new Promise(process.nextTick); @@ -740,6 +744,73 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); }); + + it('does not stop replay based on earliest event in buffer', async () => { + jest.setSystemTime(BASE_TIMESTAMP); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; + mockRecord._emitter(TEST_EVENT); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); + + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay(); + + // Flush from calling `stopRecording` + await waitForFlush(); + + // Now wait after session expires - should stop recording + mockRecord.takeFullSnapshot.mockClear(); + (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + + expect(replay).not.toHaveLastSentReplay(); + + const TICKS = 80; + + // We advance time so that we are on the border of expiring, taking into + // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The + // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has + // happened, and for the next two that will happen. The first following + // `waitForFlush` does not expire session, but the following one will. + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); + await new Promise(process.nextTick); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + // It's hard to test, but if we advance the below time less 1 ms, it should + // be enabled, but we can't trigger a session check via flush without + // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. + jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(false); + }); }); /** @@ -749,7 +820,7 @@ describe('Integration | errorSampleRate', () => { * sampling since we can load a saved session that did not have an error (and * thus no replay was created). */ -it('sends a replay after loading the session multiple times', async () => { +it('sends a replay after loading the session from storage', async () => { // Pretend that a session is already saved before loading replay WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, @@ -765,7 +836,6 @@ it('sends a replay after loading the session multiple times', async () => { autoStart: false, }); integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); @@ -778,10 +848,10 @@ it('sends a replay after loading the session multiple times', async () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + // 2 ticks to send replay from an error + await waitForBufferFlush(); + // Buffered events before error expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ @@ -791,10 +861,13 @@ it('sends a replay after loading the session multiple times', async () => { ]), }); + // `startRecording()` after switching to session mode to continue recording + await waitForFlush(); + // Latest checkout when we call `startRecording` again after uploading segment // after an error occurs (e.g. when we switch to session replay recording) expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5040, type: 2 }]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); }); diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index 18c7a86ca188..cf142ae8c45c 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -135,8 +135,8 @@ describe('Integration | flush', () => { it('long first flush enqueues following events', async () => { // Mock this to resolve after 20 seconds so that we can queue up following flushes - mockAddPerformanceEntries.mockImplementationOnce(async () => { - return await new Promise(resolve => setTimeout(resolve, 20000)); + mockAddPerformanceEntries.mockImplementationOnce(() => { + return new Promise(resolve => setTimeout(resolve, 20000)); }); expect(mockAddPerformanceEntries).not.toHaveBeenCalled(); diff --git a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts index c7b0a4bd7e90..494d03e9572f 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts @@ -1,6 +1,7 @@ import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; -import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; -import { BASE_TIMESTAMP } from './../../index'; +import { createEventBuffer } from '../../../src/eventBuffer'; +import { EventBufferSizeExceededError } from '../../../src/eventBuffer/error'; +import { BASE_TIMESTAMP } from '../../index'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; diff --git a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts index 6c3e5948fac1..cab6855e411d 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts @@ -4,8 +4,9 @@ import pako from 'pako'; import { BASE_TIMESTAMP } from '../..'; import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; +import { createEventBuffer } from '../../../src/eventBuffer'; +import { EventBufferSizeExceededError } from '../../../src/eventBuffer/error'; import { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy'; -import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; diff --git a/packages/serverless/package.json b/packages/serverless/package.json index 167e3ac936f7..d1b074547bab 100644 --- a/packages/serverless/package.json +++ b/packages/serverless/package.json @@ -12,6 +12,9 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -22,7 +25,7 @@ "@sentry/utils": "7.56.0", "@types/aws-lambda": "^8.10.62", "@types/express": "^4.17.14", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@google-cloud/bigquery": "^5.3.0", @@ -41,7 +44,9 @@ "build:bundle": "yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 284aef1331af..f6ae3a2b9184 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -15,6 +15,7 @@ export { Scope, addBreadcrumb, addGlobalEventProcessor, + autoDiscoverNodePerformanceMonitoringIntegrations, captureEvent, captureException, captureMessage, diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 1aa37e2101bf..38e844c71226 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -20,7 +23,7 @@ "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", "magic-string": "^0.30.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { "svelte": "3.x || 4.x" @@ -34,7 +37,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/svelte/src/performance.ts b/packages/svelte/src/performance.ts index 5cb9e0254557..8bcafb8d9ed8 100644 --- a/packages/svelte/src/performance.ts +++ b/packages/svelte/src/performance.ts @@ -90,7 +90,5 @@ function recordUpdateSpans(componentName: string, initSpan?: Span): void { } function getActiveTransaction(): Transaction | undefined { - const currentHub = getCurrentHub(); - const scope = currentHub && currentHub.getScope(); - return scope && scope.getTransaction(); + return getCurrentHub().getScope().getTransaction(); } diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index ef3a0ccbadc5..68c687a4c4d9 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -35,7 +35,6 @@ "@sveltejs/kit": "^1.11.0", "rollup": "^3.20.2", "svelte": "^3.44.0", - "typescript": "^4.9.3", "vite": "4.0.5" }, "scripts": { diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index 1996523346e2..e56d33b2e23c 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -3,7 +3,7 @@ import type { BaseClient } from '@sentry/core'; import { getCurrentHub, trace } from '@sentry/core'; import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte'; import { captureException } from '@sentry/svelte'; -import type { ClientOptions, SanitizedRequestData } from '@sentry/types'; +import type { Client, ClientOptions, SanitizedRequestData } from '@sentry/types'; import { addExceptionMechanism, addNonEnumerableProperty, @@ -17,6 +17,7 @@ import type { LoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; import { isRedirect } from '../common/utils'; +import { isRequestCached } from './vendor/lookUpCache'; type PatchedLoadEvent = LoadEvent & Partial; @@ -122,12 +123,14 @@ type SvelteKitFetch = LoadEvent['fetch']; * @returns a proxy of SvelteKit's fetch implementation */ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch { - const client = getCurrentHub().getClient() as BaseClient; + const client = getCurrentHub().getClient(); - const browserTracingIntegration = - client.getIntegrationById && (client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined); - const breadcrumbsIntegration = - client.getIntegrationById && (client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined); + if (!isValidClient(client)) { + return originalFetch; + } + + const browserTracingIntegration = client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined; + const breadcrumbsIntegration = client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined; const browserTracingOptions = browserTracingIntegration && browserTracingIntegration.options; @@ -151,6 +154,11 @@ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch return new Proxy(originalFetch, { apply: (wrappingTarget, thisArg, args: Parameters) => { const [input, init] = args; + + if (isRequestCached(input, init)) { + return wrappingTarget.apply(thisArg, args); + } + const { url: rawUrl, method } = parseFetchArgs(args); // TODO: extract this to a util function (and use it in breadcrumbs integration as well) @@ -194,6 +202,7 @@ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch patchedInit.headers = headers; } + let fetchPromise: Promise; const patchedFetchArgs = [input, patchedInit]; @@ -270,3 +279,14 @@ function addFetchBreadcrumb( }, ); } + +type MaybeClientWithGetIntegrationsById = + | (Client & { getIntegrationById?: BaseClient['getIntegrationById'] }) + | undefined; + +type ClientWithGetIntegrationById = Required & + Exclude; + +function isValidClient(client: MaybeClientWithGetIntegrationsById): client is ClientWithGetIntegrationById { + return !!client && typeof client.getIntegrationById === 'function'; +} diff --git a/packages/sveltekit/src/client/vendor/buildSelector.ts b/packages/sveltekit/src/client/vendor/buildSelector.ts new file mode 100644 index 000000000000..9ff0ddebe7c7 --- /dev/null +++ b/packages/sveltekit/src/client/vendor/buildSelector.ts @@ -0,0 +1,57 @@ +/* eslint-disable @sentry-internal/sdk/no-optional-chaining */ + +// Vendored from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js +// with types only changes. + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { hash } from './hash'; + +/** + * Build the cache key for a given request + * @param {URL | RequestInfo} resource + * @param {RequestInit} [opts] + */ +export function build_selector(resource: URL | RequestInfo, opts: RequestInit | undefined): string { + const url = JSON.stringify(resource instanceof Request ? resource.url : resource); + + let selector = `script[data-sveltekit-fetched][data-url=${url}]`; + + if (opts?.headers || opts?.body) { + /** @type {import('types').StrictBody[]} */ + const values = []; + + if (opts.headers) { + // @ts-ignore - TS complains but this is a 1:1 copy of the original code and apparently it works + values.push([...new Headers(opts.headers)].join(',')); + } + + if (opts.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) { + values.push(opts.body); + } + + selector += `[data-hash="${hash(...values)}"]`; + } + + return selector; +} diff --git a/packages/sveltekit/src/client/vendor/hash.ts b/packages/sveltekit/src/client/vendor/hash.ts new file mode 100644 index 000000000000..1723dac703a6 --- /dev/null +++ b/packages/sveltekit/src/client/vendor/hash.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-bitwise */ + +// Vendored from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/hash.js +// with types only changes. + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import type { StrictBody } from '@sveltejs/kit/types/internal'; + +/** + * Hash using djb2 + * @param {import('types').StrictBody[]} values + */ +export function hash(...values: StrictBody[]): string { + let hash = 5381; + + for (const value of values) { + if (typeof value === 'string') { + let i = value.length; + while (i) hash = (hash * 33) ^ value.charCodeAt(--i); + } else if (ArrayBuffer.isView(value)) { + const buffer = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + let i = buffer.length; + while (i) hash = (hash * 33) ^ buffer[--i]; + } else { + throw new TypeError('value must be a string or TypedArray'); + } + } + + return (hash >>> 0).toString(36); +} diff --git a/packages/sveltekit/src/client/vendor/lookUpCache.ts b/packages/sveltekit/src/client/vendor/lookUpCache.ts new file mode 100644 index 000000000000..afcaf676b40d --- /dev/null +++ b/packages/sveltekit/src/client/vendor/lookUpCache.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-bitwise */ + +// Parts of this code are taken from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js +// Attribution given directly in the function code below + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { WINDOW } from '@sentry/svelte'; +import { getDomElement } from '@sentry/utils'; + +import { build_selector } from './buildSelector'; + +/** + * Checks if a request is cached by looking for a script tag with the same selector as the constructed selector of the request. + * + * This function is a combination of the cache lookups in sveltekit's internal client-side fetch functions + * - initial_fetch (used during hydration) https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L76 + * - subsequent_fetch (used afterwards) https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L98 + * + * Parts of this function's logic is taken from SvelteKit source code. + * These lines are annotated with attribution in comments above them. + * + * @param input first fetch param + * @param init second fetch param + * @returns true if a cache hit was encountered, false otherwise + */ +export function isRequestCached(input: URL | RequestInfo, init: RequestInit | undefined): boolean { + // build_selector call copied from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L77 + const selector = build_selector(input, init); + + const script = getDomElement(selector); + + if (!script) { + return false; + } + + // If the script has a data-ttl attribute, we check if we're still in the TTL window: + try { + // ttl retrieval taken from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L83-L84 + const ttl = Number(script.getAttribute('data-ttl')) * 1000; + + if (isNaN(ttl)) { + return false; + } + + if (ttl) { + // cache hit determination taken from: https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L105-L106 + return ( + WINDOW.performance.now() < ttl && + ['default', 'force-cache', 'only-if-cached', undefined].includes(init && init.cache) + ); + } + } catch { + return false; + } + + // Otherwise, we check if the script has a content and return true in that case + return !!script.textContent; +} diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 07c701e912f1..4e69ad8ef3b0 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -52,7 +52,7 @@ export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'ser */ export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise { if (adapter === 'node') { - return await getNodeAdapterOutputDir(svelteConfig); + return getNodeAdapterOutputDir(svelteConfig); } // Auto and Vercel adapters simply use config.kit.outDir diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index b07e0c12108f..07608bb9845a 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -27,6 +27,12 @@ vi.mock('@sentry/svelte', async () => { }; }); +vi.mock('../../src/client/vendor/lookUpCache', () => { + return { + isRequestCached: () => false, + }; +}); + const mockTrace = vi.fn(); const mockedBrowserTracing = { @@ -52,6 +58,12 @@ const mockedGetIntegrationById = vi.fn(id => { return undefined; }); +const mockedGetClient = vi.fn(() => { + return { + getIntegrationById: mockedGetIntegrationById, + }; +}); + vi.mock('@sentry/core', async () => { const original = (await vi.importActual('@sentry/core')) as any; return { @@ -62,11 +74,7 @@ vi.mock('@sentry/core', async () => { }, getCurrentHub: () => { return { - getClient: () => { - return { - getIntegrationById: mockedGetIntegrationById, - }; - }, + getClient: mockedGetClient, getScope: () => { return { getSpan: () => { @@ -427,6 +435,27 @@ describe('wrapLoadWithSentry', () => { }); }); + it.each([ + ['is undefined', undefined], + ["doesn't have a `getClientById` method", {}], + ])("doesn't instrument fetch if the client %s", async (_, client) => { + mockedGetClient.mockImplementationOnce(() => client); + + async function load(_event: Parameters[0]): Promise> { + return { + msg: 'hi', + }; + } + const wrappedLoad = wrapLoadWithSentry(load); + + const originalFetch = MOCK_LOAD_ARGS.fetch; + await wrappedLoad(MOCK_LOAD_ARGS); + + expect(MOCK_LOAD_ARGS.fetch).toStrictEqual(originalFetch); + + expect(mockTrace).toHaveBeenCalledTimes(1); + }); + it('adds an exception mechanism', async () => { const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { void callback({}, { event_id: 'fake-event-id' }); diff --git a/packages/sveltekit/test/client/vendor/lookUpCache.test.ts b/packages/sveltekit/test/client/vendor/lookUpCache.test.ts new file mode 100644 index 000000000000..29b13494be12 --- /dev/null +++ b/packages/sveltekit/test/client/vendor/lookUpCache.test.ts @@ -0,0 +1,45 @@ +import { JSDOM } from 'jsdom'; +import { vi } from 'vitest'; + +import { isRequestCached } from '../../../src/client/vendor/lookUpCache'; + +globalThis.document = new JSDOM().window.document; + +vi.useFakeTimers().setSystemTime(new Date('2023-06-22')); +vi.spyOn(performance, 'now').mockReturnValue(1000); + +describe('isRequestCached', () => { + it('should return true if a script tag with the same selector as the constructed request selector is found', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(true); + }); + + it('should return false if a script with the same selector as the constructed request selector is not found', () => { + globalThis.document.body.innerHTML = ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); + + it('should return true if a script with the same selector as the constructed request selector is found and its TTL is valid', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(true); + }); + + it('should return false if a script with the same selector as the constructed request selector is found and its TTL is expired', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); + + it("should return false if the TTL is set but can't be parsed as a number", () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); +}); diff --git a/packages/sveltekit/test/vitest.setup.ts b/packages/sveltekit/test/vitest.setup.ts index 48c9b0e33528..af2810a98a96 100644 --- a/packages/sveltekit/test/vitest.setup.ts +++ b/packages/sveltekit/test/vitest.setup.ts @@ -11,3 +11,8 @@ export function setup() { }; }); } + +if (!globalThis.fetch) { + // @ts-ignore - Needed for vitest to work with SvelteKit fetch instrumentation + globalThis.Request = class Request {}; +} diff --git a/packages/tracing-internal/package.json b/packages/tracing-internal/package.json index 9c77cb6c6334..1ab646273551 100644 --- a/packages/tracing-internal/package.json +++ b/packages/tracing-internal/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,7 +22,7 @@ "@sentry/core": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/express": "^4.17.14" @@ -28,7 +31,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts index deb240ec4233..4d1b1bca7c97 100644 --- a/packages/tracing-internal/src/browser/browsertracing.ts +++ b/packages/tracing-internal/src/browser/browsertracing.ts @@ -111,6 +111,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { _experiments: Partial<{ enableLongTask: boolean; enableInteractions: boolean; + enableHTTPTimings: boolean; onStartRouteTransaction: (t: Transaction | undefined, ctx: TransactionContext, getCurrentHub: () => Hub) => void; }>; @@ -145,7 +146,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { startTransactionOnLocationChange: true, startTransactionOnPageLoad: true, enableLongTask: true, - _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -177,9 +177,19 @@ export class BrowserTracing implements Integration { private _collectWebVitals: () => void; + private _hasSetTracePropagationTargets: boolean = false; + public constructor(_options?: Partial) { addTracingExtensions(); + if (__DEBUG_BUILD__) { + this._hasSetTracePropagationTargets = !!( + _options && + // eslint-disable-next-line deprecation/deprecation + (_options.tracePropagationTargets || _options.tracingOrigins) + ); + } + this.options = { ...DEFAULT_BROWSER_TRACING_OPTIONS, ..._options, @@ -214,6 +224,9 @@ export class BrowserTracing implements Integration { */ public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { this._getCurrentHub = getCurrentHub; + const hub = getCurrentHub(); + const client = hub.getClient(); + const clientOptions = client && client.getOptions(); const { routingInstrumentation: instrumentRouting, @@ -222,11 +235,28 @@ export class BrowserTracing implements Integration { markBackgroundTransactions, traceFetch, traceXHR, - tracePropagationTargets, shouldCreateSpanForRequest, _experiments, } = this.options; + const clientOptionsTracePropagationTargets = clientOptions && clientOptions.tracePropagationTargets; + // There are three ways to configure tracePropagationTargets: + // 1. via top level client option `tracePropagationTargets` + // 2. via BrowserTracing option `tracePropagationTargets` + // 3. via BrowserTracing option `tracingOrigins` (deprecated) + // + // To avoid confusion, favour top level client option `tracePropagationTargets`, and fallback to + // BrowserTracing option `tracePropagationTargets` and then `tracingOrigins` (deprecated). + // This is done as it minimizes bundle size (we don't have to have undefined checks). + // + // If both 1 and either one of 2 or 3 are set (from above), we log out a warning. + const tracePropagationTargets = clientOptionsTracePropagationTargets || this.options.tracePropagationTargets; + if (__DEBUG_BUILD__ && this._hasSetTracePropagationTargets && clientOptionsTracePropagationTargets) { + logger.warn( + '[Tracing] The `tracePropagationTargets` option was set in the BrowserTracing integration and top level `Sentry.init`. The top level `Sentry.init` value is being used.', + ); + } + instrumentRouting( (context: TransactionContext) => { const transaction = this._createRouteTransaction(context); @@ -253,6 +283,9 @@ export class BrowserTracing implements Integration { traceXHR, tracePropagationTargets, shouldCreateSpanForRequest, + _experiments: { + enableHTTPTimings: _experiments.enableHTTPTimings, + }, }); } diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index e7d3b44c75bb..f8cf44894d74 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -22,6 +22,7 @@ function msToSec(time: number): number { } function getBrowserPerformanceAPI(): Performance | undefined { + // @ts-ignore we want to make sure all of these are available, even if TS is sure they are return WINDOW && WINDOW.addEventListener && WINDOW.performance; } @@ -39,6 +40,7 @@ let _clsEntry: LayoutShift | undefined; export function startTrackingWebVitals(): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin) { + // @ts-ignore we want to make sure all of these are available, even if TS is sure they are if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index 284d8f339435..d7e397ae01ac 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -4,6 +4,7 @@ import type { DynamicSamplingContext, Span } from '@sentry/types'; import { addInstrumentationHandler, BAGGAGE_HEADER_NAME, + browserPerformanceTimeOrigin, dynamicSamplingContextToSentryBaggageHeader, isInstanceOf, SENTRY_XHR_DATA_KEY, @@ -14,6 +15,13 @@ export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { + /** + * Allow experiments for the request instrumentation. + */ + _experiments: Partial<{ + enableHTTPTimings: boolean; + }>; + /** * @deprecated Will be removed in v8. * Use `shouldCreateSpanForRequest` to control span creation and `tracePropagationTargets` to control @@ -108,12 +116,13 @@ export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions // TODO (v8): Remove this property tracingOrigins: DEFAULT_TRACE_PROPAGATION_TARGETS, tracePropagationTargets: DEFAULT_TRACE_PROPAGATION_TARGETS, + _experiments: {}, }; /** Registers span creators for xhr and fetch requests */ export function instrumentOutgoingRequests(_options?: Partial): void { // eslint-disable-next-line deprecation/deprecation - const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest } = { + const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest, _experiments } = { traceFetch: defaultRequestInstrumentationOptions.traceFetch, traceXHR: defaultRequestInstrumentationOptions.traceXHR, ..._options, @@ -132,15 +141,63 @@ export function instrumentOutgoingRequests(_options?: Partial { - fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + if (_experiments?.enableHTTPTimings && createdSpan) { + addHTTPTimings(createdSpan); + } }); } if (traceXHR) { addInstrumentationHandler('xhr', (handlerData: XHRData) => { - xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + if (_experiments?.enableHTTPTimings && createdSpan) { + addHTTPTimings(createdSpan); + } + }); + } +} + +/** + * Creates a temporary observer to listen to the next fetch/xhr resourcing timings, + * so that when timings hit their per-browser limit they don't need to be removed. + * + * @param span A span that has yet to be finished, must contain `url` on data. + */ +function addHTTPTimings(span: Span): void { + const url = span.data.url; + const observer = new PerformanceObserver(list => { + const entries = list.getEntries() as PerformanceResourceTiming[]; + entries.forEach(entry => { + if ((entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') && entry.name.endsWith(url)) { + const spanData = resourceTimingEntryToSpanData(entry); + spanData.forEach(data => span.setData(...data)); + observer.disconnect(); + } }); + }); + observer.observe({ + entryTypes: ['resource'], + }); +} + +function resourceTimingEntryToSpanData(resourceTiming: PerformanceResourceTiming): [string, string | number][] { + const version = resourceTiming.nextHopProtocol.split('/')[1] || 'none'; + + const timingSpanData: [string, string | number][] = []; + if (version) { + timingSpanData.push(['network.protocol.version', version]); + } + + if (!browserPerformanceTimeOrigin) { + return timingSpanData; } + return [ + ...timingSpanData, + ['http.request.connect_start', (browserPerformanceTimeOrigin + resourceTiming.connectStart) / 1000], + ['http.request.request_start', (browserPerformanceTimeOrigin + resourceTiming.requestStart) / 1000], + ['http.request.response_start', (browserPerformanceTimeOrigin + resourceTiming.responseStart) / 1000], + ]; } /** @@ -154,13 +211,15 @@ export function shouldAttachHeaders(url: string, tracePropagationTargets: (strin /** * Create and track fetch request spans + * + * @returns Span if a span was created, otherwise void. */ -export function fetchCallback( +function fetchCallback( handlerData: FetchData, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, -): void { +): Span | void { if (!hasTracingEnabled() || !(handlerData.fetchData && shouldCreateSpan(handlerData.fetchData.url))) { return; } @@ -195,8 +254,7 @@ export function fetchCallback( return; } - const currentScope = getCurrentHub().getScope(); - const currentSpan = currentScope && currentScope.getSpan(); + const currentSpan = getCurrentHub().getScope().getSpan(); const activeTransaction = currentSpan && currentSpan.transaction; if (currentSpan && activeTransaction) { @@ -230,6 +288,7 @@ export function fetchCallback( options, ); } + return span; } } @@ -302,13 +361,15 @@ export function addTracingHeadersToFetchRequest( /** * Create and track xhr request spans + * + * @returns Span if a span was created, otherwise void. */ -export function xhrCallback( +function xhrCallback( handlerData: XHRData, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, -): void { +): Span | void { const xhr = handlerData.xhr; const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY]; @@ -336,8 +397,7 @@ export function xhrCallback( return; } - const currentScope = getCurrentHub().getScope(); - const currentSpan = currentScope && currentScope.getSpan(); + const currentSpan = getCurrentHub().getScope().getSpan(); const activeTransaction = currentSpan && currentSpan.transaction; if (currentSpan && activeTransaction) { @@ -372,5 +432,7 @@ export function xhrCallback( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } + + return span; } } diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts index 9aaa8939b6dc..75ec564eb5de 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts @@ -31,6 +31,7 @@ const getNavigationEntryFromPerformanceTiming = (): NavigationTimingPolyfillEntr for (const key in timing) { if (key !== 'navigationStart' && key !== 'toJSON') { + // eslint-disable-next-line deprecation/deprecation navigationEntry[key] = Math.max((timing[key as keyof PerformanceTiming] as number) - timing.navigationStart, 0); } } diff --git a/packages/tracing-internal/src/node/integrations/prisma.ts b/packages/tracing-internal/src/node/integrations/prisma.ts index 20a84aa36aaf..458e0f304ebe 100644 --- a/packages/tracing-internal/src/node/integrations/prisma.ts +++ b/packages/tracing-internal/src/node/integrations/prisma.ts @@ -1,7 +1,6 @@ -import type { Hub } from '@sentry/core'; -import { trace } from '@sentry/core'; -import type { EventProcessor, Integration } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { getCurrentHub, trace } from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { addNonEnumerableProperty, logger } from '@sentry/utils'; import { shouldDisableAutoInstrumentation } from './utils/node-utils'; @@ -36,11 +35,12 @@ type PrismaMiddleware = ( ) => Promise; interface PrismaClient { + _sentryInstrumented?: boolean; $use: (cb: PrismaMiddleware) => void; } function isValidPrismaClient(possibleClient: unknown): possibleClient is PrismaClient { - return possibleClient && !!(possibleClient as PrismaClient)['$use']; + return !!possibleClient && !!(possibleClient as PrismaClient)['$use']; } /** Tracing integration for @prisma/client package */ @@ -55,17 +55,30 @@ export class Prisma implements Integration { */ public name: string = Prisma.id; - /** - * Prisma ORM Client Instance - */ - private readonly _client?: PrismaClient; - /** * @inheritDoc */ public constructor(options: { client?: unknown } = {}) { - if (isValidPrismaClient(options.client)) { - this._client = options.client; + // We instrument the PrismaClient inside the constructor and not inside `setupOnce` because in some cases of server-side + // bundling (Next.js) multiple Prisma clients can be instantiated, even though users don't intend to. When instrumenting + // in setupOnce we can only ever instrument one client. + // https://github.com/getsentry/sentry-javascript/issues/7216#issuecomment-1602375012 + // In the future we might explore providing a dedicated PrismaClient middleware instead of this hack. + if (isValidPrismaClient(options.client) && !options.client._sentryInstrumented) { + addNonEnumerableProperty(options.client as any, '_sentryInstrumented', true); + + options.client.$use((params, next: (params: PrismaMiddlewareParams) => Promise) => { + if (shouldDisableAutoInstrumentation(getCurrentHub)) { + return next(params); + } + + const action = params.action; + const model = params.model; + return trace( + { name: model ? `${model} ${action}` : action, op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + () => next(params), + ); + }); } else { __DEBUG_BUILD__ && logger.warn( @@ -77,24 +90,7 @@ export class Prisma implements Integration { /** * @inheritDoc */ - public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - if (!this._client) { - __DEBUG_BUILD__ && logger.error('PrismaIntegration is missing a Prisma Client Instance'); - return; - } - - if (shouldDisableAutoInstrumentation(getCurrentHub)) { - __DEBUG_BUILD__ && logger.log('Prisma Integration is skipped because of instrumenter configuration.'); - return; - } - - this._client.$use((params, next: (params: PrismaMiddlewareParams) => Promise) => { - const action = params.action; - const model = params.model; - return trace( - { name: model ? `${model} ${action}` : action, op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, - () => next(params), - ); - }); + public setupOnce(): void { + // Noop - here for backwards compatibility } } diff --git a/packages/tracing-internal/test/browser/browsertracing.test.ts b/packages/tracing-internal/test/browser/browsertracing.test.ts index 669e79e3c097..c7cec8a54735 100644 --- a/packages/tracing-internal/test/browser/browsertracing.test.ts +++ b/packages/tracing-internal/test/browser/browsertracing.test.ts @@ -250,6 +250,65 @@ describe('BrowserTracing', () => { tracePropagationTargets: ['something'], }); }); + + it('uses `tracePropagationTargets` set by client over integration set targets', () => { + jest.clearAllMocks(); + hub.getClient()!.getOptions().tracePropagationTargets = ['something-else']; + const sampleTracePropagationTargets = ['something']; + createBrowserTracing(true, { + routingInstrumentation: customInstrumentRouting, + tracePropagationTargets: sampleTracePropagationTargets, + }); + + expect(instrumentOutgoingRequestsMock).toHaveBeenCalledWith({ + traceFetch: true, + traceXHR: true, + tracePropagationTargets: ['something-else'], + }); + }); + + it.each([ + [true, 'tracePropagationTargets', 'defined', { tracePropagationTargets: ['something'] }], + [false, 'tracePropagationTargets', 'undefined', { tracePropagationTargets: undefined }], + [true, 'tracingOrigins', 'defined', { tracingOrigins: ['something'] }], + [false, 'tracingOrigins', 'undefined', { tracingOrigins: undefined }], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'defined', + { tracePropagationTargets: ['something'], tracingOrigins: ['something-else'] }, + ], + [ + false, + 'tracePropagationTargets and tracingOrigins', + 'undefined', + { tracePropagationTargets: undefined, tracingOrigins: undefined }, + ], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'defined and undefined', + { tracePropagationTargets: ['something'], tracingOrigins: undefined }, + ], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'undefined and defined', + { tracePropagationTargets: undefined, tracingOrigins: ['something'] }, + ], + ])( + 'sets `_hasSetTracePropagationTargets` to %s if %s is %s', + (hasSet: boolean, _: string, __: string, options: Partial) => { + jest.clearAllMocks(); + const inst = createBrowserTracing(true, { + routingInstrumentation: customInstrumentRouting, + ...options, + }); + + // @ts-ignore accessing private property + expect(inst._hasSetTracePropagationTargets).toBe(hasSet); + }, + ); }); describe('beforeNavigate', () => { diff --git a/packages/tracing/package.json b/packages/tracing/package.json index c177599cdcfe..2113c78809f8 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -12,6 +12,9 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -30,7 +33,9 @@ "build": "run-p build:transpile build:types", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts index d292a231f1b7..044582fcf6e2 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -44,7 +44,7 @@ describe('Hub', () => { scope.setSpan(transaction); }); - expect(hub.getScope()?.getTransaction()).toBe(transaction); + expect(hub.getScope().getTransaction()).toBe(transaction); }); it('should find a transaction which has been set on the scope if sampled = false', () => { @@ -57,7 +57,7 @@ describe('Hub', () => { scope.setSpan(transaction); }); - expect(hub.getScope()?.getTransaction()).toBe(transaction); + expect(hub.getScope().getTransaction()).toBe(transaction); }); it("should not find an open transaction if it's not on the scope", () => { @@ -66,7 +66,7 @@ describe('Hub', () => { makeMain(hub); hub.startTransaction({ name: 'dogpark' }); - expect(hub.getScope()?.getTransaction()).toBeUndefined(); + expect(hub.getScope().getTransaction()).toBeUndefined(); }); }); diff --git a/packages/tracing/test/integrations/node/prisma.test.ts b/packages/tracing/test/integrations/node/prisma.test.ts index 3096401ec43a..61c0e5fb07f6 100644 --- a/packages/tracing/test/integrations/node/prisma.test.ts +++ b/packages/tracing/test/integrations/node/prisma.test.ts @@ -1,7 +1,6 @@ /* eslint-disable deprecation/deprecation */ -/* eslint-disable @typescript-eslint/unbound-method */ -import { Hub, Scope } from '@sentry/core'; -import { logger } from '@sentry/utils'; +import * as sentryCore from '@sentry/core'; +import { Hub } from '@sentry/core'; import { Integrations } from '../../../src'; import { getTestClient } from '../../testutils'; @@ -38,21 +37,15 @@ class PrismaClient { } describe('setupOnce', function () { - const Client: PrismaClient = new PrismaClient(); - - beforeAll(() => { - new Integrations.Prisma({ client: Client }).setupOnce( - () => undefined, - () => new Hub(undefined, new Scope()), - ); - }); - beforeEach(() => { mockTrace.mockClear(); + mockTrace.mockReset(); }); it('should add middleware with $use method correctly', done => { - void Client.user.create()?.then(() => { + const prismaClient = new PrismaClient(); + new Integrations.Prisma({ client: prismaClient }); + void prismaClient.user.create()?.then(() => { expect(mockTrace).toHaveBeenCalledTimes(1); expect(mockTrace).toHaveBeenLastCalledWith( { name: 'user create', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, @@ -62,18 +55,18 @@ describe('setupOnce', function () { }); }); - it("doesn't attach when using otel instrumenter", () => { - const loggerLogSpy = jest.spyOn(logger, 'log'); + it("doesn't trace when using otel instrumenter", done => { + const prismaClient = new PrismaClient(); + new Integrations.Prisma({ client: prismaClient }); const client = getTestClient({ instrumenter: 'otel' }); const hub = new Hub(client); - const integration = new Integrations.Prisma({ client: Client }); - integration.setupOnce( - () => {}, - () => hub, - ); + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); - expect(loggerLogSpy).toBeCalledWith('Prisma Integration is skipped because of instrumenter configuration.'); + void prismaClient.user.create()?.then(() => { + expect(mockTrace).not.toHaveBeenCalled(); + done(); + }); }); }); diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts index e642f7490589..1720dd4e6ec4 100644 --- a/packages/tracing/test/span.test.ts +++ b/packages/tracing/test/span.test.ts @@ -96,6 +96,7 @@ describe('Span', () => { span.setHttpStatus(404); expect((span.getTraceContext() as any).status).toBe('not_found'); expect(span.tags['http.status_code']).toBe('404'); + expect(span.data['http.response.status_code']).toBe(404); }); test('isSuccess', () => { diff --git a/packages/types/package.json b/packages/types/package.json index 736924037fc0..77f343f59de0 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,7 +22,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1da1778d2012..ccabae59a995 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,6 +49,16 @@ export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocati export type { ClientOptions, Options } from './options'; export type { Package } from './package'; export type { PolymorphicEvent, PolymorphicRequest } from './polymorphics'; +export type { + ThreadId, + FrameId, + StackId, + ThreadCpuSample, + ThreadCpuStack, + ThreadCpuFrame, + ThreadCpuProfile, + Profile, +} from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index 0f8a163b2615..60d747136d90 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -5,6 +5,7 @@ import type { Integration } from './integration'; import type { CaptureContext } from './scope'; import type { SdkMetadata } from './sdkmetadata'; import type { StackLineParser, StackParser } from './stacktrace'; +import type { TracePropagationTargets } from './tracing'; import type { SamplingContext } from './transaction'; import type { BaseTransportOptions, Transport } from './transport'; @@ -221,6 +222,24 @@ export interface ClientOptions; + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + /** * Function to compute tracing sample rate dynamically and filter unwanted traces. * diff --git a/packages/types/src/profiling.ts b/packages/types/src/profiling.ts new file mode 100644 index 000000000000..d99736df735e --- /dev/null +++ b/packages/types/src/profiling.ts @@ -0,0 +1,70 @@ +import type { DebugImage } from './debugMeta'; +export type ThreadId = string; +export type FrameId = number; +export type StackId = number; + +export interface ThreadCpuSample { + stack_id: StackId; + thread_id: ThreadId; + elapsed_since_start_ns: string; +} + +export type ThreadCpuStack = FrameId[]; + +export type ThreadCpuFrame = { + function: string; + file?: string; + line?: number; + column?: number; +}; + +export interface ThreadCpuProfile { + samples: ThreadCpuSample[]; + stacks: ThreadCpuStack[]; + frames: ThreadCpuFrame[]; + thread_metadata: Record; + queue_metadata?: Record; +} + +export interface Profile { + event_id: string; + version: string; + os: { + name: string; + version: string; + build_number?: string; + }; + runtime: { + name: string; + version: string; + }; + device: { + architecture: string; + is_emulator: boolean; + locale: string; + manufacturer: string; + model: string; + }; + timestamp: string; + release: string; + environment: string; + platform: string; + profile: ThreadCpuProfile; + debug_meta?: { + images: DebugImage[]; + }; + transaction?: { + name: string; + id: string; + trace_id: string; + active_thread_id: string; + }; + transactions?: { + name: string; + id: string; + trace_id: string; + active_thread_id: string; + relative_start_ns: string; + relative_end_ns: string; + }[]; +} diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 44c0379492fc..43578348e184 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -11,7 +11,7 @@ "access": "public" }, "peerDependencies": { - "typescript": "3.8.3" + "typescript": "4.9.5" }, "scripts": { "clean": "yarn rimraf sentry-internal-typescript-*.tgz", diff --git a/packages/utils/package.json b/packages/utils/package.json index 625cf7652e13..524174880ac1 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,12 +12,15 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, "dependencies": { "@sentry/types": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/array.prototype.flat": "^1.2.1", @@ -29,7 +32,9 @@ "build:dev": "yarn build", "build:transpile": "yarn ts-node scripts/buildRollup.ts", "build:transpile:uncached": "yarn ts-node scripts/buildRollup.ts", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/browser/src/profiling/cache.ts b/packages/utils/src/cache.ts similarity index 89% rename from packages/browser/src/profiling/cache.ts rename to packages/utils/src/cache.ts index ee62538e60cb..412970e77c76 100644 --- a/packages/browser/src/profiling/cache.ts +++ b/packages/utils/src/cache.ts @@ -1,10 +1,8 @@ -import type { Event } from '@sentry/types'; - /** * Creates a cache that evicts keys in fifo order * @param size {Number} */ -export function makeProfilingCache( +export function makeFifoCache( size: number, ): { get: (key: Key) => Value | undefined; @@ -68,5 +66,3 @@ export function makeProfilingCache( }, }; } - -export const PROFILING_EVENT_CACHE = makeProfilingCache(20); diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 66adb7e78ebd..e91aefdbab5b 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -34,7 +34,7 @@ export function createEnvelope(headers: E[0], items: E[1] = */ export function addItemToEnvelope(envelope: E, newItem: E[1][number]): E { const [headers, items] = envelope; - return [headers, [...items, newItem]] as E; + return [headers, [...items, newItem]] as unknown as E; } /** diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c5631559a9aa..6b9426c22149 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,3 +28,4 @@ export * from './ratelimit'; export * from './baggage'; export * from './url'; export * from './userIntegrations'; +export * from './cache'; diff --git a/packages/utils/test/buildPolyfills/originals.js b/packages/utils/test/buildPolyfills/originals.js index d3dcb22e8082..969591755367 100644 --- a/packages/utils/test/buildPolyfills/originals.js +++ b/packages/utils/test/buildPolyfills/originals.js @@ -2,11 +2,11 @@ // the modified versions do the same thing the originals do. // From Sucrase -export async function _asyncNullishCoalesce(lhs, rhsFn) { +export function _asyncNullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { - return await rhsFn(); + return rhsFn(); } } diff --git a/packages/utils/test/envelope.test.ts b/packages/utils/test/envelope.test.ts index 9649da0e2108..10cdcc2cab73 100644 --- a/packages/utils/test/envelope.test.ts +++ b/packages/utils/test/envelope.test.ts @@ -1,4 +1,4 @@ -import type { EventEnvelope } from '@sentry/types'; +import type { Event, EventEnvelope } from '@sentry/types'; import { TextDecoder, TextEncoder } from 'util'; const encoder = new TextEncoder(); @@ -68,8 +68,8 @@ describe('envelope', () => { }); it("doesn't throw when being passed a an envelope that contains a circular item payload", () => { - const chicken: { egg?: unknown } = {}; - const egg = { chicken }; + const chicken: { egg?: any } = {}; + const egg = { chicken } as unknown as Event; chicken.egg = chicken; const env = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ diff --git a/packages/utils/test/worldwide.test.ts b/packages/utils/test/worldwide.test.ts index 6137c95c093b..52203a248d69 100644 --- a/packages/utils/test/worldwide.test.ts +++ b/packages/utils/test/worldwide.test.ts @@ -3,6 +3,7 @@ import { GLOBAL_OBJ } from '../src/worldwide'; describe('GLOBAL_OBJ', () => { test('should return the same object', () => { const backup = global.process; + // @ts-ignore for testing delete global.process; const first = GLOBAL_OBJ; const second = GLOBAL_OBJ; diff --git a/packages/vue/package.json b/packages/vue/package.json index b82ca6ae4e61..875f5ce643a9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -20,7 +23,7 @@ "@sentry/core": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { "vue": "2.x || 3.x" @@ -32,7 +35,9 @@ "build": "run-p build:transpile build:types", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index 55b7b7304baa..1be68b26b61e 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -30,8 +30,7 @@ const HOOKS: { [key in Operation]: Hook[] } = { /** Grabs active transaction off scope, if any */ export function getActiveTransaction(): Transaction | undefined { - const scope = getCurrentHub().getScope(); - return scope && scope.getTransaction(); + return getCurrentHub().getScope().getTransaction(); } /** Finish top-level span and activity with a debounce configured using `timeout` option */ diff --git a/packages/wasm/package.json b/packages/wasm/package.json index fcd2bef8a637..674fa58e4dd1 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -12,6 +12,9 @@ "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,14 +22,16 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", "build:bundle": "rollup --config rollup.bundle.config.js", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", "build:bundle:watch": "rollup --config rollup.bundle.config.js --watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", diff --git a/scripts/ensure-bundle-deps.ts b/scripts/ensure-bundle-deps.ts index 8be6377ee4e1..4e61a6ecf993 100644 --- a/scripts/ensure-bundle-deps.ts +++ b/scripts/ensure-bundle-deps.ts @@ -129,7 +129,7 @@ function checkForBundleDeps(packagesDir: string, dependencyDirs: string[]): bool * Wait the given number of milliseconds before continuing. */ async function sleep(ms: number): Promise { - await new Promise(resolve => + await new Promise(resolve => setTimeout(() => { resolve(); }, ms), diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index 9e24e8090096..9c54cf2020c4 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -1,6 +1,6 @@ const pkg = require('../package.json'); -const TYPESCRIPT_VERSION = '3.8.3'; +const TYPESCRIPT_VERSION = '4.9.5'; if (pkg.devDependencies.typescript !== TYPESCRIPT_VERSION) { console.error(` diff --git a/yarn.lock b/yarn.lock index b4ab26edf619..224622dc7189 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11128,6 +11128,15 @@ dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +downlevel-dts@~0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/downlevel-dts/-/downlevel-dts-0.11.0.tgz#514a2d723009c5845730c1db6c994484c596ed9c" + integrity sha512-vo835pntK7kzYStk7xUHDifiYJvXxVhUapt85uk2AI94gUUAQX9HNRtrcMHNSc3YHJUEHGbYIGsM99uIbgAtxw== + dependencies: + semver "^7.3.2" + shelljs "^0.8.3" + typescript next + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -24140,7 +24149,7 @@ shell-quote@1.7.2, shell-quote@^1.6.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== -shelljs@^0.8.4: +shelljs@^0.8.3, shelljs@^0.8.4: version "0.8.5" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== @@ -26244,20 +26253,20 @@ tslib@2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== -tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + +tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - -tslib@^2.2.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, "tslib@^2.4.1 || ^1.9.3": + version "2.5.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" + integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== tsutils@^3.0.0, tsutils@^3.17.1, tsutils@^3.21.0: version "3.21.0" @@ -26405,45 +26414,30 @@ typescript-memoize@^1.0.0-alpha.3, typescript-memoize@^1.0.1: resolved "https://registry.yarnpkg.com/typescript-memoize/-/typescript-memoize-1.0.1.tgz#0a8199aa28f6fe18517f6e9308ef7bfbe9a98d59" integrity sha512-oJNge1qUrOK37d5Y6Ly2txKeuelYVsFtNF6U9kXIN7juudcQaHJQg2MxLOy0CqtkW65rVDYuTCOjnSIVPd8z3w== -typescript@3.8.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" - integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== - typescript@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== -typescript@4.3.5, typescript@~4.3.5: +typescript@4.3.5: version "4.3.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== -"typescript@^3 || ^4", typescript@^4.5.2: - version "4.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" - integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== - -typescript@^3.9.5, typescript@^3.9.7: - version "3.9.9" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.9.tgz#e69905c54bc0681d0518bd4d587cc6f2d0b1a674" - integrity sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w== - -typescript@^4.9.3, typescript@^4.9.4: +typescript@4.9.5, "typescript@^3 || ^4": version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@~4.0.2: - version "4.0.8" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.8.tgz#5739105541db80a971fdbd0d56511d1a6f17d37f" - integrity sha512-oz1765PN+imfz1MlZzSZPtC/tqcwsCyIYA8L47EkRnRW97ztRk83SzMiWLrnChC0vqoYxSU1fcFUDA5gV/ZiPg== - -typescript@~4.5.2: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^3.9.5, typescript@^3.9.7: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + +typescript@next: + version "5.2.0-dev.20230530" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" + integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39"