diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 6057361b0174..4eaa0f5d64ab 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -35,6 +35,7 @@ jobs: uses: actions/setup-node@v3 with: node-version-file: 'package.json' + cache: 'yarn' - name: Install dependencies run: yarn install --ignore-engines --frozen-lockfile diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 920c44a73610..cb7fddd1b50c 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -62,12 +62,12 @@ jobs: "@sentry.serverless": { "label": "Package: Serverless" }, - "@sentry.svelte": { - "label": "Package: svelte" - }, "@sentry.sveltekit": { "label": "Package: SvelteKit" }, + "@sentry.svelte": { + "label": "Package: svelte" + }, "@sentry.vue": { "label": "Package: vue" }, diff --git a/.size-limit.js b/.size-limit.js index 417703ffd105..71346dc3d48d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -75,7 +75,7 @@ module.exports = [ { name: '@sentry/react (incl. Tracing, Replay) - Webpack (gzipped)', path: 'packages/react/build/esm/index.js', - import: '{ init, BrowserTYracing, Replay }', + import: '{ init, BrowserTracing, Replay }', gzip: true, limit: '80 KB', }, diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e6b5cde7f1..89eb3b20a452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.65.0 + +- build: Remove build-specific polyfills (#8809) +- build(deps): bump protobufjs from 6.11.3 to 6.11.4 (#8822) +- deps(sveltekit): Bump `@sentry/vite-plugin` (#8877) +- feat(core): Introduce `Sentry.startActiveSpan` and `Sentry.startSpan` (#8803) +- fix: Memoize `AsyncLocalStorage` instance (#8831) +- fix(nextjs): Check for validity of API route handler signature (#8811) +- fix(nextjs): Fix `requestAsyncStorageShim` path resolution on windows (#8875) +- fix(node): Log entire error object in `OnUncaughtException` (#8876) +- fix(node): More relevant warning message when tracing extensions are missing (#8820) +- fix(replay): Streamline session creation/refresh (#8813) +- fix(sveltekit): Avoid invalidating data on route changes in `wrapServerLoadWithSentry` (#8801) +- fix(tracing): Better guarding for performance observer (#8872) +- ref(sveltekit): Remove custom client fetch instrumentation and use default instrumentation (#8802) +- ref(tracing-internal): Deprecate `tracePropagationTargets` in `BrowserTracing` (#8874) + ## 7.64.0 - feat(core): Add setMeasurement export (#8791) diff --git a/packages/angular-ivy/package.json b/packages/angular-ivy/package.json index b8596b22c32c..6452b5a1f9b1 100644 --- a/packages/angular-ivy/package.json +++ b/packages/angular-ivy/package.json @@ -61,5 +61,16 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "dependsOn": [ + "^build:transpile", + "^build:transpile:uncached", + "^build:types" + ] + } + } + } } diff --git a/packages/angular/package.json b/packages/angular/package.json index 6ab840da170f..cee9f9c23951 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -65,5 +65,16 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "dependsOn": [ + "^build:transpile", + "^build:transpile:uncached", + "^build:types" + ] + } + } + } } diff --git a/packages/browser-integration-tests/suites/replay/bufferModeReload/init.js b/packages/browser-integration-tests/suites/replay/bufferModeReload/init.js new file mode 100644 index 000000000000..89c185dacc7f --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferModeReload/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/bufferModeReload/template.html b/packages/browser-integration-tests/suites/replay/bufferModeReload/template.html new file mode 100644 index 000000000000..084254db29e1 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferModeReload/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/replay/bufferModeReload/test.ts b/packages/browser-integration-tests/suites/replay/bufferModeReload/test.ts new file mode 100644 index 000000000000..95e2bf399592 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferModeReload/test.ts @@ -0,0 +1,51 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { + getReplaySnapshot, + shouldSkipReplayTest, + waitForReplayRequest, + waitForReplayRunning, +} from '../../../utils/replayHelpers'; + +sentryTest('continues buffer session in session mode after error & reload', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise1 = 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 getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + + // buffer session captures an error & switches to session mode + await page.click('#buttonError'); + await new Promise(resolve => setTimeout(resolve, 300)); + await reqPromise1; + + await waitForReplayRunning(page); + const replay1 = await getReplaySnapshot(page); + + expect(replay1.recordingMode).toEqual('session'); + expect(replay1.session?.sampled).toEqual('buffer'); + expect(replay1.session?.segmentId).toBeGreaterThan(0); + + // Reload to ensure the session is correctly recovered from sessionStorage + await page.reload(); + + await waitForReplayRunning(page); + const replay2 = await getReplaySnapshot(page); + + expect(replay2.recordingMode).toEqual('session'); + expect(replay2.session?.sampled).toEqual('buffer'); + expect(replay2.session?.segmentId).toBeGreaterThan(0); +}); diff --git a/packages/browser-integration-tests/suites/replay/customEvents/test.ts b/packages/browser-integration-tests/suites/replay/customEvents/test.ts index 690929dc9d3a..1966ba2d4e4c 100644 --- a/packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -88,6 +88,8 @@ sentryTest( const reqPromise0 = waitForReplayRequest(page, 0); const reqPromise1 = waitForReplayRequest(page, 1); + const reqPromise2 = waitForReplayRequest(page, 2); + const reqPromise3 = waitForReplayRequest(page, 3); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -103,8 +105,6 @@ sentryTest( await reqPromise0; await page.click('#error'); - await page.click('#img'); - await page.click('.sentry-unmask'); await forceFlushReplay(); const req1 = await reqPromise1; const content1 = getReplayRecordingContent(req1); @@ -131,7 +131,11 @@ sentryTest( ]), ); - expect(content1.breadcrumbs).toEqual( + await page.click('#img'); + await forceFlushReplay(); + const req2 = await reqPromise2; + const content2 = getReplayRecordingContent(req2); + expect(content2.breadcrumbs).toEqual( expect.arrayContaining([ { ...expectedClickBreadcrumb, @@ -151,7 +155,11 @@ sentryTest( ]), ); - expect(content1.breadcrumbs).toEqual( + await page.click('.sentry-unmask'); + await forceFlushReplay(); + const req3 = await reqPromise3; + const content3 = getReplayRecordingContent(req3); + expect(content3.breadcrumbs).toEqual( expect.arrayContaining([ { ...expectedClickBreadcrumb, diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index ea4f955ec681..5961529be687 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -374,11 +374,19 @@ export class Hub implements HubInterface { const result = this._callExtensionMethod('startTransaction', context, customSamplingContext); if (__DEBUG_BUILD__ && !result) { - // eslint-disable-next-line no-console - console.warn(`Tracing extension 'startTransaction' has not been added. Call 'addTracingExtensions' before calling 'init': + const client = this.getClient(); + if (!client) { + // eslint-disable-next-line no-console + console.warn( + "Tracing extension 'startTransaction' is missing. You should 'init' the SDK before calling 'startTransaction'", + ); + } else { + // eslint-disable-next-line no-console + console.warn(`Tracing extension 'startTransaction' has not been added. Call 'addTracingExtensions' before calling 'init': Sentry.addTracingExtensions(); Sentry.init({...}); `); + } } return result; diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index f418453ff28d..470286366c81 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -6,6 +6,6 @@ export { extractTraceparentData, getActiveTransaction } from './utils'; // eslint-disable-next-line deprecation/deprecation export { SpanStatus } from './spanstatus'; export type { SpanStatusType } from './span'; -export { trace } from './trace'; +export { trace, getActiveSpan, startActiveSpan, startSpan } from './trace'; export { getDynamicSamplingContextFromClient } from './dynamicSamplingContext'; export { setMeasurement } from './measurement'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 2864377bfc04..0ca928e9002a 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -34,14 +34,14 @@ export function trace( const parentSpan = scope.getSpan(); - function getActiveSpan(): Span | undefined { + function startActiveSpan(): Span | undefined { if (!hasTracingEnabled()) { return undefined; } return parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); } - const activeSpan = getActiveSpan(); + const activeSpan = startActiveSpan(); scope.setSpan(activeSpan); function finishAndSetSpan(): void { @@ -76,3 +76,100 @@ export function trace( return maybePromiseResult; } + +/** + * Wraps a function with a transaction/span and finishes the span after the function is done. + * The created span is the active span and will be used as parent by other spans created inside the function + * and can be accessed via `Sentry.getSpan()`, as long as the function is executed while the scope is active. + * + * If you want to create a span that is not set as active, use {@link startSpan}. + * + * Note that if you have not enabled tracing extensions via `addTracingExtensions` + * or you didn't set `tracesSampleRate`, this function will not generate spans + * and the `span` returned from the callback will be undefined. + */ +export function startActiveSpan(context: TransactionContext, callback: (span: Span | undefined) => T): T { + const ctx = { ...context }; + // If a name is set and a description is not, set the description to the name. + if (ctx.name !== undefined && ctx.description === undefined) { + ctx.description = ctx.name; + } + + const hub = getCurrentHub(); + const scope = hub.getScope(); + + const parentSpan = scope.getSpan(); + + function startActiveSpan(): Span | undefined { + if (!hasTracingEnabled()) { + return undefined; + } + return parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + } + + const activeSpan = startActiveSpan(); + scope.setSpan(activeSpan); + + function finishAndSetSpan(): void { + activeSpan && activeSpan.finish(); + hub.getScope().setSpan(parentSpan); + } + + let maybePromiseResult: T; + try { + maybePromiseResult = callback(activeSpan); + } catch (e) { + activeSpan && activeSpan.setStatus('internal_error'); + finishAndSetSpan(); + throw e; + } + + if (isThenable(maybePromiseResult)) { + Promise.resolve(maybePromiseResult).then( + () => { + finishAndSetSpan(); + }, + () => { + activeSpan && activeSpan.setStatus('internal_error'); + finishAndSetSpan(); + }, + ); + } else { + finishAndSetSpan(); + } + + return maybePromiseResult; +} + +/** + * Creates a span. This span is not set as active, so will not get automatic instrumentation spans + * as children or be able to be accessed via `Sentry.getSpan()`. + * + * If you want to create a span that is set as active, use {@link startActiveSpan}. + * + * Note that if you have not enabled tracing extensions via `addTracingExtensions` + * or you didn't set `tracesSampleRate` or `tracesSampler`, this function will not generate spans + * and the `span` returned from the callback will be undefined. + */ +export function startSpan(context: TransactionContext): Span | undefined { + if (!hasTracingEnabled()) { + return undefined; + } + + const ctx = { ...context }; + // If a name is set and a description is not, set the description to the name. + if (ctx.name !== undefined && ctx.description === undefined) { + ctx.description = ctx.name; + } + + const hub = getCurrentHub(); + const parentSpan = getActiveSpan(); + return parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); +} + +/** + * Returns the currently active span. + */ +export function getActiveSpan(): Span | undefined { + return getCurrentHub().getScope().getSpan(); +} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index bff1c425c2a0..f607aa7369f9 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, Hub, makeMain } from '../../../src'; -import { trace } from '../../../src/tracing'; +import { startActiveSpan } from '../../../src/tracing'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; beforeAll(() => { @@ -14,7 +14,7 @@ const enum Type { let hub: Hub; let client: TestClient; -describe('trace', () => { +describe('startActiveSpan', () => { beforeEach(() => { const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); client = new TestClient(options); @@ -38,7 +38,7 @@ describe('trace', () => { ])('with %s callback and error %s', (_type, isError, callback, expected) => { it('should return the same value as the callback', async () => { try { - const result = await trace({ name: 'GET users/[id]' }, () => { + const result = await startActiveSpan({ name: 'GET users/[id]' }, () => { return callback(); }); expect(result).toEqual(expected); @@ -53,7 +53,7 @@ describe('trace', () => { // if tracingExtensions are not enabled jest.spyOn(hub, 'startTransaction').mockReturnValue(undefined); try { - const result = await trace({ name: 'GET users/[id]' }, () => { + const result = await startActiveSpan({ name: 'GET users/[id]' }, () => { return callback(); }); expect(result).toEqual(expected); @@ -68,7 +68,7 @@ describe('trace', () => { ref = transaction; }); try { - await trace({ name: 'GET users/[id]' }, () => { + await startActiveSpan({ name: 'GET users/[id]' }, () => { return callback(); }); } catch (e) { @@ -86,7 +86,7 @@ describe('trace', () => { ref = transaction; }); try { - await trace( + await startActiveSpan( { name: 'GET users/[id]', parentSampled: true, @@ -113,7 +113,7 @@ describe('trace', () => { ref = transaction; }); try { - await trace({ name: 'GET users/[id]' }, span => { + await startActiveSpan({ name: 'GET users/[id]' }, span => { if (span) { span.op = 'http.server'; } @@ -132,8 +132,8 @@ describe('trace', () => { ref = transaction; }); try { - await trace({ name: 'GET users/[id]', parentSampled: true }, () => { - return trace({ name: 'SELECT * from users' }, () => { + await startActiveSpan({ name: 'GET users/[id]', parentSampled: true }, () => { + return startActiveSpan({ name: 'SELECT * from users' }, () => { return callback(); }); }); @@ -153,8 +153,8 @@ describe('trace', () => { ref = transaction; }); try { - await trace({ name: 'GET users/[id]', parentSampled: true }, () => { - return trace({ name: 'SELECT * from users' }, childSpan => { + await startActiveSpan({ name: 'GET users/[id]', parentSampled: true }, () => { + return startActiveSpan({ name: 'SELECT * from users' }, childSpan => { if (childSpan) { childSpan.op = 'db.query'; } @@ -168,50 +168,5 @@ describe('trace', () => { expect(ref.spanRecorder.spans).toHaveLength(2); expect(ref.spanRecorder.spans[1].op).toEqual('db.query'); }); - - it('calls `onError` hook', async () => { - 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); - }); - - 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/nextjs/package.json b/packages/nextjs/package.json index 853e909c496f..1b90648a076c 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -13,6 +13,16 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "exports": { + ".": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js", + "types": "./build/types/index.types.d.ts" + }, + "./requestAsyncStorageShim": { + "import": "./build/esm/config/templates/requestAsyncStorageShim.js" + } + }, "typesVersions": { "<4.9": { "build/npm/types/index.d.ts": [ diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts index b4af7d47893e..85ec0cb4b1c2 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts @@ -53,9 +53,25 @@ export const withSentryAPI = wrapApiHandlerWithSentry; */ export function withSentry(apiHandler: NextApiHandler, parameterizedRoute?: string): NextApiHandler { return new Proxy(apiHandler, { - apply: (wrappingTarget, thisArg, args: [AugmentedNextApiRequest, AugmentedNextApiResponse]) => { + apply: ( + wrappingTarget, + thisArg, + args: [AugmentedNextApiRequest | undefined, AugmentedNextApiResponse | undefined], + ) => { const [req, res] = args; + if (!req) { + logger.debug( + `Wrapped API handler on route "${parameterizedRoute}" was not passed a request object. Will not instrument.`, + ); + return wrappingTarget.apply(thisArg, args); + } else if (!res) { + logger.debug( + `Wrapped API handler on route "${parameterizedRoute}" was not passed a response object. Will not instrument.`, + ); + return wrappingTarget.apply(thisArg, args); + } + // We're now auto-wrapping API route handlers using `wrapApiHandlerWithSentry` (which uses `withSentry` under the hood), but // users still may have their routes manually wrapped with `withSentry`. This check makes `sentryWrappedHandler` // idempotent so that those cases don't break anything. diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 57b913b23ab1..4cc2425a33c0 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -26,7 +26,6 @@ const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encod const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js'); const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' }); -const requestAsyncStorageShimPath = path.resolve(__dirname, '..', 'templates', 'requestAsyncStorageShim.js'); const requestAsyncStorageModuleExists = moduleExists(NEXTJS_REQUEST_ASYNC_STORAGE_MODULE_PATH); let showedMissingAsyncStorageModuleWarning = false; @@ -190,7 +189,10 @@ export default function wrappingLoader( ); showedMissingAsyncStorageModuleWarning = true; } - templateCode = templateCode.replace(/__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, requestAsyncStorageShimPath); + templateCode = templateCode.replace( + /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, + '@sentry/nextjs/requestAsyncStorageShim', + ); } templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); diff --git a/packages/nextjs/src/edge/asyncLocalStorageAsyncContextStrategy.ts b/packages/nextjs/src/edge/asyncLocalStorageAsyncContextStrategy.ts index e6872cd08893..36c6317248b4 100644 --- a/packages/nextjs/src/edge/asyncLocalStorageAsyncContextStrategy.ts +++ b/packages/nextjs/src/edge/asyncLocalStorageAsyncContextStrategy.ts @@ -11,6 +11,8 @@ interface AsyncLocalStorage { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any const MaybeGlobalAsyncLocalStorage = (GLOBAL_OBJ as any).AsyncLocalStorage; +let asyncStorage: AsyncLocalStorage; + /** * Sets the async context strategy to use AsyncLocalStorage which should be available in the edge runtime. */ @@ -23,7 +25,9 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { return; } - const asyncStorage: AsyncLocalStorage = new MaybeGlobalAsyncLocalStorage(); + if (!asyncStorage) { + asyncStorage = new MaybeGlobalAsyncLocalStorage(); + } function getCurrentHub(): Hub | undefined { return asyncStorage.getStore(); diff --git a/packages/node-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js b/packages/node-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js new file mode 100644 index 000000000000..758f9e26cc5b --- /dev/null +++ b/packages/node-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js @@ -0,0 +1,7 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +throw new Error('foo', { cause: 'bar' }); diff --git a/packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts b/packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts index 00c8459466c9..c90dd989e37f 100644 --- a/packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts +++ b/packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -28,6 +28,21 @@ describe('OnUncaughtException integration', () => { }); }); + test('should log entire error object to console stderr', done => { + expect.assertions(2); + + const testScriptPath = path.resolve(__dirname, 'log-entire-error-to-console.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stderr) => { + expect(err).not.toBeNull(); + const errString = err?.toString() || ''; + + expect(errString).toContain(stderr); + + done(); + }); + }); + describe('with `exitEvenIfOtherHandlersAreRegistered` set to false', () => { test('should close process on uncaught error with no additional listeners registered', done => { expect.assertions(3); diff --git a/packages/node/src/async/hooks.ts b/packages/node/src/async/hooks.ts index c7e59de997ef..151b699c70d6 100644 --- a/packages/node/src/async/hooks.ts +++ b/packages/node/src/async/hooks.ts @@ -12,11 +12,15 @@ type AsyncLocalStorageConstructor = { new (): AsyncLocalStorage }; // AsyncLocalStorage only exists in async_hook after Node v12.17.0 or v13.10.0 type NewerAsyncHooks = typeof async_hooks & { AsyncLocalStorage: AsyncLocalStorageConstructor }; +let asyncStorage: AsyncLocalStorage; + /** * Sets the async context strategy to use AsyncLocalStorage which requires Node v12.17.0 or v13.10.0. */ export function setHooksAsyncContextStrategy(): void { - const asyncStorage = new (async_hooks as NewerAsyncHooks).AsyncLocalStorage(); + if (!asyncStorage) { + asyncStorage = new (async_hooks as NewerAsyncHooks).AsyncLocalStorage(); + } function getCurrentHub(): Hub | undefined { return asyncStorage.getStore(); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index e0443691a8ae..1c172bc89618 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -55,6 +55,9 @@ export { withScope, captureCheckIn, setMeasurement, + getActiveSpan, + startActiveSpan, + startSpan, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing'; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index ded111673387..1102c0301095 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -34,6 +34,8 @@ interface TracingOptions { * array, and only attach tracing headers if a match was found. * * @deprecated Use top level `tracePropagationTargets` option instead. + * This option will be removed in v8. + * * ``` * Sentry.init({ * tracePropagationTargets: ['api.site.com'], diff --git a/packages/node/src/integrations/utils/errorhandling.ts b/packages/node/src/integrations/utils/errorhandling.ts index e4c7a14924a1..cf52929fa642 100644 --- a/packages/node/src/integrations/utils/errorhandling.ts +++ b/packages/node/src/integrations/utils/errorhandling.ts @@ -10,7 +10,7 @@ const DEFAULT_SHUTDOWN_TIMEOUT = 2000; */ export function logAndExitProcess(error: Error): void { // eslint-disable-next-line no-console - console.error(error && error.stack ? error.stack : error); + console.error(error); const client = getCurrentHub().getClient(); diff --git a/packages/overhead-metrics/test-apps/booking-app/img/house-0.jpg b/packages/overhead-metrics/test-apps/booking-app/img/house-0.jpg new file mode 100644 index 000000000000..ff0962fc24f1 Binary files /dev/null and b/packages/overhead-metrics/test-apps/booking-app/img/house-0.jpg differ diff --git a/packages/overhead-metrics/test-apps/booking-app/img/house-1.jpg b/packages/overhead-metrics/test-apps/booking-app/img/house-1.jpg new file mode 100644 index 000000000000..138f0b58ab2e Binary files /dev/null and b/packages/overhead-metrics/test-apps/booking-app/img/house-1.jpg differ diff --git a/packages/overhead-metrics/test-apps/booking-app/img/house-2.jpg b/packages/overhead-metrics/test-apps/booking-app/img/house-2.jpg new file mode 100644 index 000000000000..31f6bc1d5184 Binary files /dev/null and b/packages/overhead-metrics/test-apps/booking-app/img/house-2.jpg differ diff --git a/packages/overhead-metrics/test-apps/booking-app/main.js b/packages/overhead-metrics/test-apps/booking-app/main.js index c906d1c1fa45..1ad19f429506 100644 --- a/packages/overhead-metrics/test-apps/booking-app/main.js +++ b/packages/overhead-metrics/test-apps/booking-app/main.js @@ -99,12 +99,15 @@ function generateResult() { const descriptionShort = description.slice(0, 200); const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price); + const imgSrc = `./img/house-${Math.floor(Math.random() * 3)}.jpg`; + const placeholders = { title, beds, description, descriptionShort, priceStr, + imgSrc, }; return replacePlaceholders(template, placeholders); @@ -124,7 +127,7 @@ function replacePlaceholders(str, placeholders) { } const template = `
- {{title}} + {{title}}
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 6bba4eb66a0a..eec3edf45083 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -18,7 +18,8 @@ import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; import { createEventBuffer } from './eventBuffer'; import { clearSession } from './session/clearSession'; -import { getSession } from './session/getSession'; +import { loadOrCreateSession } from './session/loadOrCreateSession'; +import { maybeRefreshSession } from './session/maybeRefreshSession'; import { saveSession } from './session/saveSession'; import type { AddEventResult, @@ -228,13 +229,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Otherwise if there is _any_ sample rate set, try to load an existing // session, or create a new one. - const isSessionSampled = this._loadAndCheckSession(); - - if (!isSessionSampled) { - // This should only occur if `errorSampleRate` is 0 and was unsampled for - // session-based replay. In this case there is nothing to do. - return; - } + this._initializeSessionForSampling(); if (!this.session) { // This should not happen, something wrong has occurred @@ -242,14 +237,16 @@ export class ReplayContainer implements ReplayContainerInterface { return; } - if (this.session.sampled && this.session.sampled !== 'session') { - // If not sampled as session-based, then recording mode will be `buffer` - // Note that we don't explicitly check if `sampled === 'buffer'` because we - // could have sessions from Session storage that are still `error` from - // prior SDK version. - this.recordingMode = 'buffer'; + if (this.session.sampled === false) { + // This should only occur if `errorSampleRate` is 0 and was unsampled for + // session-based replay. In this case there is nothing to do. + return; } + // If segmentId > 0, it means we've previously already captured this session + // In this case, we still want to continue in `session` recording mode + this.recordingMode = this.session.sampled === 'buffer' && this.session.segmentId === 0 ? 'buffer' : 'session'; + logInfoNextTick( `[Replay] Starting replay in ${this.recordingMode} mode`, this._options._experiments.traceInternals, @@ -276,19 +273,20 @@ export class ReplayContainer implements ReplayContainerInterface { logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals); - const previousSessionId = this.session && this.session.id; - - const { session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - // This is intentional: create a new session-based replay when calling `start()` - sessionSampleRate: 1, - allowBuffering: false, - traceInternals: this._options._experiments.traceInternals, - }); + const session = loadOrCreateSession( + this.session, + { + timeouts: this.timeouts, + traceInternals: this._options._experiments.traceInternals, + }, + { + stickySession: this._options.stickySession, + // This is intentional: create a new session-based replay when calling `start()` + sessionSampleRate: 1, + allowBuffering: false, + }, + ); - session.previousSessionId = previousSessionId; this.session = session; this._initializeRecording(); @@ -305,18 +303,19 @@ export class ReplayContainer implements ReplayContainerInterface { logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals); - const previousSessionId = this.session && this.session.id; - - const { session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - sessionSampleRate: 0, - allowBuffering: true, - traceInternals: this._options._experiments.traceInternals, - }); + const session = loadOrCreateSession( + this.session, + { + timeouts: this.timeouts, + traceInternals: this._options._experiments.traceInternals, + }, + { + stickySession: this._options.stickySession, + sessionSampleRate: 0, + allowBuffering: true, + }, + ); - session.previousSessionId = previousSessionId; this.session = session; this.recordingMode = 'buffer'; @@ -427,7 +426,7 @@ export class ReplayContainer implements ReplayContainerInterface { * new DOM checkout.` */ public resume(): void { - if (!this._isPaused || !this._loadAndCheckSession()) { + if (!this._isPaused || !this._checkSession()) { return; } @@ -535,7 +534,7 @@ export class ReplayContainer implements ReplayContainerInterface { if (!this._stopRecording) { // Create a new session, otherwise when the user action is flushed, it // will get rejected due to an expired session. - if (!this._loadAndCheckSession()) { + if (!this._checkSession()) { return; } @@ -634,7 +633,7 @@ export class ReplayContainer implements ReplayContainerInterface { // --- There is recent user activity --- // // This will create a new session if expired, based on expiry length - if (!this._loadAndCheckSession()) { + if (!this._checkSession()) { return; } @@ -751,31 +750,63 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Loads (or refreshes) the current session. + */ + private _initializeSessionForSampling(): void { + // Whenever there is _any_ error sample rate, we always allow buffering + // Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors + const allowBuffering = this._options.errorSampleRate > 0; + + const session = loadOrCreateSession( + this.session, + { + timeouts: this.timeouts, + traceInternals: this._options._experiments.traceInternals, + }, + { + stickySession: this._options.stickySession, + sessionSampleRate: this._options.sessionSampleRate, + allowBuffering, + }, + ); + + this.session = session; + } + + /** + * Checks and potentially refreshes the current session. * Returns false if session is not recorded. */ - private _loadAndCheckSession(): boolean { - const { type, session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - sessionSampleRate: this._options.sessionSampleRate, - allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer', - traceInternals: this._options._experiments.traceInternals, - }); + private _checkSession(): boolean { + // If there is no session yet, we do not want to refresh anything + // This should generally not happen, but to be safe.... + if (!this.session) { + return false; + } + + const currentSession = this.session; + + const newSession = maybeRefreshSession( + currentSession, + { + timeouts: this.timeouts, + traceInternals: this._options._experiments.traceInternals, + }, + { + stickySession: Boolean(this._options.stickySession), + sessionSampleRate: this._options.sessionSampleRate, + allowBuffering: this._options.errorSampleRate > 0, + }, + ); + + const isNew = newSession.id !== currentSession.id; // If session was newly created (i.e. was not loaded from storage), then // enable flag to create the root replay - if (type === 'new') { + if (isNew) { this.setInitialState(); + this.session = newSession; } - const currentSessionId = this.getSessionId(); - if (session.id !== currentSessionId) { - session.previousSessionId = currentSessionId; - } - - this.session = session; - if (!this.session.sampled) { void this.stop({ reason: 'session not refreshed' }); return false; diff --git a/packages/replay/src/session/Session.ts b/packages/replay/src/session/Session.ts index e373d50dfaa2..80b32aed345a 100644 --- a/packages/replay/src/session/Session.ts +++ b/packages/replay/src/session/Session.ts @@ -14,6 +14,7 @@ export function makeSession(session: Partial & { sampled: Sampled }): S const segmentId = session.segmentId || 0; const sampled = session.sampled; const shouldRefresh = typeof session.shouldRefresh === 'boolean' ? session.shouldRefresh : true; + const previousSessionId = session.previousSessionId; return { id, @@ -22,5 +23,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S segmentId, sampled, shouldRefresh, + previousSessionId, }; } diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts index 0ecd940a4dae..2cb9c0853b09 100644 --- a/packages/replay/src/session/createSession.ts +++ b/packages/replay/src/session/createSession.ts @@ -15,10 +15,14 @@ export function getSessionSampleType(sessionSampleRate: number, allowBuffering: * that all replays will be saved to as attachments. Currently, we only expect * one of these Sentry events per "replay session". */ -export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session { +export function createSession( + { sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions, + { previousSessionId }: { previousSessionId?: string } = {}, +): Session { const sampled = getSessionSampleType(sessionSampleRate, allowBuffering); const session = makeSession({ sampled, + previousSessionId, }); if (stickySession) { diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts deleted file mode 100644 index da3184f05296..000000000000 --- a/packages/replay/src/session/getSession.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Session, SessionOptions, Timeouts } from '../types'; -import { isSessionExpired } from '../util/isSessionExpired'; -import { logInfoNextTick } from '../util/log'; -import { createSession } from './createSession'; -import { fetchSession } from './fetchSession'; -import { makeSession } from './Session'; - -interface GetSessionParams extends SessionOptions { - timeouts: Timeouts; - - /** - * The current session (e.g. if stickySession is off) - */ - currentSession?: Session; - - traceInternals?: boolean; -} - -/** - * Get or create a session - */ -export function getSession({ - timeouts, - currentSession, - stickySession, - sessionSampleRate, - allowBuffering, - traceInternals, -}: GetSessionParams): { type: 'new' | 'saved'; session: Session } { - // If session exists and is passed, use it instead of always hitting session storage - const session = currentSession || (stickySession && fetchSession(traceInternals)); - - if (session) { - // If there is a session, check if it is valid (e.g. "last activity" time - // should be within the "session idle time", and "session started" time is - // within "max session time"). - const isExpired = isSessionExpired(session, timeouts); - - if (!isExpired || (allowBuffering && session.shouldRefresh)) { - return { type: 'saved', session }; - } else if (!session.shouldRefresh) { - // This is the case if we have an error session that is completed - // (=triggered an error). Session will continue as session-based replay, - // and when this session is expired, it will not be renewed until user - // reloads. - const discardedSession = makeSession({ sampled: false }); - logInfoNextTick('[Replay] Session should not be refreshed', traceInternals); - return { type: 'new', session: discardedSession }; - } else { - logInfoNextTick('[Replay] Session has expired', traceInternals); - } - // Otherwise continue to create a new session - } - - const newSession = createSession({ - stickySession, - sessionSampleRate, - allowBuffering, - }); - logInfoNextTick('[Replay] Created new session', traceInternals); - - return { type: 'new', session: newSession }; -} diff --git a/packages/replay/src/session/loadOrCreateSession.ts b/packages/replay/src/session/loadOrCreateSession.ts new file mode 100644 index 000000000000..9695eef56102 --- /dev/null +++ b/packages/replay/src/session/loadOrCreateSession.ts @@ -0,0 +1,32 @@ +import type { Session, SessionOptions, Timeouts } from '../types'; +import { logInfoNextTick } from '../util/log'; +import { createSession } from './createSession'; +import { fetchSession } from './fetchSession'; +import { maybeRefreshSession } from './maybeRefreshSession'; + +/** + * Get or create a session, when initializing the replay. + * Returns a session that may be unsampled. + */ +export function loadOrCreateSession( + currentSession: Session | undefined, + { + timeouts, + traceInternals, + }: { + timeouts: Timeouts; + traceInternals?: boolean; + }, + sessionOptions: SessionOptions, +): Session { + // If session exists and is passed, use it instead of always hitting session storage + const existingSession = currentSession || (sessionOptions.stickySession && fetchSession(traceInternals)); + + // No session exists yet, just create a new one + if (!existingSession) { + logInfoNextTick('[Replay] Created new session', traceInternals); + return createSession(sessionOptions); + } + + return maybeRefreshSession(existingSession, { timeouts, traceInternals }, sessionOptions); +} diff --git a/packages/replay/src/session/maybeRefreshSession.ts b/packages/replay/src/session/maybeRefreshSession.ts new file mode 100644 index 000000000000..51e4925d074d --- /dev/null +++ b/packages/replay/src/session/maybeRefreshSession.ts @@ -0,0 +1,48 @@ +import type { Session, SessionOptions, Timeouts } from '../types'; +import { isSessionExpired } from '../util/isSessionExpired'; +import { logInfoNextTick } from '../util/log'; +import { createSession } from './createSession'; +import { makeSession } from './Session'; + +/** + * Check a session, and either return it or a refreshed version of it. + * The refreshed version may be unsampled. + * You can check if the session has changed by comparing the session IDs. + */ +export function maybeRefreshSession( + session: Session, + { + timeouts, + traceInternals, + }: { + timeouts: Timeouts; + traceInternals?: boolean; + }, + sessionOptions: SessionOptions, +): Session { + // If not expired, all good, just keep the session + if (!isSessionExpired(session, timeouts)) { + return session; + } + + const isBuffering = session.sampled === 'buffer'; + + // If we are buffering & the session may be refreshed, just return it + if (isBuffering && session.shouldRefresh) { + return session; + } + + // If we are buffering & the session may not be refreshed (=it was converted to session previously already) + // We return an unsampled new session + if (isBuffering) { + logInfoNextTick('[Replay] Session should not be refreshed', traceInternals); + return makeSession({ sampled: false }); + } + + // Else, we are not buffering, and the session is expired, so we need to create a new one + logInfoNextTick('[Replay] Session has expired, creating new one...', traceInternals); + + const newSession = createSession(sessionOptions, { previousSessionId: session.id }); + + return newSession; +} diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts index 6bf33f182e66..4059b71fe195 100644 --- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -84,7 +84,8 @@ describe('Integration | beforeAddRecordingEvent', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockSendReplayRequest.mockClear(); }); @@ -94,7 +95,6 @@ describe('Integration | beforeAddRecordingEvent', () => { await new Promise(process.nextTick); jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 777cb437f7e3..e56edae0f723 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -295,10 +295,11 @@ describe('Integration | errorSampleRate', () => { 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. + // `_initializeSessionForSampling` is not called when initializing the plugin. replay.stop(); replay['_options']['errorSampleRate'] = 0; - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); jest.runAllTimers(); await new Promise(process.nextTick); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index b95faffa59da..c90f8ceed125 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -40,7 +40,8 @@ describe('Integration | events', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockTransportSend.mockClear(); }); @@ -93,7 +94,8 @@ describe('Integration | events', () => { it('has correct timestamps when there are events earlier than initial timestamp', async function () { clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockTransportSend.mockClear(); Object.defineProperty(document, 'visibilityState', { configurable: true, diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index 29ce2ba527fd..6f2d3b7d8ccd 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -85,7 +85,8 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); if (replay.eventBuffer) { jest.spyOn(replay.eventBuffer, 'finish'); @@ -276,7 +277,8 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); // click happens first domHandler({ @@ -307,10 +309,12 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); - // No-op _loadAndCheckSession to avoid us resetting the session for this test - const _tmp = replay['_loadAndCheckSession']; - replay['_loadAndCheckSession'] = () => { + replay['_initializeSessionForSampling'](); + replay.setInitialState(); + + // No-op _checkSession to avoid us resetting the session for this test + const _tmp = replay['_checkSession']; + replay['_checkSession'] = () => { return true; }; @@ -331,7 +335,7 @@ describe('Integration | flush', () => { expect(mockSendReplay).toHaveBeenCalledTimes(0); replay.timeouts.maxSessionLife = MAX_SESSION_LIFE; - replay['_loadAndCheckSession'] = _tmp; + replay['_checkSession'] = _tmp; }); it('logs warning if flushing initial segment without checkout', async () => { @@ -339,7 +343,8 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); await new Promise(process.nextTick); jest.setSystemTime(BASE_TIMESTAMP); @@ -399,7 +404,8 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); await new Promise(process.nextTick); jest.setSystemTime(BASE_TIMESTAMP); @@ -454,7 +460,8 @@ describe('Integration | flush', () => { sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); await new Promise(process.nextTick); jest.setSystemTime(BASE_TIMESTAMP); diff --git a/packages/replay/test/integration/rateLimiting.test.ts b/packages/replay/test/integration/rateLimiting.test.ts index 723dc682d100..291a95c4f94e 100644 --- a/packages/replay/test/integration/rateLimiting.test.ts +++ b/packages/replay/test/integration/rateLimiting.test.ts @@ -46,7 +46,8 @@ describe('Integration | rate-limiting behaviour', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockSendReplayRequest.mockClear(); }); @@ -57,7 +58,6 @@ describe('Integration | rate-limiting behaviour', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); jest.clearAllMocks(); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts index d7a9974bcaa9..d6f26db6653c 100644 --- a/packages/replay/test/integration/sendReplayEvent.test.ts +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -59,7 +59,8 @@ describe('Integration | sendReplayEvent', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockSendReplayRequest.mockClear(); }); @@ -69,7 +70,6 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 304059659078..6e62b71ca09c 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -424,7 +424,8 @@ describe('Integration | session', () => { it('increases segment id after each event', async () => { clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); Object.defineProperty(document, 'visibilityState', { configurable: true, diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index cc0e28195244..a88c5de6a839 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -52,7 +52,8 @@ describe('Integration | stop', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSessionForSampling'](); + replay.setInitialState(); mockRecord.takeFullSnapshot.mockClear(); mockAddInstrumentationHandler.mockClear(); Object.defineProperty(WINDOW, 'location', { diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts deleted file mode 100644 index aa3110d114f2..000000000000 --- a/packages/replay/test/unit/session/getSession.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - MAX_SESSION_LIFE, - SESSION_IDLE_EXPIRE_DURATION, - SESSION_IDLE_PAUSE_DURATION, - WINDOW, -} from '../../../src/constants'; -import * as CreateSession from '../../../src/session/createSession'; -import * as FetchSession from '../../../src/session/fetchSession'; -import { getSession } from '../../../src/session/getSession'; -import { saveSession } from '../../../src/session/saveSession'; -import { makeSession } from '../../../src/session/Session'; - -jest.mock('@sentry/utils', () => { - return { - ...(jest.requireActual('@sentry/utils') as { string: unknown }), - uuid4: jest.fn(() => 'test_session_uuid'), - }; -}); - -const SAMPLE_OPTIONS = { - sessionSampleRate: 1.0, - allowBuffering: false, -}; - -function createMockSession(when: number = Date.now()) { - return makeSession({ - id: 'test_session_id', - segmentId: 0, - lastActivity: when, - started: when, - sampled: 'session', - shouldRefresh: true, - }); -} - -describe('Unit | session | getSession', () => { - beforeAll(() => { - jest.spyOn(CreateSession, 'createSession'); - jest.spyOn(FetchSession, 'fetchSession'); - WINDOW.sessionStorage.clear(); - }); - - afterEach(() => { - WINDOW.sessionStorage.clear(); - (CreateSession.createSession as jest.MockedFunction).mockClear(); - (FetchSession.fetchSession as jest.MockedFunction).mockClear(); - }); - - it('creates a non-sticky session when one does not exist', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - - // Should not have anything in storage - expect(FetchSession.fetchSession()).toBe(null); - }); - - it('creates a non-sticky session, regardless of session existing in sessionStorage', function () { - saveSession(createMockSession(Date.now() - 10000)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toBeDefined(); - }); - - it('creates a non-sticky session, when one is expired', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'old_session_id', - lastActivity: Date.now() - 1001, - started: Date.now() - 1001, - segmentId: 0, - sampled: 'session', - }), - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toBeDefined(); - expect(session.id).not.toBe('old_session_id'); - }); - - it('creates a sticky session when one does not exist', function () { - expect(FetchSession.fetchSession()).toBe(null); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - sessionSampleRate: 1.0, - allowBuffering: false, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - - // Should not have anything in storage - expect(FetchSession.fetchSession()).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - }); - - it('fetches an existing sticky session', function () { - const now = Date.now(); - saveSession(createMockSession(now)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - sessionSampleRate: 1.0, - allowBuffering: false, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: now, - sampled: 'session', - started: now, - shouldRefresh: true, - }); - }); - - it('fetches an expired sticky session', function () { - const now = Date.now(); - saveSession(createMockSession(Date.now() - 2000)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session.id).toBe('test_session_uuid'); - expect(session.lastActivity).toBeGreaterThanOrEqual(now); - expect(session.started).toBeGreaterThanOrEqual(now); - expect(session.segmentId).toBe(0); - }); - - it('fetches a non-expired non-sticky session', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - 500, - started: +new Date() - 500, - segmentId: 0, - sampled: 'session', - }), - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(session.id).toBe('test_session_uuid_2'); - expect(session.segmentId).toBe(0); - }); - - it('re-uses the same "buffer" session if it is expired and has never sent a buffered replay', function () { - const { type, session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - MAX_SESSION_LIFE - 1, - started: +new Date() - MAX_SESSION_LIFE - 1, - segmentId: 0, - sampled: 'buffer', - }), - allowBuffering: true, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(type).toBe('saved'); - expect(session.id).toBe('test_session_uuid_2'); - expect(session.sampled).toBe('buffer'); - expect(session.segmentId).toBe(0); - }); - - it('creates a new session if it is expired and it was a "buffer" session that has sent a replay', function () { - const currentSession = makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - MAX_SESSION_LIFE - 1, - started: +new Date() - MAX_SESSION_LIFE - 1, - segmentId: 0, - sampled: 'buffer', - }); - currentSession.shouldRefresh = false; - - const { type, session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession, - allowBuffering: true, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(type).toBe('new'); - expect(session.id).not.toBe('test_session_uuid_2'); - expect(session.sampled).toBe(false); - expect(session.segmentId).toBe(0); - }); -}); diff --git a/packages/replay/test/unit/session/loadOrCreateSession.test.ts b/packages/replay/test/unit/session/loadOrCreateSession.test.ts new file mode 100644 index 000000000000..907e078c75d3 --- /dev/null +++ b/packages/replay/test/unit/session/loadOrCreateSession.test.ts @@ -0,0 +1,396 @@ +import { + MAX_SESSION_LIFE, + SESSION_IDLE_EXPIRE_DURATION, + SESSION_IDLE_PAUSE_DURATION, + WINDOW, +} from '../../../src/constants'; +import * as CreateSession from '../../../src/session/createSession'; +import * as FetchSession from '../../../src/session/fetchSession'; +import { loadOrCreateSession } from '../../../src/session/loadOrCreateSession'; +import { saveSession } from '../../../src/session/saveSession'; +import { makeSession } from '../../../src/session/Session'; +import type { SessionOptions, Timeouts } from '../../../src/types'; + +jest.mock('@sentry/utils', () => { + return { + ...(jest.requireActual('@sentry/utils') as { string: unknown }), + uuid4: jest.fn(() => 'test_session_uuid'), + }; +}); + +const SAMPLE_OPTIONS: SessionOptions = { + stickySession: false, + sessionSampleRate: 1.0, + allowBuffering: false, +}; + +const timeouts: Timeouts = { + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, + maxSessionLife: MAX_SESSION_LIFE, +}; + +function createMockSession(when: number = Date.now(), id = 'test_session_id') { + return makeSession({ + id, + segmentId: 0, + lastActivity: when, + started: when, + sampled: 'session', + shouldRefresh: true, + }); +} + +describe('Unit | session | loadOrCreateSession', () => { + beforeAll(() => { + jest.spyOn(CreateSession, 'createSession'); + jest.spyOn(FetchSession, 'fetchSession'); + WINDOW.sessionStorage.clear(); + }); + + afterEach(() => { + WINDOW.sessionStorage.clear(); + (CreateSession.createSession as jest.MockedFunction).mockClear(); + (FetchSession.fetchSession as jest.MockedFunction).mockClear(); + }); + + describe('stickySession: false', () => { + it('creates new session if none is passed in', function () { + const session = loadOrCreateSession( + undefined, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + stickySession: false, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + expect(session).toEqual({ + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + shouldRefresh: true, + }); + + // Should not have anything in storage + expect(FetchSession.fetchSession()).toBe(null); + }); + + it('creates new session, even if something is in sessionStorage', function () { + const sessionInStorage = createMockSession(Date.now() - 10000, 'test_old_session_uuid'); + saveSession(sessionInStorage); + + const session = loadOrCreateSession( + undefined, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + stickySession: false, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + expect(session).toEqual({ + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + shouldRefresh: true, + }); + + // Should not have anything in storage + expect(FetchSession.fetchSession()).toEqual(sessionInStorage); + }); + + it('uses passed in session', function () { + const now = Date.now(); + const currentSession = createMockSession(now - 2000); + + const session = loadOrCreateSession( + currentSession, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + stickySession: false, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + }); + + describe('stickySession: true', () => { + it('creates new session if none exists', function () { + const session = loadOrCreateSession( + undefined, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + stickySession: true, + }, + ); + + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + const expectedSession = { + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + shouldRefresh: true, + }; + expect(session).toEqual(expectedSession); + + // Should also be stored in storage + expect(FetchSession.fetchSession()).toEqual(expectedSession); + }); + + it('creates new session if session in sessionStorage is expired', function () { + const now = Date.now(); + const date = now - 2000; + saveSession(createMockSession(date, 'test_old_session_uuid')); + + const session = loadOrCreateSession( + undefined, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + stickySession: true, + }, + ); + + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + const expectedSession = { + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + shouldRefresh: true, + previousSessionId: 'test_old_session_uuid', + }; + expect(session).toEqual(expectedSession); + expect(session.lastActivity).toBeGreaterThanOrEqual(now); + expect(session.started).toBeGreaterThanOrEqual(now); + expect(FetchSession.fetchSession()).toEqual(expectedSession); + }); + + it('returns session from sessionStorage if not expired', function () { + const date = Date.now() - 2000; + saveSession(createMockSession(date, 'test_old_session_uuid')); + + const session = loadOrCreateSession( + undefined, + { + timeouts: { ...timeouts, sessionIdleExpire: 5000 }, + }, + { + ...SAMPLE_OPTIONS, + stickySession: true, + }, + ); + + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual({ + id: 'test_old_session_uuid', + segmentId: 0, + lastActivity: date, + sampled: 'session', + started: date, + shouldRefresh: true, + }); + }); + + it('uses passed in session instead of fetching from sessionStorage', function () { + const now = Date.now(); + saveSession(createMockSession(now - 10000, 'test_storage_session_uuid')); + const currentSession = createMockSession(now - 2000); + + const session = loadOrCreateSession( + currentSession, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + stickySession: true, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + }); + + describe('buffering', () => { + it('returns current session when buffering, even if expired', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: true, + }); + + const session = loadOrCreateSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + + it('returns new unsampled session when buffering & expired, if shouldRefresh===false', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: false, + }); + + const session = loadOrCreateSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).not.toEqual(currentSession); + expect(session.sampled).toBe(false); + expect(session.started).toBeGreaterThanOrEqual(now); + }); + + it('returns existing session when buffering & not expired, if shouldRefresh===false', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: false, + }); + + const session = loadOrCreateSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 5000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + }); + + describe('sampling', () => { + it('returns unsampled session if sample rates are 0', function () { + const session = loadOrCreateSession( + undefined, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 0, + allowBuffering: false, + }, + ); + + const expectedSession = { + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: false, + started: expect.any(Number), + shouldRefresh: true, + }; + expect(session).toEqual(expectedSession); + }); + + it('returns `session` session if sessionSampleRate===1', function () { + const session = loadOrCreateSession( + undefined, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 1.0, + allowBuffering: false, + }, + ); + + expect(session.sampled).toBe('session'); + }); + + it('returns `buffer` session if allowBuffering===true', function () { + const session = loadOrCreateSession( + undefined, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 0.0, + allowBuffering: true, + }, + ); + + expect(session.sampled).toBe('buffer'); + }); + }); +}); diff --git a/packages/replay/test/unit/session/maybeRefreshSession.test.ts b/packages/replay/test/unit/session/maybeRefreshSession.test.ts new file mode 100644 index 000000000000..5bcc8bf4481c --- /dev/null +++ b/packages/replay/test/unit/session/maybeRefreshSession.test.ts @@ -0,0 +1,266 @@ +import { + MAX_SESSION_LIFE, + SESSION_IDLE_EXPIRE_DURATION, + SESSION_IDLE_PAUSE_DURATION, + WINDOW, +} from '../../../src/constants'; +import * as CreateSession from '../../../src/session/createSession'; +import { maybeRefreshSession } from '../../../src/session/maybeRefreshSession'; +import { makeSession } from '../../../src/session/Session'; +import type { SessionOptions, Timeouts } from '../../../src/types'; + +jest.mock('@sentry/utils', () => { + return { + ...(jest.requireActual('@sentry/utils') as { string: unknown }), + uuid4: jest.fn(() => 'test_session_uuid'), + }; +}); + +const SAMPLE_OPTIONS: SessionOptions = { + stickySession: false, + sessionSampleRate: 1.0, + allowBuffering: false, +}; + +const timeouts: Timeouts = { + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, + maxSessionLife: MAX_SESSION_LIFE, +}; + +function createMockSession(when: number = Date.now(), id = 'test_session_id') { + return makeSession({ + id, + segmentId: 0, + lastActivity: when, + started: when, + sampled: 'session', + shouldRefresh: true, + }); +} + +describe('Unit | session | maybeRefreshSession', () => { + beforeAll(() => { + jest.spyOn(CreateSession, 'createSession'); + }); + + afterEach(() => { + WINDOW.sessionStorage.clear(); + (CreateSession.createSession as jest.MockedFunction).mockClear(); + }); + + it('returns session if not expired', function () { + const now = Date.now(); + const currentSession = createMockSession(now - 2000); + + const session = maybeRefreshSession( + currentSession, + { + timeouts, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + + it('creates new session if expired', function () { + const now = Date.now(); + const currentSession = createMockSession(now - 2000, 'test_old_session_uuid'); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(CreateSession.createSession).toHaveBeenCalled(); + + expect(session).not.toEqual(currentSession); + const expectedSession = { + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + shouldRefresh: true, + previousSessionId: 'test_old_session_uuid', + }; + expect(session).toEqual(expectedSession); + expect(session.lastActivity).toBeGreaterThanOrEqual(now); + expect(session.started).toBeGreaterThanOrEqual(now); + }); + + describe('buffering', () => { + it('returns session when buffering, even if expired', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: true, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + + it('returns new unsampled session when buffering & expired, if shouldRefresh===false', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: false, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).not.toEqual(currentSession); + expect(session.sampled).toBe(false); + expect(session.started).toBeGreaterThanOrEqual(now); + }); + + it('returns existing session when buffering & not expired, if shouldRefresh===false', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'buffer', + shouldRefresh: false, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 5000 }, + }, + { + ...SAMPLE_OPTIONS, + }, + ); + + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session).toEqual(currentSession); + }); + }); + + describe('sampling', () => { + it('creates unsampled session if sample rates are 0', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'session', + shouldRefresh: true, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 0, + allowBuffering: false, + }, + ); + + expect(session.id).toBe('test_session_uuid'); + expect(session.sampled).toBe(false); + }); + + it('creates `session` session if sessionSampleRate===1', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'session', + shouldRefresh: true, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 1.0, + allowBuffering: false, + }, + ); + + expect(session.id).toBe('test_session_uuid'); + expect(session.sampled).toBe('session'); + }); + + it('creates `buffer` session if allowBuffering===true', function () { + const now = Date.now(); + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: now - 2000, + started: now - 2000, + segmentId: 0, + sampled: 'session', + shouldRefresh: true, + }); + + const session = maybeRefreshSession( + currentSession, + { + timeouts: { ...timeouts, sessionIdleExpire: 1000 }, + }, + { + ...SAMPLE_OPTIONS, + sessionSampleRate: 0.0, + allowBuffering: true, + }, + ); + + expect(session.id).toBe('test_session_uuid'); + expect(session.sampled).toBe('buffer'); + }); + }); +}); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index 02a965b7d9c2..cb70c85bbe54 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -42,8 +42,8 @@ export function setupReplayContainer({ }); clearSession(replay); + replay['_initializeSessionForSampling'](); replay.setInitialState(); - replay['_loadAndCheckSession'](); replay['_isEnabled'] = true; replay.eventBuffer = createEventBuffer({ useCompression: options?.useCompression || false, diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 99730ac8dac1..f7a195aba4e8 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -50,4 +50,7 @@ export { Handlers, Integrations, setMeasurement, + getActiveSpan, + startActiveSpan, + startSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index a8841d5a02c2..7a62ede5ed0f 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -27,7 +27,7 @@ "@sentry/svelte": "7.64.0", "@sentry/types": "7.64.0", "@sentry/utils": "7.64.0", - "@sentry/vite-plugin": "^0.6.0", + "@sentry/vite-plugin": "^0.6.1", "magicast": "0.2.8", "sorcery": "0.11.0" }, @@ -44,7 +44,7 @@ "build:types": "tsc -p tsconfig.types.json", "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", + "build:transpile:watch": "rollup -c rollup.npm.config.js --bundleConfigAsCjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "ts-node ../../scripts/prepack.ts && npm pack ./build", "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts", diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index e12ed19a6cae..44430a3b9b1c 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -1,23 +1,10 @@ -import { addTracingHeadersToFetchRequest } from '@sentry-internal/tracing'; -import type { BaseClient } from '@sentry/core'; -import { getCurrentHub, trace } from '@sentry/core'; -import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte'; +import { trace } from '@sentry/core'; import { captureException } from '@sentry/svelte'; -import type { Client, ClientOptions, SanitizedRequestData, Span } from '@sentry/types'; -import { - addExceptionMechanism, - addNonEnumerableProperty, - getSanitizedUrlString, - objectify, - parseFetchArgs, - parseUrl, - stringMatchesSomePattern, -} from '@sentry/utils'; +import { addExceptionMechanism, addNonEnumerableProperty, objectify } from '@sentry/utils'; 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; @@ -80,7 +67,6 @@ export function wrapLoadWithSentry any>(origLoad: T) const patchedEvent: PatchedLoadEvent = { ...event, - fetch: instrumentSvelteKitFetch(event.fetch), }; addNonEnumerableProperty(patchedEvent as unknown as Record, '__sentry_wrapped__', true); @@ -101,182 +87,3 @@ export function wrapLoadWithSentry any>(origLoad: T) }, }); } - -type SvelteKitFetch = LoadEvent['fetch']; - -/** - * Instruments SvelteKit's client `fetch` implementation which is passed to the client-side universal `load` functions. - * - * We need to instrument this in addition to the native fetch we instrument in BrowserTracing because SvelteKit - * stores the native fetch implementation before our SDK is initialized. - * - * see: https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/client/fetcher.js - * - * This instrumentation takes the fetch-related options from `BrowserTracing` to determine if we should - * instrument fetch for perfomance monitoring, create a span for or attach our tracing headers to the given request. - * - * To dertermine if breadcrumbs should be recorded, this instrumentation relies on the availability of and the options - * set in the `BreadCrumbs` integration. - * - * @param originalFetch SvelteKit's original fetch implemenetation - * - * @returns a proxy of SvelteKit's fetch implementation - */ -function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch { - const client = getCurrentHub().getClient(); - - if (!isValidClient(client)) { - return originalFetch; - } - - const options = client.getOptions(); - - const browserTracingIntegration = client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined; - const breadcrumbsIntegration = client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined; - - const browserTracingOptions = browserTracingIntegration && browserTracingIntegration.options; - - const shouldTraceFetch = browserTracingOptions && browserTracingOptions.traceFetch; - const shouldAddFetchBreadcrumb = breadcrumbsIntegration && breadcrumbsIntegration.options.fetch; - - /* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */ - const shouldCreateSpan = - browserTracingOptions && typeof browserTracingOptions.shouldCreateSpanForRequest === 'function' - ? browserTracingOptions.shouldCreateSpanForRequest - : (_: string) => shouldTraceFetch; - - /* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */ - const shouldAttachHeaders: (url: string) => boolean = url => { - return ( - !!shouldTraceFetch && - stringMatchesSomePattern( - url, - options.tracePropagationTargets || browserTracingOptions.tracePropagationTargets || ['localhost', /^\//], - ) - ); - }; - - 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) - if (rawUrl.match(/sentry_key/)) { - // We don't create spans or breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests) - return wrappingTarget.apply(thisArg, args); - } - - const urlObject = parseUrl(rawUrl); - - const requestData: SanitizedRequestData = { - url: getSanitizedUrlString(urlObject), - 'http.method': method, - ...(urlObject.search && { 'http.query': urlObject.search.substring(1) }), - ...(urlObject.hash && { 'http.hash': urlObject.hash.substring(1) }), - }; - - const patchedInit: RequestInit = { ...init }; - const hub = getCurrentHub(); - const scope = hub.getScope(); - const client = hub.getClient(); - - let fetchPromise: Promise; - - function callFetchTarget(span?: Span): Promise { - if (client && shouldAttachHeaders(rawUrl)) { - const headers = addTracingHeadersToFetchRequest(input as string | Request, client, scope, patchedInit, span); - patchedInit.headers = headers as HeadersInit; - } - const patchedFetchArgs = [input, patchedInit]; - return wrappingTarget.apply(thisArg, patchedFetchArgs); - } - - if (shouldCreateSpan(rawUrl)) { - fetchPromise = trace( - { - name: `${method} ${requestData.url}`, // this will become the description of the span - op: 'http.client', - data: requestData, - }, - span => { - const promise = callFetchTarget(span); - if (span) { - promise.then(res => span.setHttpStatus(res.status)).catch(_ => span.setStatus('internal_error')); - } - return promise; - }, - ); - } else { - fetchPromise = callFetchTarget(); - } - - if (shouldAddFetchBreadcrumb) { - addFetchBreadcrumb(fetchPromise, requestData, args); - } - - return fetchPromise; - }, - }); -} - -/* Adds a breadcrumb for the given fetch result */ -function addFetchBreadcrumb( - fetchResult: Promise, - requestData: SanitizedRequestData, - args: Parameters, -): void { - const breadcrumbStartTimestamp = Date.now(); - fetchResult.then( - response => { - getCurrentHub().addBreadcrumb( - { - type: 'http', - category: 'fetch', - data: { - ...requestData, - status_code: response.status, - }, - }, - { - input: args, - response, - startTimestamp: breadcrumbStartTimestamp, - endTimestamp: Date.now(), - }, - ); - }, - error => { - getCurrentHub().addBreadcrumb( - { - type: 'http', - category: 'fetch', - level: 'error', - data: requestData, - }, - { - input: args, - data: error, - startTimestamp: breadcrumbStartTimestamp, - endTimestamp: Date.now(), - }, - ); - }, - ); -} - -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/sdk.ts b/packages/sveltekit/src/client/sdk.ts index 9bf1d2cb140b..c399a4a2ad02 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -1,11 +1,15 @@ import { hasTracingEnabled } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; -import { BrowserTracing, configureScope, init as initSvelteSdk } from '@sentry/svelte'; +import { BrowserTracing, configureScope, init as initSvelteSdk, WINDOW } from '@sentry/svelte'; import { addOrUpdateIntegration } from '@sentry/utils'; import { applySdkMetadata } from '../common/metadata'; import { svelteKitRoutingInstrumentation } from './router'; +type WindowWithSentryFetchProxy = typeof WINDOW & { + _sentryFetchProxy?: typeof fetch; +}; + // Treeshakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -19,8 +23,17 @@ export function init(options: BrowserOptions): void { addClientIntegrations(options); + // 1. Switch window.fetch to our fetch proxy we injected earlier + const actualFetch = switchToFetchProxy(); + + // 2. Initialize the SDK which will instrument our proxy initSvelteSdk(options); + // 3. Restore the original fetch now that our proxy is instrumented + if (actualFetch) { + restoreFetch(actualFetch); + } + configureScope(scope => { scope.setTag('runtime', 'browser'); }); @@ -45,3 +58,43 @@ function addClientIntegrations(options: BrowserOptions): void { options.integrations = integrations; } + +/** + * During server-side page load, we injected a + `; return html.replace('', content); } diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 96f43cc9f7f9..f7c0b99f6301 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -45,6 +45,9 @@ export { Integrations, Handlers, setMeasurement, + getActiveSpan, + startActiveSpan, + startSpan, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts index 0ad28e1cb4eb..04d0137062c6 100644 --- a/packages/sveltekit/src/server/load.ts +++ b/packages/sveltekit/src/server/load.ts @@ -119,7 +119,11 @@ export function wrapServerLoadWithSentry any>(origSe addNonEnumerableProperty(event as unknown as Record, '__sentry_wrapped__', true); - const routeId = event.route && event.route.id; + // Accessing any member of `event.route` causes SvelteKit to invalidate the + // server `load` function's data on every route change. + // To work around this, we use `Object.getOwnPropertyDescriptor` which doesn't invoke the proxy. + // https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/server/page/load_data.js#L111C3-L124 + const routeId = event.route && (Object.getOwnPropertyDescriptor(event.route, 'id')?.value as string | undefined); const { dynamicSamplingContext, traceparentData, propagationContext } = getTracePropagationData(event); getCurrentHub().getScope().setPropagationContext(propagationContext); diff --git a/packages/sveltekit/test/client/fetch.test.ts b/packages/sveltekit/test/client/fetch.test.ts new file mode 100644 index 000000000000..a97478cc86e8 --- /dev/null +++ b/packages/sveltekit/test/client/fetch.test.ts @@ -0,0 +1,55 @@ +import { init } from '../../src/client/index'; + +describe('instruments fetch', () => { + beforeEach(() => { + // For the happy path, we can assume that both fetch and the fetch proxy are set + // We test the edge cases in the other tests below + + // @ts-expect-error this fine just for the test + globalThis.fetch = () => Promise.resolve('fetch'); + + globalThis._sentryFetchProxy = () => Promise.resolve('_sentryFetchProxy'); + // small hack to make `supportsNativeFetch` return true + globalThis._sentryFetchProxy.toString = () => 'function fetch() { [native code] }'; + }); + + it('correctly swaps and instruments window._sentryFetchProxy', async () => { + // We expect init to swap window.fetch with our fetch proxy so that the proxy is instrumented + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + }); + + // fetch proxy was instrumented + expect(globalThis._sentryFetchProxy['__sentry_original__']).toBeDefined(); + + // in the end, fetch and fetch proxy were restored correctly + expect(await globalThis.fetch('')).toEqual('fetch'); + expect(await globalThis._sentryFetchProxy()).toEqual('_sentryFetchProxy'); + }); + + it("doesn't swap fetch if the fetch proxy doesn't exist", async () => { + delete globalThis._sentryFetchProxy; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + }); + + expect(await globalThis.fetch('')).toEqual('fetch'); + expect(globalThis._sentryFetchProxy).toBeUndefined(); + }); + + it("doesn't swap fetch if global fetch doesn't exist", async () => { + // @ts-expect-error this fine just for the test + delete globalThis.fetch; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + }); + + expect(await globalThis._sentryFetchProxy()).toEqual('_sentryFetchProxy'); + expect(globalThis._sentryFetchProxy['__sentry_original__']).toBeUndefined(); + }); +}); diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index 6373ea1ff571..01c2377ddbf2 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -1,17 +1,10 @@ import { addTracingExtensions, Scope } from '@sentry/svelte'; -import { baggageHeaderToDynamicSamplingContext } from '@sentry/utils'; import type { Load } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { vi } from 'vitest'; import { wrapLoadWithSentry } from '../../src/client/load'; -const SENTRY_TRACE_HEADER = '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; -const BAGGAGE_HEADER = - 'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=dogpark,' + - 'sentry-user_segment=segmentA,sentry-public_key=dogsarebadatkeepingsecrets,' + - 'sentry-trace_id=1234567890abcdef1234567890abcdef,sentry-sample_rate=1'; - const mockCaptureException = vi.fn(); let mockScope = new Scope(); @@ -27,44 +20,8 @@ vi.mock('@sentry/svelte', async () => { }; }); -vi.mock('../../src/client/vendor/lookUpCache', () => { - return { - isRequestCached: () => false, - }; -}); - const mockTrace = vi.fn(); -const mockedBrowserTracing = { - options: { - tracePropagationTargets: ['example.com', /^\\/], - traceFetch: true, - shouldCreateSpanForRequest: undefined as undefined | (() => boolean), - }, -}; - -const mockedBreadcrumbs = { - options: { - fetch: true, - }, -}; - -const mockedGetIntegrationById = vi.fn(id => { - if (id === 'BrowserTracing') { - return mockedBrowserTracing; - } else if (id === 'Breadcrumbs') { - return mockedBreadcrumbs; - } - return undefined; -}); - -const mockedGetClient = vi.fn(() => { - return { - getIntegrationById: mockedGetIntegrationById, - getOptions: () => ({}), - }; -}); - vi.mock('@sentry/core', async () => { const original = (await vi.importActual('@sentry/core')) as any; return { @@ -73,38 +30,10 @@ vi.mock('@sentry/core', async () => { mockTrace(...args); return original.trace(...args); }, - getCurrentHub: () => { - return { - getClient: mockedGetClient, - getScope: () => { - return { - getPropagationContext: () => ({ - traceId: '1234567890abcdef1234567890abcdef', - spanId: '1234567890abcdef', - sampled: false, - }), - getSpan: () => { - return { - transaction: { - getDynamicSamplingContext: () => { - return baggageHeaderToDynamicSamplingContext(BAGGAGE_HEADER); - }, - }, - toTraceparent: () => { - return SENTRY_TRACE_HEADER; - }, - }; - }, - }; - }, - addBreadcrumb: mockedAddBreadcrumb, - }; - }, }; }); const mockAddExceptionMechanism = vi.fn(); -const mockedAddBreadcrumb = vi.fn(); vi.mock('@sentry/utils', async () => { const original = (await vi.importActual('@sentry/utils')) as any; @@ -118,30 +47,12 @@ function getById(_id?: string) { throw new Error('error'); } -const mockedSveltekitFetch = vi.fn().mockReturnValue(Promise.resolve({ status: 200 })); - const MOCK_LOAD_ARGS: any = { params: { id: '123' }, route: { id: '/users/[id]', }, url: new URL('http://localhost:3000/users/123'), - request: { - headers: { - get: (key: string) => { - if (key === 'sentry-trace') { - return SENTRY_TRACE_HEADER; - } - - if (key === 'baggage') { - return BAGGAGE_HEADER; - } - - return null; - }, - }, - }, - fetch: mockedSveltekitFetch, }; beforeAll(() => { @@ -153,9 +64,6 @@ describe('wrapLoadWithSentry', () => { mockCaptureException.mockClear(); mockAddExceptionMechanism.mockClear(); mockTrace.mockClear(); - mockedGetIntegrationById.mockClear(); - mockedSveltekitFetch.mockClear(); - mockedAddBreadcrumb.mockClear(); mockScope = new Scope(); }); @@ -211,253 +119,35 @@ describe('wrapLoadWithSentry', () => { ); }); - describe.each([ - [ - 'fetch call with fragment and params', - ['example.com/api/users/?id=123#testfragment'], - { - op: 'http.client', - name: 'GET example.com/api/users/', - data: { - 'http.method': 'GET', - url: 'example.com/api/users/', - 'http.hash': 'testfragment', - 'http.query': 'id=123', - }, - }, - ], - [ - 'fetch call with options object', - ['example.com/api/users/?id=123#testfragment', { method: 'POST' }], - { - op: 'http.client', - name: 'POST example.com/api/users/', - data: { - 'http.method': 'POST', - url: 'example.com/api/users/', - 'http.hash': 'testfragment', - 'http.query': 'id=123', - }, - }, - ], - [ - 'fetch call with custom headers in options ', - ['example.com/api/users/?id=123#testfragment', { method: 'POST', headers: { 'x-my-header': 'some value' } }], - { - op: 'http.client', - name: 'POST example.com/api/users/', - data: { - 'http.method': 'POST', - url: 'example.com/api/users/', - 'http.hash': 'testfragment', - 'http.query': 'id=123', - }, - }, - ], - [ - 'fetch call with a Request object ', - [{ url: '/api/users?id=123', headers: { 'x-my-header': 'value' } } as unknown as Request], - { - op: 'http.client', - name: 'GET /api/users', - data: { - 'http.method': 'GET', - url: '/api/users', - 'http.query': 'id=123', - }, - }, - ], - ])('instruments fetch (%s)', (_, originalFetchArgs, spanCtx) => { - beforeEach(() => { - mockedBrowserTracing.options = { - tracePropagationTargets: ['example.com', /^\//], - traceFetch: true, - shouldCreateSpanForRequest: undefined, - }; - }); - - const load = async ({ params, fetch }) => { - await fetch(...originalFetchArgs); + it("falls back to the raw URL if `even.route.id` isn't available", async () => { + async function load({ params }: Parameters[0]): Promise> { return { post: params.id, }; - }; - - it('creates a fetch span and attaches tracing headers by default when event.fetch was called', async () => { - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); - - expect(mockTrace).toHaveBeenCalledTimes(2); - expect(mockTrace).toHaveBeenNthCalledWith( - 1, - { - op: 'function.sveltekit.load', - name: '/users/[id]', - status: 'ok', - metadata: { - source: 'route', - }, - }, - expect.any(Function), - expect.any(Function), - ); - expect(mockTrace).toHaveBeenNthCalledWith(2, spanCtx, expect.any(Function)); - - const hasSecondArg = originalFetchArgs.length > 1; - const expectedFetchArgs = [ - originalFetchArgs[0], - { - ...(hasSecondArg && (originalFetchArgs[1] as RequestInit)), - headers: { - // @ts-ignore that's fine - ...(hasSecondArg && (originalFetchArgs[1].headers as RequestInit['headers'])), - baggage: expect.any(String), - 'sentry-trace': expect.any(String), - }, - }, - ]; - - expect(mockedSveltekitFetch).toHaveBeenCalledWith(...expectedFetchArgs); - }); - - it("only creates a span but doesn't propagate headers if traceProgagationTargets don't match", async () => { - const previousPropagationTargets = mockedBrowserTracing.options.tracePropagationTargets; - mockedBrowserTracing.options.tracePropagationTargets = []; - - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); - - expect(mockTrace).toHaveBeenCalledTimes(2); - expect(mockTrace).toHaveBeenNthCalledWith( - 1, - { - op: 'function.sveltekit.load', - name: '/users/[id]', - status: 'ok', - metadata: { - source: 'route', - }, - }, - expect.any(Function), - expect.any(Function), - ); - expect(mockTrace).toHaveBeenNthCalledWith(2, spanCtx, expect.any(Function)); - - expect(mockedSveltekitFetch).toHaveBeenCalledWith( - ...[originalFetchArgs[0], originalFetchArgs.length === 2 ? originalFetchArgs[1] : {}], - ); - - mockedBrowserTracing.options.tracePropagationTargets = previousPropagationTargets; - }); - - it("doesn't create a span nor propagate headers, if `Browsertracing.options.traceFetch` is false", async () => { - mockedBrowserTracing.options.traceFetch = false; - - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); - - expect(mockTrace).toHaveBeenCalledTimes(1); - expect(mockTrace).toHaveBeenCalledWith( - { - op: 'function.sveltekit.load', - name: '/users/[id]', - status: 'ok', - metadata: { - source: 'route', - }, - }, - expect.any(Function), - expect.any(Function), - ); - - expect(mockedSveltekitFetch).toHaveBeenCalledWith( - ...[originalFetchArgs[0], originalFetchArgs.length === 2 ? originalFetchArgs[1] : {}], - ); - - mockedBrowserTracing.options.traceFetch = true; - }); - - it("doesn't create a span if `shouldCreateSpanForRequest` returns false", async () => { - mockedBrowserTracing.options.shouldCreateSpanForRequest = () => false; - - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); - - expect(mockTrace).toHaveBeenCalledTimes(1); - expect(mockTrace).toHaveBeenCalledWith( - { - op: 'function.sveltekit.load', - name: '/users/[id]', - status: 'ok', - metadata: { - source: 'route', - }, - }, - expect.any(Function), - expect.any(Function), - ); + } + const wrappedLoad = wrapLoadWithSentry(load); - mockedBrowserTracing.options.shouldCreateSpanForRequest = () => true; - }); + const event = { ...MOCK_LOAD_ARGS }; + delete event.route.id; - it('adds a breadcrumb for the fetch call', async () => { - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); + await wrappedLoad(MOCK_LOAD_ARGS); - expect(mockedAddBreadcrumb).toHaveBeenCalledWith( - { - category: 'fetch', - data: { - ...spanCtx.data, - status_code: 200, - }, - type: 'http', - }, - { - endTimestamp: expect.any(Number), - input: [...originalFetchArgs], - response: { - status: 200, - }, - startTimestamp: expect.any(Number), + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith( + { + op: 'function.sveltekit.load', + name: '/users/123', + status: 'ok', + metadata: { + source: 'url', }, - ); - }); - - it("doesn't add a breadcrumb if fetch breadcrumbs are deactivated in the integration", async () => { - mockedBreadcrumbs.options.fetch = false; - - const wrappedLoad = wrapLoadWithSentry(load); - await wrappedLoad(MOCK_LOAD_ARGS); - - expect(mockedAddBreadcrumb).not.toHaveBeenCalled(); - - mockedBreadcrumbs.options.fetch = true; - }); + }, + expect.any(Function), + expect.any(Function), + ); }); }); - 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/sdk.test.ts b/packages/sveltekit/test/client/sdk.test.ts index a8353a73df3e..5ff3b9f9e846 100644 --- a/packages/sveltekit/test/client/sdk.test.ts +++ b/packages/sveltekit/test/client/sdk.test.ts @@ -109,9 +109,7 @@ describe('Sentry client SDK', () => { it('Merges a user-provided BrowserTracing integration with the automatically added one', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - new BrowserTracing({ tracePropagationTargets: ['myDomain.com'], startTransactionOnLocationChange: false }), - ], + integrations: [new BrowserTracing({ finalTimeout: 10, startTransactionOnLocationChange: false })], enableTracing: true, }); @@ -126,8 +124,7 @@ describe('Sentry client SDK', () => { expect(browserTracing).toBeDefined(); // This shows that the user-configured options are still here - expect(options.tracePropagationTargets).toEqual(['myDomain.com']); - expect(options.startTransactionOnLocationChange).toBe(false); + expect(options.finalTimeout).toEqual(10); // But we force the routing instrumentation to be ours expect(options.routingInstrumentation).toEqual(svelteKitRoutingInstrumentation); diff --git a/packages/sveltekit/test/client/vendor/lookUpCache.test.ts b/packages/sveltekit/test/client/vendor/lookUpCache.test.ts deleted file mode 100644 index 29b13494be12..000000000000 --- a/packages/sveltekit/test/client/vendor/lookUpCache.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/server/load.test.ts b/packages/sveltekit/test/server/load.test.ts index fa57dc6b7c46..90980204cf68 100644 --- a/packages/sveltekit/test/server/load.test.ts +++ b/packages/sveltekit/test/server/load.test.ts @@ -400,4 +400,33 @@ describe('wrapServerLoadWithSentry calls trace', () => { expect(mockTrace).toHaveBeenCalledTimes(1); }); + + it("doesn't invoke the proxy set on `event.route`", async () => { + const event = getServerOnlyArgs(); + + // simulates SvelteKit adding a proxy to `event.route` + // https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/server/page/load_data.js#L111C3-L124 + const proxyFn = vi.fn((target: { id: string }, key: string | symbol): any => { + return target[key]; + }); + + event.route = new Proxy(event.route, { + get: proxyFn, + }); + + const wrappedLoad = wrapServerLoadWithSentry(serverLoad); + await wrappedLoad(event); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'function.sveltekit.server.load', + name: '/users/[id]', // <-- this shows that the route was still accessed + }), + expect.any(Function), + expect.any(Function), + ); + + expect(proxyFn).not.toHaveBeenCalled(); + }); }); diff --git a/packages/sveltekit/test/vitest.setup.ts b/packages/sveltekit/test/vitest.setup.ts index af2810a98a96..57fdb8baef87 100644 --- a/packages/sveltekit/test/vitest.setup.ts +++ b/packages/sveltekit/test/vitest.setup.ts @@ -13,6 +13,8 @@ export function setup() { } if (!globalThis.fetch) { - // @ts-ignore - Needed for vitest to work with SvelteKit fetch instrumentation + // @ts-ignore - Needed for vitest to work with our fetch instrumentation globalThis.Request = class Request {}; + // @ts-ignore - Needed for vitest to work with our fetch instrumentation + globalThis.Response = class Response {}; } diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts index aae66bee3358..d01c837d26c2 100644 --- a/packages/tracing-internal/src/browser/browsertracing.ts +++ b/packages/tracing-internal/src/browser/browsertracing.ts @@ -248,6 +248,7 @@ export class BrowserTracing implements Integration { // 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. + // eslint-disable-next-line deprecation/deprecation const tracePropagationTargets = clientOptionsTracePropagationTargets || this.options.tracePropagationTargets; if (__DEBUG_BUILD__ && this._hasSetTracePropagationTargets && clientOptionsTracePropagationTargets) { logger.warn( diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index e24c726ada5f..7c64484ce54b 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -27,7 +27,10 @@ export interface RequestInstrumentationOptions { * List of strings and/or regexes used to determine which outgoing requests will have `sentry-trace` and `baggage` * headers attached. * - * Default: ['localhost', /^\//] {@see DEFAULT_TRACE_PROPAGATION_TARGETS} + * @deprecated Use the top-level `tracePropagationTargets` option in `Sentry.init` instead. + * This option will be removed in v8. + * + * Default: ['localhost', /^\//] @see {DEFAULT_TRACE_PROPAGATION_TARGETS} */ tracePropagationTargets: Array; @@ -125,6 +128,7 @@ export function instrumentOutgoingRequests(_options?: Partial { - const entries = list.getEntries() as PerformanceResourceTiming[]; + const entries = list.getEntries(); entries.forEach(entry => { - if ((entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') && entry.name.endsWith(url)) { + if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) { const spanData = resourceTimingEntryToSpanData(entry); spanData.forEach(data => span.setData(...data)); observer.disconnect(); @@ -220,7 +233,7 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string; return { name, version }; } -function getAbsoluteTime(time: number): number { +function getAbsoluteTime(time: number = 0): number { return ((browserPerformanceTimeOrigin || performance.timeOrigin) + time) / 1000; } diff --git a/packages/utils/src/buildPolyfills/_createNamedExportFrom.ts b/packages/utils/src/buildPolyfills/_createNamedExportFrom.ts deleted file mode 100644 index 2193609e64f7..000000000000 --- a/packages/utils/src/buildPolyfills/_createNamedExportFrom.ts +++ /dev/null @@ -1,45 +0,0 @@ -// https://github.com/alangpierce/sucrase/tree/265887868966917f3b924ce38dfad01fbab1329f -// -// The MIT License (MIT) -// -// Copyright (c) 2012-2018 various contributors (see AUTHORS) -// -// 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 { GenericObject } from './types'; - -declare const exports: GenericObject; - -/** - * Copy a property from the given object into `exports`, under the given name. - * - * Adapted from Sucrase (https://github.com/alangpierce/sucrase) - * - * @param obj The object containing the property to copy. - * @param localName The name under which to export the property - * @param importedName The name under which the property lives in `obj` - */ -export function _createNamedExportFrom(obj: GenericObject, localName: string, importedName: string): void { - exports[localName] = obj[importedName]; -} - -// Sucrase version: -// function _createNamedExportFrom(obj, localName, importedName) { -// Object.defineProperty(exports, localName, {enumerable: true, get: () => obj[importedName]}); -// } diff --git a/packages/utils/src/buildPolyfills/_createStarExport.ts b/packages/utils/src/buildPolyfills/_createStarExport.ts deleted file mode 100644 index 377d51e10a84..000000000000 --- a/packages/utils/src/buildPolyfills/_createStarExport.ts +++ /dev/null @@ -1,52 +0,0 @@ -// https://github.com/alangpierce/sucrase/tree/265887868966917f3b924ce38dfad01fbab1329f -// -// The MIT License (MIT) -// -// Copyright (c) 2012-2018 various contributors (see AUTHORS) -// -// 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 { GenericObject } from './types'; - -declare const exports: GenericObject; - -/** - * Copy properties from an object into `exports`. - * - * Adapted from Sucrase (https://github.com/alangpierce/sucrase) - * - * @param obj The object containing the properties to copy. - */ -export function _createStarExport(obj: GenericObject): void { - Object.keys(obj) - .filter(key => key !== 'default' && key !== '__esModule' && !(key in exports)) - .forEach(key => (exports[key] = obj[key])); -} - -// Sucrase version: -// function _createStarExport(obj) { -// Object.keys(obj) -// .filter(key => key !== 'default' && key !== '__esModule') -// .forEach(key => { -// if (exports.hasOwnProperty(key)) { -// return; -// } -// Object.defineProperty(exports, key, { enumerable: true, get: () => obj[key] }); -// }); -// } diff --git a/packages/utils/src/buildPolyfills/_interopDefault.ts b/packages/utils/src/buildPolyfills/_interopDefault.ts deleted file mode 100644 index 3a8c29d1bbaf..000000000000 --- a/packages/utils/src/buildPolyfills/_interopDefault.ts +++ /dev/null @@ -1,37 +0,0 @@ -// https://github.com/rollup/rollup/tree/c2cda424e69686671ba010d628c0f70c43a563f8 -// The MIT License (MIT) -// -// Copyright (c) 2017 [these people](https://github.com/rollup/rollup/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 { RequireResult } from './types'; - -/** - * Unwraps a module if it has been wrapped in an object under the key `default`. - * - * Adapted from Rollup (https://github.com/rollup/rollup) - * - * @param requireResult The result of calling `require` on a module - * @returns The full module, unwrapped if necessary. - */ -export function _interopDefault(requireResult: RequireResult): RequireResult { - return requireResult.__esModule ? (requireResult.default as RequireResult) : requireResult; -} - -// Rollup version: -// function _interopDefault(e) { -// return e && e.__esModule ? e['default'] : e; -// } diff --git a/packages/utils/src/buildPolyfills/_interopNamespace.ts b/packages/utils/src/buildPolyfills/_interopNamespace.ts deleted file mode 100644 index ed596090ff73..000000000000 --- a/packages/utils/src/buildPolyfills/_interopNamespace.ts +++ /dev/null @@ -1,45 +0,0 @@ -// https://github.com/rollup/rollup/tree/c2cda424e69686671ba010d628c0f70c43a563f8 -// The MIT License (MIT) -// -// Copyright (c) 2017 [these people](https://github.com/rollup/rollup/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 { RequireResult } from './types'; - -/** - * Adds a self-referential `default` property to CJS modules which aren't the result of transpilation from ESM modules. - * - * Adapted from Rollup (https://github.com/rollup/rollup) - * - * @param requireResult The result of calling `require` on a module - * @returns Either `requireResult` or a copy of `requireResult` with an added self-referential `default` property - */ -export function _interopNamespace(requireResult: RequireResult): RequireResult { - return requireResult.__esModule ? requireResult : { ...requireResult, default: requireResult }; -} - -// Rollup version (with `output.externalLiveBindings` and `output.freeze` both set to false) -// function _interopNamespace(e) { -// if (e && e.__esModule) return e; -// var n = Object.create(null); -// if (e) { -// for (var k in e) { -// n[k] = e[k]; -// } -// } -// n["default"] = e; -// return n; -// } diff --git a/packages/utils/src/buildPolyfills/_interopNamespaceDefaultOnly.ts b/packages/utils/src/buildPolyfills/_interopNamespaceDefaultOnly.ts deleted file mode 100644 index a3b1de3ab3b5..000000000000 --- a/packages/utils/src/buildPolyfills/_interopNamespaceDefaultOnly.ts +++ /dev/null @@ -1,43 +0,0 @@ -// https://github.com/rollup/rollup/tree/c2cda424e69686671ba010d628c0f70c43a563f8 -// The MIT License (MIT) -// -// Copyright (c) 2017 [these people](https://github.com/rollup/rollup/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 { RequireResult } from './types'; - -/** - * Wrap a module in an object, as the value under the key `default`. - * - * Adapted from Rollup (https://github.com/rollup/rollup) - * - * @param requireResult The result of calling `require` on a module - * @returns An object containing the key-value pair (`default`, `requireResult`) - */ -export function _interopNamespaceDefaultOnly(requireResult: RequireResult): RequireResult { - return { - __proto__: null, - default: requireResult, - }; -} - -// Rollup version -// function _interopNamespaceDefaultOnly(e) { -// return { -// __proto__: null, -// 'default': e -// }; -// } diff --git a/packages/utils/src/buildPolyfills/_interopRequireDefault.ts b/packages/utils/src/buildPolyfills/_interopRequireDefault.ts deleted file mode 100644 index 74122265c07e..000000000000 --- a/packages/utils/src/buildPolyfills/_interopRequireDefault.ts +++ /dev/null @@ -1,42 +0,0 @@ -// https://github.com/alangpierce/sucrase/tree/265887868966917f3b924ce38dfad01fbab1329f -// -// The MIT License (MIT) -// -// Copyright (c) 2012-2018 various contributors (see AUTHORS) -// -// 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 { RequireResult } from './types'; - -/** - * Wraps modules which aren't the result of transpiling an ESM module in an object under the key `default` - * - * Adapted from Sucrase (https://github.com/alangpierce/sucrase) - * - * @param requireResult The result of calling `require` on a module - * @returns `requireResult` or `requireResult` wrapped in an object, keyed as `default` - */ -export function _interopRequireDefault(requireResult: RequireResult): RequireResult { - return requireResult.__esModule ? requireResult : { default: requireResult }; -} - -// Sucrase version -// function _interopRequireDefault(obj) { -// return obj && obj.__esModule ? obj : { default: obj }; -// } diff --git a/packages/utils/src/buildPolyfills/_interopRequireWildcard.ts b/packages/utils/src/buildPolyfills/_interopRequireWildcard.ts deleted file mode 100644 index 5be829e3e48a..000000000000 --- a/packages/utils/src/buildPolyfills/_interopRequireWildcard.ts +++ /dev/null @@ -1,55 +0,0 @@ -// https://github.com/alangpierce/sucrase/tree/265887868966917f3b924ce38dfad01fbab1329f -// -// The MIT License (MIT) -// -// Copyright (c) 2012-2018 various contributors (see AUTHORS) -// -// 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 { RequireResult } from './types'; - -/** - * Adds a `default` property to CJS modules which aren't the result of transpilation from ESM modules. - * - * Adapted from Sucrase (https://github.com/alangpierce/sucrase) - * - * @param requireResult The result of calling `require` on a module - * @returns Either `requireResult` or a copy of `requireResult` with an added self-referential `default` property - */ -export function _interopRequireWildcard(requireResult: RequireResult): RequireResult { - return requireResult.__esModule ? requireResult : { ...requireResult, default: requireResult }; -} - -// Sucrase version -// function _interopRequireWildcard(obj) { -// if (obj && obj.__esModule) { -// return obj; -// } else { -// var newObj = {}; -// if (obj != null) { -// for (var key in obj) { -// if (Object.prototype.hasOwnProperty.call(obj, key)) { -// newObj[key] = obj[key]; -// } -// } -// } -// newObj.default = obj; -// return newObj; -// } -// } diff --git a/packages/utils/src/buildPolyfills/index.ts b/packages/utils/src/buildPolyfills/index.ts index 9717453e98fa..2017dcbd9592 100644 --- a/packages/utils/src/buildPolyfills/index.ts +++ b/packages/utils/src/buildPolyfills/index.ts @@ -1,13 +1,6 @@ export { _asyncNullishCoalesce } from './_asyncNullishCoalesce'; export { _asyncOptionalChain } from './_asyncOptionalChain'; export { _asyncOptionalChainDelete } from './_asyncOptionalChainDelete'; -export { _createNamedExportFrom } from './_createNamedExportFrom'; -export { _createStarExport } from './_createStarExport'; -export { _interopDefault } from './_interopDefault'; -export { _interopNamespace } from './_interopNamespace'; -export { _interopNamespaceDefaultOnly } from './_interopNamespaceDefaultOnly'; -export { _interopRequireDefault } from './_interopRequireDefault'; -export { _interopRequireWildcard } from './_interopRequireWildcard'; export { _nullishCoalesce } from './_nullishCoalesce'; export { _optionalChain } from './_optionalChain'; export { _optionalChainDelete } from './_optionalChainDelete'; diff --git a/packages/utils/test/buildPolyfills/interop.test.ts b/packages/utils/test/buildPolyfills/interop.test.ts deleted file mode 100644 index a53c64eb0979..000000000000 --- a/packages/utils/test/buildPolyfills/interop.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { - _interopDefault, - _interopNamespace, - _interopNamespaceDefaultOnly, - _interopRequireDefault, - _interopRequireWildcard, -} from '../../src/buildPolyfills'; -import type { RequireResult } from '../../src/buildPolyfills/types'; -import { - _interopDefault as _interopDefaultOrig, - _interopNamespace as _interopNamespaceOrig, - _interopNamespaceDefaultOnly as _interopNamespaceDefaultOnlyOrig, - _interopRequireDefault as _interopRequireDefaultOrig, - _interopRequireWildcard as _interopRequireWildcardOrig, -} from './originals'; - -// This file tests five different functions against a range of test cases. Though the inputs are the same for each -// function's test cases, the expected output differs. The testcases for each function are therefore built from separate -// collections of expected inputs and expected outputs. Further, for readability purposes, the tests labels have also -// been split into their own object. It's also worth noting that in real life, there are some test-case/function -// pairings which would never happen, but by testing all combinations, we're guaranteed to have tested the ones which -// show up in the wild. - -const dogStr = 'dogs are great!'; -const dogFunc = () => dogStr; -const dogAdjectives = { maisey: 'silly', charlie: 'goofy' }; - -const withESModuleFlag = { __esModule: true, ...dogAdjectives }; -const withESModuleFlagAndDefault = { __esModule: true, default: dogFunc, ...dogAdjectives }; -const namedExports = { ...dogAdjectives }; -const withNonEnumerableProp = { ...dogAdjectives }; -// Properties added using `Object.defineProperty` are non-enumerable by default -Object.defineProperty(withNonEnumerableProp, 'hiddenProp', { value: 'shhhhhhhh' }); -const withDefaultExport = { default: dogFunc, ...dogAdjectives }; -const withOnlyDefaultExport = { default: dogFunc }; -const exportsEquals = dogFunc as RequireResult; -const exportsEqualsWithDefault = dogFunc as RequireResult; -exportsEqualsWithDefault.default = exportsEqualsWithDefault; - -const mockRequireResults: Record = { - withESModuleFlag, - withESModuleFlagAndDefault, - namedExports, - withNonEnumerableProp, - withDefaultExport, - withOnlyDefaultExport, - exportsEquals: exportsEquals, - exportsEqualsWithDefault: exportsEqualsWithDefault as unknown as RequireResult, -}; - -const testLabels: Record = { - withESModuleFlag: 'module with `__esModule` flag', - withESModuleFlagAndDefault: 'module with `__esModule` flag and default export', - namedExports: 'module with named exports', - withNonEnumerableProp: 'module with named exports and non-enumerable prop', - withDefaultExport: 'module with default export', - withOnlyDefaultExport: 'module with only default export', - exportsEquals: 'module using `exports =`', - exportsEqualsWithDefault: 'module using `exports =` with default export', -}; - -function makeTestCases(expectedOutputs: Record): Array<[string, RequireResult, RequireResult]> { - return Object.keys(mockRequireResults).map(key => [testLabels[key], mockRequireResults[key], expectedOutputs[key]]); -} - -describe('_interopNamespace', () => { - describe('returns the same result as the original', () => { - const expectedOutputs: Record = { - withESModuleFlag: withESModuleFlag, - withESModuleFlagAndDefault: withESModuleFlagAndDefault, - namedExports: { ...namedExports, default: namedExports }, - withNonEnumerableProp: { - ...withNonEnumerableProp, - default: withNonEnumerableProp, - }, - withDefaultExport: { ...withDefaultExport, default: withDefaultExport }, - withOnlyDefaultExport: { default: withOnlyDefaultExport }, - exportsEquals: { default: exportsEquals }, - exportsEqualsWithDefault: { default: exportsEqualsWithDefault }, - }; - - const testCases = makeTestCases(expectedOutputs); - - it.each(testCases)('%s', (_, requireResult, expectedOutput) => { - expect(_interopNamespace(requireResult)).toEqual(_interopNamespaceOrig(requireResult)); - expect(_interopNamespace(requireResult)).toEqual(expectedOutput); - }); - }); -}); - -describe('_interopNamespaceDefaultOnly', () => { - describe('returns the same result as the original', () => { - const expectedOutputs: Record = { - withESModuleFlag: { default: withESModuleFlag }, - withESModuleFlagAndDefault: { default: withESModuleFlagAndDefault }, - namedExports: { default: namedExports }, - withNonEnumerableProp: { default: withNonEnumerableProp }, - withDefaultExport: { default: withDefaultExport }, - withOnlyDefaultExport: { default: withOnlyDefaultExport }, - exportsEquals: { default: exportsEquals }, - exportsEqualsWithDefault: { default: exportsEqualsWithDefault }, - }; - - const testCases = makeTestCases(expectedOutputs); - - it.each(testCases)('%s', (_, requireResult, expectedOutput) => { - expect(_interopNamespaceDefaultOnly(requireResult)).toEqual(_interopNamespaceDefaultOnlyOrig(requireResult)); - expect(_interopNamespaceDefaultOnly(requireResult)).toEqual(expectedOutput); - }); - }); -}); - -describe('_interopRequireWildcard', () => { - describe('returns the same result as the original', () => { - const expectedOutputs: Record = { - withESModuleFlag: withESModuleFlag, - withESModuleFlagAndDefault: withESModuleFlagAndDefault, - namedExports: { ...namedExports, default: namedExports }, - withNonEnumerableProp: { - ...withNonEnumerableProp, - default: withNonEnumerableProp, - }, - withDefaultExport: { ...withDefaultExport, default: withDefaultExport }, - withOnlyDefaultExport: { default: withOnlyDefaultExport }, - exportsEquals: { default: exportsEquals }, - exportsEqualsWithDefault: { default: exportsEqualsWithDefault }, - }; - - const testCases = makeTestCases(expectedOutputs); - - it.each(testCases)('%s', (_, requireResult, expectedOutput) => { - expect(_interopRequireWildcard(requireResult)).toEqual(_interopRequireWildcardOrig(requireResult)); - expect(_interopRequireWildcard(requireResult)).toEqual(expectedOutput); - }); - }); -}); - -describe('_interopDefault', () => { - describe('returns the same result as the original', () => { - const expectedOutputs: Record = { - withESModuleFlag: undefined as unknown as RequireResult, - withESModuleFlagAndDefault: withESModuleFlagAndDefault.default as RequireResult, - namedExports: namedExports, - withNonEnumerableProp: withNonEnumerableProp, - withDefaultExport: withDefaultExport, - withOnlyDefaultExport: withOnlyDefaultExport, - exportsEquals: exportsEquals, - exportsEqualsWithDefault: exportsEqualsWithDefault, - }; - - const testCases = makeTestCases(expectedOutputs); - - it.each(testCases)('%s', (_, requireResult, expectedOutput) => { - expect(_interopDefault(requireResult)).toEqual(_interopDefaultOrig(requireResult)); - expect(_interopDefault(requireResult)).toEqual(expectedOutput); - }); - }); -}); - -describe('_interopRequireDefault', () => { - describe('returns the same result as the original', () => { - const expectedOutputs: Record = { - withESModuleFlag: withESModuleFlag, - withESModuleFlagAndDefault: withESModuleFlagAndDefault, - namedExports: { default: namedExports }, - withNonEnumerableProp: { default: withNonEnumerableProp }, - withDefaultExport: { default: withDefaultExport }, - withOnlyDefaultExport: { default: withOnlyDefaultExport }, - exportsEquals: { default: exportsEquals }, - exportsEqualsWithDefault: { default: exportsEqualsWithDefault }, - }; - - const testCases = makeTestCases(expectedOutputs); - - it.each(testCases)('%s', (_, requireResult, expectedOutput) => { - expect(_interopRequireDefault(requireResult)).toEqual(_interopRequireDefaultOrig(requireResult)); - expect(_interopRequireDefault(requireResult)).toEqual(expectedOutput); - }); - }); -}); diff --git a/packages/utils/test/buildPolyfills/originals.d.ts b/packages/utils/test/buildPolyfills/originals.d.ts index 323d6f26e93c..c2032b265476 100644 --- a/packages/utils/test/buildPolyfills/originals.d.ts +++ b/packages/utils/test/buildPolyfills/originals.d.ts @@ -8,16 +8,6 @@ export function _asyncNullishCoalesce(lhs: any, rhsFn: any): Promise; export function _asyncOptionalChain(ops: any): Promise; export function _asyncOptionalChainDelete(ops: any): Promise; -export function _createNamedExportFrom(obj: any, localName: any, importedName: any): void; -export function _createStarExport(obj: any): void; -export function _interopDefault(e: any): any; -export function _interopNamespace(e: any): any; -export function _interopNamespaceDefaultOnly(e: any): { - __proto__: any; - default: any; -}; -export function _interopRequireDefault(obj: any): any; -export function _interopRequireWildcard(obj: any): any; export function _nullishCoalesce(lhs: any, rhsFn: any): any; export function _optionalChain(ops: any): any; export function _optionalChainDelete(ops: any): any; diff --git a/packages/utils/test/buildPolyfills/originals.js b/packages/utils/test/buildPolyfills/originals.js index 969591755367..5ec688de93ac 100644 --- a/packages/utils/test/buildPolyfills/originals.js +++ b/packages/utils/test/buildPolyfills/originals.js @@ -40,73 +40,6 @@ export async function _asyncOptionalChainDelete(ops) { return result == null ? true : result; } -// From Sucrase -export function _createNamedExportFrom(obj, localName, importedName) { - Object.defineProperty(exports, localName, { enumerable: true, get: () => obj[importedName] }); -} - -// From Sucrase -export function _createStarExport(obj) { - Object.keys(obj) - .filter(key => key !== 'default' && key !== '__esModule') - .forEach(key => { - // eslint-disable-next-line no-prototype-builtins - if (exports.hasOwnProperty(key)) { - return; - } - Object.defineProperty(exports, key, { enumerable: true, get: () => obj[key] }); - }); -} - -// From Rollup -export function _interopDefault(e) { - return e && e.__esModule ? e['default'] : e; -} - -// From Rollup -export function _interopNamespace(e) { - if (e && e.__esModule) return e; - var n = Object.create(null); - if (e) { - // eslint-disable-next-line guard-for-in - for (var k in e) { - n[k] = e[k]; - } - } - n['default'] = e; - return n; -} - -export function _interopNamespaceDefaultOnly(e) { - return { - __proto__: null, - default: e, - }; -} - -// From Sucrase -export function _interopRequireDefault(obj) { - return obj && obj.__esModule ? obj : { default: obj }; -} - -// From Sucrase -export function _interopRequireWildcard(obj) { - if (obj && obj.__esModule) { - return obj; - } else { - var newObj = {}; - if (obj != null) { - for (var key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - newObj[key] = obj[key]; - } - } - } - newObj.default = obj; - return newObj; - } -} - // From Sucrase export function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { diff --git a/rollup/plugins/extractPolyfillsPlugin.js b/rollup/plugins/extractPolyfillsPlugin.js index e7b83b23dd35..134f39c64bdb 100644 --- a/rollup/plugins/extractPolyfillsPlugin.js +++ b/rollup/plugins/extractPolyfillsPlugin.js @@ -7,13 +7,6 @@ const POLYFILL_NAMES = new Set([ '_asyncNullishCoalesce', '_asyncOptionalChain', '_asyncOptionalChainDelete', - '_createNamedExportFrom', - '_createStarExport', - '_interopDefault', // rollup's version - '_interopNamespace', // rollup's version - '_interopNamespaceDefaultOnly', - '_interopRequireDefault', // sucrase's version - '_interopRequireWildcard', // sucrase's version '_nullishCoalesce', '_optionalChain', '_optionalChainDelete', diff --git a/yarn.lock b/yarn.lock index af50dde468cf..2f4c50f00895 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23094,9 +23094,9 @@ proper-lockfile@^4.1.2: signal-exit "^3.0.2" protobufjs@^6.10.2, protobufjs@^6.8.6: - version "6.11.3" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" - integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== + version "6.11.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.4.tgz#29a412c38bf70d89e537b6d02d904a6f448173aa" + integrity sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2"