From e6e6ebff74d82e5aee8dfa7cc50ac3491a35f2b7 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 14 Aug 2023 14:45:14 +0200 Subject: [PATCH 01/22] fix(nextjs): Check for validity of API route handler signature (#8811) --- .../src/common/wrapApiHandlerWithSentry.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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. From 448406aefc203e772448aa62b0805ecbad1ba3a2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Aug 2023 15:54:38 +0200 Subject: [PATCH 02/22] build: Remove build-specific polyfills (#8809) It _seems_ they do not do anything - at least I compared the complete build ouput for node, core & browser with & without this, and it appeared _exactly_ the same. Not sure, maybe I missed something, it is not entirely clear to me how/what/when these would be applied etc. --- .../buildPolyfills/_createNamedExportFrom.ts | 45 ----- .../src/buildPolyfills/_createStarExport.ts | 52 ----- .../src/buildPolyfills/_interopDefault.ts | 37 ---- .../src/buildPolyfills/_interopNamespace.ts | 45 ----- .../_interopNamespaceDefaultOnly.ts | 43 ----- .../buildPolyfills/_interopRequireDefault.ts | 42 ---- .../buildPolyfills/_interopRequireWildcard.ts | 55 ------ packages/utils/src/buildPolyfills/index.ts | 7 - .../utils/test/buildPolyfills/interop.test.ts | 180 ------------------ .../utils/test/buildPolyfills/originals.d.ts | 10 - .../utils/test/buildPolyfills/originals.js | 67 ------- rollup/plugins/extractPolyfillsPlugin.js | 7 - 12 files changed, 590 deletions(-) delete mode 100644 packages/utils/src/buildPolyfills/_createNamedExportFrom.ts delete mode 100644 packages/utils/src/buildPolyfills/_createStarExport.ts delete mode 100644 packages/utils/src/buildPolyfills/_interopDefault.ts delete mode 100644 packages/utils/src/buildPolyfills/_interopNamespace.ts delete mode 100644 packages/utils/src/buildPolyfills/_interopNamespaceDefaultOnly.ts delete mode 100644 packages/utils/src/buildPolyfills/_interopRequireDefault.ts delete mode 100644 packages/utils/src/buildPolyfills/_interopRequireWildcard.ts delete mode 100644 packages/utils/test/buildPolyfills/interop.test.ts 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', From 5c085efa8d6a326541135c6387f5dacb3bbe32bf Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Aug 2023 11:47:26 -0400 Subject: [PATCH 03/22] feat(core): Introduce `Sentry.startActiveSpan` and `Sentry.startSpan` (#8803) --- packages/core/src/tracing/index.ts | 2 +- packages/core/src/tracing/trace.ts | 101 ++++++++++++++++++- packages/core/test/lib/tracing/trace.test.ts | 67 ++---------- packages/node/src/index.ts | 3 + packages/serverless/src/index.ts | 3 + packages/sveltekit/src/server/index.ts | 3 + 6 files changed, 120 insertions(+), 59 deletions(-) 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/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/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/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 From dc653d0adb95198daace6975b2343fd1d4443847 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Aug 2023 09:02:58 +0200 Subject: [PATCH 04/22] build(deps): bump protobufjs from 6.11.3 to 6.11.4 (#8822) Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 6.11.3 to 6.11.4.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=protobufjs&package-manager=npm_and_yarn&previous-version=6.11.3&new-version=6.11.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" From 65df86932562fcc4d003fd45b74382c8412f408a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Aug 2023 10:43:37 +0200 Subject: [PATCH 05/22] fix(sveltekit): Avoid invalidating data on route changes in `wrapServerLoadWithSentry` (#8801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SvelteKit SDK caused server load data to be invalidated, resulting in said functions being called on every route change. I initially thought this was related to our Kit-specific client fetch instrumentation which turned out not to be causing this. Instead, the culprit is our `wrapServerLoadWithSentry` wrapper: In the wrapper, we access `event.route.id` to determine the route of the load function for the span description. Internally, SvelteKit puts a proxy on certain `event` properties, such as `event.route`. In case any property of `event.route` was accessed, [SvelteKit marks this](https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/server/page/load_data.js#L111-L124) internally and send along a flag to the client. On a route change, the client would [check this flag](https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/client/client.js#L572) and mark the route as invalidated, thereby causing a [call to the load function](https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/client/client.js#L641) on each navigation. --------- Co-authored-by: Kamil Ogórek --- packages/sveltekit/src/server/load.ts | 6 ++++- packages/sveltekit/test/server/load.test.ts | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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/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(); + }); }); From c20e0fb29fa2a1aa94896151fcce97a5dc4dc5ca Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 17 Aug 2023 07:59:44 +0200 Subject: [PATCH 06/22] fix(node): More relevant warning message when tracing extensions are missing (#8820) Closes #8624 --- packages/core/src/hub.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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; From 39bf783f5f7131479df0940d584623a97082e03d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 17 Aug 2023 08:04:46 +0200 Subject: [PATCH 07/22] build: Fix typo in size limit config (#8825) Oops... --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', }, From 490631e30a131ad2b768ec15fe75d9facc2e0dbd Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 17 Aug 2023 02:58:25 -0400 Subject: [PATCH 08/22] feat(ci): Cache `node_modules` in flaky test detector (#8787) This should cut down on the `yarn install` step. --- .github/workflows/flaky-test-detector.yml | 1 + 1 file changed, 1 insertion(+) 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 From a6e2642bfe10034a1d0259cc721af1fd032bb9c4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 17 Aug 2023 10:20:36 +0200 Subject: [PATCH 09/22] ref(sveltekit): Remove custom client fetch instrumentation and use default instrumentation (#8802) Remove our custom SvelteKit client fetch instrumentation which we created when we initially worked on the SDK. Back then I didn't think that it's possible to use our default fetch instrumentation from `BrowserTracing`, due to timing issues where Kit would store away `window.fetch` (and use the stored version in `load` functions) before our SDK was initialized. After receiving some [hints](https://github.com/sveltejs/kit/issues/9530#issuecomment-1611195970) how it might be possible, we now have a way to instrument `fetch` everywhere on the client (including the one in `load`) functions. This works in two parts: 1. During the initial page load request, our server-side `handle` wrapper injects a script into the returned HTML that wraps `window.fetch` and adds a proxy handler (`window._sentryFetchProxy`) that at this time just forwards the fetch call to the original fetch. After this script is evaluated by the browser, at some point, SvelteKit loads its initial client-side bundle that stores away `window.fetch`. Kit also patches `window.fetch` itself at this time. Sometime later, the code from the `hooks.client.js` file is evaluated in the browser, including our `Sentry.init` call: 2. During `Sentry.init` we now swap `window.fetch` with `window._sentryFetchProxy` which will make our `BrowserTracing` integration patch our proxy with our default fetch instrumentation. After the init, we swap the two fetches back and we're done. --- packages/sveltekit/package.json | 2 +- packages/sveltekit/src/client/load.ts | 197 +--------- packages/sveltekit/src/client/sdk.ts | 55 ++- .../src/client/vendor/buildSelector.ts | 57 --- packages/sveltekit/src/client/vendor/hash.ts | 51 --- .../src/client/vendor/lookUpCache.ts | 79 ---- packages/sveltekit/src/server/handle.ts | 14 +- packages/sveltekit/test/client/fetch.test.ts | 55 +++ packages/sveltekit/test/client/load.test.ts | 348 +----------------- .../test/client/vendor/lookUpCache.test.ts | 45 --- packages/sveltekit/test/vitest.setup.ts | 4 +- 11 files changed, 146 insertions(+), 761 deletions(-) delete mode 100644 packages/sveltekit/src/client/vendor/buildSelector.ts delete mode 100644 packages/sveltekit/src/client/vendor/hash.ts delete mode 100644 packages/sveltekit/src/client/vendor/lookUpCache.ts create mode 100644 packages/sveltekit/test/client/fetch.test.ts delete mode 100644 packages/sveltekit/test/client/vendor/lookUpCache.test.ts diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index a8841d5a02c2..2c74e6e78ede 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -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/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/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/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 {}; } From e5a388590d866a64d53814c82254b50cef8dd173 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 17 Aug 2023 13:51:59 +0200 Subject: [PATCH 10/22] fix: Memoize `AsyncLocalStorage` instance (#8831) --- .../src/edge/asyncLocalStorageAsyncContextStrategy.ts | 6 +++++- packages/node/src/async/hooks.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/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(); From ff268873130e80049e887ac3dfce48d35e548c08 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 17 Aug 2023 07:52:44 -0400 Subject: [PATCH 11/22] test(replay): Fix flakes from `customEvents` (#8827) Force a flush after each click, otherwise its possible that we have a flush inbetween the 3 clicks. --- .../suites/replay/customEvents/test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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, From a985738b9f860ca309f81673924227a8f50ceb19 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 17 Aug 2023 19:08:33 +0200 Subject: [PATCH 12/22] fix(sveltekit): Remove invalid return in fetch proxy script (#8835) Fixes a bug where we incorrectly returned from the sveltekit fetch proxy Html script. --- packages/sveltekit/src/server/handle.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index d69f427daa92..332dea4925df 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -59,9 +59,10 @@ function sendErrorToSentry(e: unknown): unknown { const FETCH_PROXY_SCRIPT = ` const f = window.fetch; - if(!f){return} - window._sentryFetchProxy = function(...a){return f(...a)} - window.fetch = function(...a){return window._sentryFetchProxy(...a)} + if(f){ + window._sentryFetchProxy = function(...a){return f(...a)} + window.fetch = function(...a){return window._sentryFetchProxy(...a)} + } `; export const transformPageChunk: NonNullable = ({ html }) => { From 4a4df0d7088a0a012fe8e78ffa76fb611ebacb76 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 18 Aug 2023 08:12:26 +0200 Subject: [PATCH 13/22] fix(replay): Streamline session creation/refresh (#8813) We've been using `_loadAndCheckSession` both in initial session setup as well as when checking for expiration of session. This leads to some not-so-optimized stuff, as we kind of have to do double duty in there (e.g. we constantly re-assign the session etc). This streamlines this by splitting this into: * `_initializeSessionForSampling()`: Only called in `initializeSampling()` * `_checkSession()`: Called everywhere else, assumes we have a session setup yet Only the former actually looks into sessionStorage, the latter can assume we always have a session already. This also extends the behavior so that if we fetch a `buffer` session from storage and segment_id > 0, we start the session in `session` mode. Without this, we could theoretically run into endless sessions if the user keeps refreshing and keeps having errors, leading to continuous switchovers from buffer>session mode. --- .../suites/replay/bufferModeReload/init.js | 17 + .../replay/bufferModeReload/template.html | 10 + .../suites/replay/bufferModeReload/test.ts | 51 +++ packages/replay/src/replay.ts | 145 ++++--- packages/replay/src/session/Session.ts | 2 + packages/replay/src/session/createSession.ts | 6 +- packages/replay/src/session/getSession.ts | 63 --- .../replay/src/session/loadOrCreateSession.ts | 32 ++ .../replay/src/session/maybeRefreshSession.ts | 48 +++ .../beforeAddRecordingEvent.test.ts | 4 +- .../test/integration/errorSampleRate.test.ts | 5 +- .../replay/test/integration/events.test.ts | 6 +- .../replay/test/integration/flush.test.ts | 27 +- .../test/integration/rateLimiting.test.ts | 4 +- .../test/integration/sendReplayEvent.test.ts | 4 +- .../replay/test/integration/session.test.ts | 3 +- packages/replay/test/integration/stop.test.ts | 3 +- .../test/unit/session/getSession.test.ts | 291 ------------- .../unit/session/loadOrCreateSession.test.ts | 396 ++++++++++++++++++ .../unit/session/maybeRefreshSession.test.ts | 266 ++++++++++++ .../replay/test/utils/setupReplayContainer.ts | 2 +- 21 files changed, 950 insertions(+), 435 deletions(-) create mode 100644 packages/browser-integration-tests/suites/replay/bufferModeReload/init.js create mode 100644 packages/browser-integration-tests/suites/replay/bufferModeReload/template.html create mode 100644 packages/browser-integration-tests/suites/replay/bufferModeReload/test.ts delete mode 100644 packages/replay/src/session/getSession.ts create mode 100644 packages/replay/src/session/loadOrCreateSession.ts create mode 100644 packages/replay/src/session/maybeRefreshSession.ts delete mode 100644 packages/replay/test/unit/session/getSession.test.ts create mode 100644 packages/replay/test/unit/session/loadOrCreateSession.test.ts create mode 100644 packages/replay/test/unit/session/maybeRefreshSession.test.ts 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/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, From 88611c75fd2b850f75b7c739420123af61192b12 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 18 Aug 2023 03:23:34 -0400 Subject: [PATCH 14/22] test(overhead): Stop loading `api.lorem.space` images (#8833) This site is dead, lets remove the image loading from it. --------- Co-authored-by: Francesco Novy --- .../test-apps/booking-app/img/house-0.jpg | Bin 0 -> 34805 bytes .../test-apps/booking-app/img/house-1.jpg | Bin 0 -> 42043 bytes .../test-apps/booking-app/img/house-2.jpg | Bin 0 -> 46950 bytes .../test-apps/booking-app/main.js | 5 ++++- 4 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 packages/overhead-metrics/test-apps/booking-app/img/house-0.jpg create mode 100644 packages/overhead-metrics/test-apps/booking-app/img/house-1.jpg create mode 100644 packages/overhead-metrics/test-apps/booking-app/img/house-2.jpg 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 0000000000000000000000000000000000000000..ff0962fc24f1fefe767773d4c39fe4a6d53af54f GIT binary patch literal 34805 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 Wr?CG3MnBK4uTyC?6u48Eum9QJ)41CJ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..138f0b58ab2e16fd8057a7651e62ebce7e7e02f9 GIT binary patch literal 42043 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 LvuK}|V!!{{-{KkG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..31f6bc1d5184ef0396d3a0580d16d106c5c921a1 GIT binary patch literal 46950 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 IN4eAg+5O*}s{jB1 literal 0 HcmV?d00001 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}}
From 2d80b4b2cfabb69f0cfd4a96ea637a8cabbd37cb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 18 Aug 2023 14:21:44 +0200 Subject: [PATCH 15/22] build(angular): Fix Nx dependency graph for Angular (#8841) Angular `build:transpile` actually depends on types, so we need to reflect that - otherwise, `yarn build:dev` fails if no types are built yet. --- packages/angular-ivy/package.json | 13 ++++++++++++- packages/angular/package.json | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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" + ] + } + } + } } From 81efb87d3cc74f4b2c809dec023ab7bdd390a79c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 28 Aug 2023 09:10:37 +0200 Subject: [PATCH 16/22] chore(repo): Fix Package: SvelteKit/Svelte label assignment workflow (#8858) --- .github/workflows/issue-package-label.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" }, From 891a44ec7c564bf764d3ff9c380837e73ad8e413 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 28 Aug 2023 10:51:50 +0200 Subject: [PATCH 17/22] fix(tracing): Better guarding for performance observer (#8872) This removes a type cast for the performance observer and actually adds some guards to make sure we do not run into cases where a property we expect to exist does not exist. It seems we sometimes ran into cases where `nextHopProtocol` would be `undefined`, not a string, leading to https://github.com/getsentry/sentry-javascript/blob/develop/packages/tracing-internal/src/browser/request.ts#L202 failing. I now specifically check for the existence of this property, as well as also adding a default for all the time based stuff (0) to ensure these also work in the case one of the fields does not exist (instead of checking for existence of all of them). Closes https://github.com/getsentry/sentry-javascript/issues/8870 Closes https://github.com/getsentry/sentry-javascript/issues/8863 --- packages/tracing-internal/src/browser/request.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index e24c726ada5f..32c8fefd5474 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -166,6 +166,15 @@ 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 +229,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; } From 478b5e25962fb3d26af0a4c9bfb6168c517c174a Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 28 Aug 2023 10:40:36 +0100 Subject: [PATCH 18/22] fix(nextjs): Fix `requestAsyncStorageShim` path resolution on windows (#8875) --- packages/nextjs/package.json | 10 ++++++++++ packages/nextjs/src/config/loaders/wrappingLoader.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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/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, '\\\\')); From 4be150ec2758ee5e3abaacc48123c3774cff7c6f Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 28 Aug 2023 10:48:34 +0100 Subject: [PATCH 19/22] deps(sveltekit): Bump `@sentry/vite-plugin` (#8877) --- packages/sveltekit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 2c74e6e78ede..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" }, From 5afb8613cf8304a4759a20b95ba82f8a4f61dbe0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 28 Aug 2023 13:09:01 +0200 Subject: [PATCH 20/22] fix(node): Log entire error object in `OnUncaughtException` (#8876) In our `OnUncaughtException` integration, we have to emulate Node's default behaviour of logging errors to the console. For some reason though, we only logged the stack trace of an `error` with a stack trace instead of the entire error object. As reported in #8856, this causes additional properties on the error (such as `cause`) not to be logged anymore which doesn't reflect Node's default behaviour (see issue for comparison). This patch simplifies the `console.error` call to just always log the entire `error`. --- .../log-entire-error-to-console.js | 7 +++++++ .../suites/public-api/OnUncaughtException/test.ts | 15 +++++++++++++++ .../node/src/integrations/utils/errorhandling.ts | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/node-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js 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/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(); From 341bc4c8c2b4db4295ede2f41eab5f025811a44a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 28 Aug 2023 13:35:58 +0200 Subject: [PATCH 21/22] ref(tracing-internal): Deprecate `tracePropagationTargets` in `BrowserTracing` (#8874) This PR deprecates `BrowserTracing`'s `tracePropagationTargets` option in favour of the top-level Sentry.init option. When introducing Tracing without Performance, we opted to [promote `tracePropagationTargets` to a top-level option](https://github.com/getsentry/sentry-javascript/pull/8395). We deprecated the integration-level option in Node's `Http` integration but not in `BrowserTracing`. This patch fixes that. --- packages/node/src/integrations/http.ts | 2 ++ packages/sveltekit/test/client/sdk.test.ts | 7 ++----- packages/tracing-internal/src/browser/browsertracing.ts | 1 + packages/tracing-internal/src/browser/request.ts | 6 +++++- 4 files changed, 10 insertions(+), 6 deletions(-) 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/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/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 32c8fefd5474..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 Date: Mon, 28 Aug 2023 12:07:47 +0000 Subject: [PATCH 22/22] meta(changelog): Update changelog for 7.65.0 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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)