From e9aac32f9b19f8d510677d01f38e97e229b13b7c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 2 Feb 2023 15:43:54 +0000 Subject: [PATCH] feat: Wrap callbacks in wrapper to better handle errors --- packages/rrweb/package.json | 4 +- packages/rrweb/src/record/index.ts | 3 +- packages/rrweb/src/record/observer.ts | 338 +++++++++++------- packages/rrweb/src/sentry/callbackWrapper.ts | 22 ++ packages/rrweb/test/record/errors.test.ts | 265 ++++++++++++++ packages/rrweb/test/utils.ts | 2 +- .../rrweb/typings/sentry/callbackWrapper.d.ts | 1 + 7 files changed, 494 insertions(+), 141 deletions(-) create mode 100644 packages/rrweb/src/sentry/callbackWrapper.ts create mode 100644 packages/rrweb/test/record/errors.test.ts create mode 100644 packages/rrweb/typings/sentry/callbackWrapper.d.ts diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 48ab4bd708..e75b903193 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -9,8 +9,8 @@ "prepare": "npm run prepack", "prepack": "npm run bundle", "test": "npm run bundle:browser && jest", - "test:headless": "npm run bundle:browser && PUPPETEER_HEADLESS=true jest", - "test:watch": "PUPPETEER_HEADLESS=true npm run test -- --watch", + "test:debug": "npm run bundle:browser && PUPPETEER_DEBUG=true jest", + "test:watch": "npm run test -- --watch", "repl": "npm run bundle:browser && node scripts/repl.js", "dev": "yarn bundle:browser --watch", "bundle:browser": "cross-env BROWSER_ONLY=true rollup --config", diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 0073f91dcb..7dcc3823fe 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -27,6 +27,7 @@ import { import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; +import { callbackWrapper } from '../sentry/callbackWrapper'; function wrapEvent(e: event): eventWithTime { return { @@ -343,7 +344,7 @@ function record( ); const observe = (doc: Document) => { - return initObservers( + return callbackWrapper(initObservers)( { mutationCb: wrappedMutationEmit, mousemoveCb: (positions, source) => diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index b92d0251ce..4e62037d49 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -41,6 +41,7 @@ import { MutationBufferParam, } from '../types'; import MutationBuffer from './mutation'; +import { callbackWrapper } from '../sentry/callbackWrapper'; type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; @@ -111,9 +112,11 @@ export function initMutationObserver( typeof MutationObserver >)[angularZoneSymbol]; } + const observer = new mutationObserverCtor( - mutationBuffer.processMutations.bind(mutationBuffer), + callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)), ); + observer.observe(rootEl, { attributes: true, attributeOldValue: true, @@ -152,7 +155,7 @@ function initMoveObserver({ | IncrementalSource.Drag, ) => { const totalOffset = Date.now() - timeBaseline!; - mousemoveCb( + callbackWrapper(mousemoveCb)( positions.map((p) => { p.timeOffset -= totalOffset; return p; @@ -195,13 +198,13 @@ function initMoveObserver({ }, ); const handlers = [ - on('mousemove', updatePosition, doc), - on('touchmove', updatePosition, doc), - on('drag', updatePosition, doc), + on('mousemove', callbackWrapper(updatePosition), doc), + on('touchmove', callbackWrapper(updatePosition), doc), + on('drag', callbackWrapper(updatePosition), doc), ]; - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function initMouseInteractionObserver({ @@ -233,7 +236,7 @@ function initMouseInteractionObserver({ } const id = mirror.getId(target as INode); const { clientX, clientY } = e; - mouseInteractionCb({ + callbackWrapper(mouseInteractionCb)({ type: MouseInteractions[eventKey], id, x: clientX, @@ -250,12 +253,12 @@ function initMouseInteractionObserver({ ) .forEach((eventKey: keyof typeof MouseInteractions) => { const eventName = eventKey.toLowerCase(); - const handler = getHandler(eventKey); + const handler = callbackWrapper(getHandler(eventKey)); handlers.push(on(eventName, handler, doc)); }); - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } export function initScrollObserver({ @@ -276,20 +279,20 @@ export function initScrollObserver({ const id = mirror.getId(target as INode); if (target === doc) { const scrollEl = (doc.scrollingElement || doc.documentElement)!; - scrollCb({ + callbackWrapper(scrollCb)({ id, x: scrollEl.scrollLeft, y: scrollEl.scrollTop, }); } else { - scrollCb({ + callbackWrapper(scrollCb)({ id, x: (target as HTMLElement).scrollLeft, y: (target as HTMLElement).scrollTop, }); } }, sampling.scroll || 100); - return on('scroll', updatePosition, doc); + return on('scroll', callbackWrapper(updatePosition), doc); } function initViewportResizeObserver({ @@ -301,7 +304,7 @@ function initViewportResizeObserver({ const height = getWindowHeight(); const width = getWindowWidth(); if (lastH !== height || lastW !== width) { - viewportResizeCb({ + callbackWrapper(viewportResizeCb)({ width: Number(width), height: Number(height), }); @@ -309,7 +312,7 @@ function initViewportResizeObserver({ lastW = width; } }, 200); - return on('resize', updateDimension, window); + return on('resize', callbackWrapper(updateDimension), window); } function wrapEventWithUserTriggeredFlag( @@ -384,7 +387,7 @@ function initInputObserver({ } cbWithDedup( target, - wrapEventWithUserTriggeredFlag( + callbackWrapper(wrapEventWithUserTriggeredFlag)( { text, isChecked, userTriggered }, userTriggeredOnInput, ), @@ -399,7 +402,7 @@ function initInputObserver({ if (el !== target) { cbWithDedup( el, - wrapEventWithUserTriggeredFlag( + callbackWrapper(wrapEventWithUserTriggeredFlag)( { text: (el as HTMLInputElement).value, isChecked: !isChecked, @@ -430,7 +433,9 @@ function initInputObserver({ const events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const handlers: Array< listenerHandler | hookResetter - > = events.map((eventName) => on(eventName, eventHandler, doc)); + > = events.map((eventName) => + on(eventName, callbackWrapper(eventHandler), doc), + ); const propertyDescriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', @@ -450,15 +455,15 @@ function initInputObserver({ hookSetter(p[0], p[1], { set() { // mock to a normal event - eventHandler({ target: this } as Event); + callbackWrapper(eventHandler)({ target: this } as Event); }, }), ), ); } - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } type GroupingCSSRule = @@ -510,31 +515,44 @@ function initStyleSheetObserver( } const insertRule = win.CSSStyleSheet.prototype.insertRule; - win.CSSStyleSheet.prototype.insertRule = function ( - rule: string, - index?: number, - ) { - const id = mirror.getId(this.ownerNode as INode); - if (id !== -1) { - styleSheetRuleCb({ - id, - adds: [{ rule, index }], - }); - } - return insertRule.apply(this, arguments); - }; + win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, { + apply: callbackWrapper( + ( + target: typeof insertRule, + thisArg: any, + argumentsList: [string, number | undefined], + ) => { + const [rule, index] = argumentsList; + const id = mirror.getId(thisArg.ownerNode as INode); + + if (id !== -1) { + styleSheetRuleCb({ + id, + adds: [{ rule, index }], + }); + } + + return target.apply(thisArg, argumentsList); + }, + ), + }); const deleteRule = win.CSSStyleSheet.prototype.deleteRule; - win.CSSStyleSheet.prototype.deleteRule = function (index: number) { - const id = mirror.getId(this.ownerNode as INode); - if (id !== -1) { - styleSheetRuleCb({ - id, - removes: [{ index }], - }); - } - return deleteRule.apply(this, arguments); - }; + win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, { + apply: callbackWrapper( + (target: typeof deleteRule, thisArg: any, argumentsList: [number]) => { + const [index] = argumentsList; + const id = mirror.getId(thisArg.ownerNode as INode); + if (id !== -1) { + styleSheetRuleCb({ + id, + removes: [{ index }], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); const supportedNestedCSSRuleTypes: { [key: string]: GroupingCSSRuleTypes; @@ -570,45 +588,76 @@ function initStyleSheetObserver( deleteRule: (type as GroupingCSSRuleTypes).prototype.deleteRule, }; - type.prototype.insertRule = function (rule: string, index?: number) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); - if (id !== -1) { - styleSheetRuleCb({ - id, - adds: [ - { - rule, - index: [ - ...getNestedCSSRulePositions(this), - index || 0, // defaults to 0 - ], - }, - ], - }); - } - return unmodifiedFunctions[typeKey].insertRule.apply(this, arguments); - }; + type.prototype.insertRule = new Proxy( + unmodifiedFunctions[typeKey].insertRule, + { + apply: callbackWrapper( + ( + target: typeof insertRule, + thisArg: any, + argumentsList: [string, number | undefined], + ) => { + const [rule, index] = argumentsList; + const id = mirror.getId( + thisArg.parentStyleSheet.ownerNode as INode, + ); + if (id !== -1) { + styleSheetRuleCb({ + id, + adds: [ + { + rule, + index: [ + ...getNestedCSSRulePositions(thisArg), + index || 0, // defaults to 0 + ], + }, + ], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }, + ); - type.prototype.deleteRule = function (index: number) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); - if (id !== -1) { - styleSheetRuleCb({ - id, - removes: [{ index: [...getNestedCSSRulePositions(this), index] }], - }); - } - return unmodifiedFunctions[typeKey].deleteRule.apply(this, arguments); - }; + type.prototype.deleteRule = new Proxy( + unmodifiedFunctions[typeKey].deleteRule, + { + apply: callbackWrapper( + ( + target: typeof deleteRule, + thisArg: any, + argumentsList: [number], + ) => { + const [index] = argumentsList; + + const id = mirror.getId( + thisArg.parentStyleSheet.ownerNode as INode, + ); + if (id !== -1) { + styleSheetRuleCb({ + id, + removes: [ + { index: [...getNestedCSSRulePositions(thisArg), index] }, + ], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }, + ); }); - return () => { + return callbackWrapper(() => { win.CSSStyleSheet.prototype.insertRule = insertRule; win.CSSStyleSheet.prototype.deleteRule = deleteRule; Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; }); - }; + }); } function initStyleDeclarationObserver( @@ -616,53 +665,65 @@ function initStyleDeclarationObserver( { win }: { win: IWindow }, ): listenerHandler { const setProperty = win.CSSStyleDeclaration.prototype.setProperty; - win.CSSStyleDeclaration.prototype.setProperty = function ( - this: CSSStyleDeclaration, - property: string, - value: string, - priority: string, - ) { - const id = mirror.getId( - (this.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, - ); - if (id !== -1) { - styleDeclarationCb({ - id, - set: { - property, - value, - priority, - }, - index: getNestedCSSRulePositions(this.parentRule!), - }); - } - return setProperty.apply(this, arguments); - }; + + win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, { + apply: callbackWrapper( + ( + target: typeof setProperty, + thisArg: any, + argumentsList: [string, string, string], + ) => { + const [property, value, priority] = argumentsList; + const id = mirror.getId( + (thisArg.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, + ); + if (id !== -1) { + styleDeclarationCb({ + id, + set: { + property, + value, + priority, + }, + index: getNestedCSSRulePositions(thisArg.parentRule!), + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty; - win.CSSStyleDeclaration.prototype.removeProperty = function ( - this: CSSStyleDeclaration, - property: string, - ) { - const id = mirror.getId( - (this.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, - ); - if (id !== -1) { - styleDeclarationCb({ - id, - remove: { - property, - }, - index: getNestedCSSRulePositions(this.parentRule!), - }); - } - return removeProperty.apply(this, arguments); - }; + win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, { + apply: callbackWrapper( + ( + target: typeof removeProperty, + thisArg: any, + argumentsList: [string], + ) => { + const [property] = argumentsList; + + const id = mirror.getId( + (thisArg.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, + ); + if (id !== -1) { + styleDeclarationCb({ + id, + remove: { + property, + }, + index: getNestedCSSRulePositions(thisArg.parentRule!), + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); - return () => { + return callbackWrapper(() => { win.CSSStyleDeclaration.prototype.setProperty = setProperty; win.CSSStyleDeclaration.prototype.removeProperty = removeProperty; - }; + }); } function initMediaInteractionObserver({ @@ -672,29 +733,32 @@ function initMediaInteractionObserver({ sampling, }: observerParam): listenerHandler { const handler = (type: MediaInteractions) => - throttle((event: Event) => { - const target = getEventTarget(event); - if (!target || isBlocked(target as Node, blockClass)) { - return; - } - const { currentTime, volume, muted } = target as HTMLMediaElement; - mediaInteractionCb({ - type, - id: mirror.getId(target as INode), - currentTime, - volume, - muted, - }); - }, sampling.media || 500); + throttle( + callbackWrapper((event: Event) => { + const target = getEventTarget(event); + if (!target || isBlocked(target as Node, blockClass)) { + return; + } + const { currentTime, volume, muted } = target as HTMLMediaElement; + mediaInteractionCb({ + type, + id: mirror.getId(target as INode), + currentTime, + volume, + muted, + }); + }), + sampling.media || 500, + ); const handlers = [ on('play', handler(MediaInteractions.Play)), on('pause', handler(MediaInteractions.Pause)), on('seeked', handler(MediaInteractions.Seeked)), on('volumechange', handler(MediaInteractions.VolumeChange)), ]; - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { @@ -745,9 +809,9 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { }); handlers.push(restoreHandler); - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function mergeHooks(o: observerParam, hooks: hooksParam) { @@ -863,7 +927,7 @@ export function initObservers( ); } - return () => { + return callbackWrapper(() => { mutationBuffers.forEach((b) => b.reset()); mutationObserver.disconnect(); mousemoveHandler(); @@ -880,5 +944,5 @@ export function initObservers( } fontObserver(); pluginHandlers.forEach((h) => h()); - }; + }); } diff --git a/packages/rrweb/src/sentry/callbackWrapper.ts b/packages/rrweb/src/sentry/callbackWrapper.ts new file mode 100644 index 0000000000..f06708f144 --- /dev/null +++ b/packages/rrweb/src/sentry/callbackWrapper.ts @@ -0,0 +1,22 @@ +/** + * Try to set `__rrweb__` on all errors thrown by the given callback. + * This can be used to filter these/handle them specifically. + */ +export const callbackWrapper = (cb: T): T => { + // @ts-ignore + const rrwebWrapped: T = (...rest: any[]) => { + try { + return cb(...rest); + } catch (error) { + try { + error.__rrweb__ = true; + } catch { + // ignore errors here + } + + throw error; + } + }; + + return rrwebWrapped; +}; diff --git a/packages/rrweb/test/record/errors.test.ts b/packages/rrweb/test/record/errors.test.ts new file mode 100644 index 0000000000..44d28760de --- /dev/null +++ b/packages/rrweb/test/record/errors.test.ts @@ -0,0 +1,265 @@ +/* tslint:disable no-console */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as puppeteer from 'puppeteer'; +import { + recordOptions, + listenerHandler, + eventWithTime, + EventType, +} from '../../src/types'; +import { launchPuppeteer } from '../utils'; + +interface ISuite { + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; + events: eventWithTime[]; +} + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + addCustomEvent(tag: string, payload: T): void; + }; + emit: (e: eventWithTime) => undefined; +} + +const setup = function (this: ISuite, content: string): ISuite { + const ctx = {} as ISuite; + + beforeAll(async () => { + ctx.browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto('about:blank'); + await ctx.page.setContent(content); + await ctx.page.evaluate(ctx.code); + ctx.events = []; + await ctx.page.exposeFunction('emit', (e: eventWithTime) => { + if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { + return; + } + ctx.events.push(e); + }); + + ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + }); + }); + + afterEach(async () => { + await ctx.page.close(); + }); + + afterAll(async () => { + await ctx.browser.close(); + }); + + return ctx; +}; + +describe('record errors', function (this: ISuite) { + jest.setTimeout(100_000); + + const ctx: ISuite = setup.call( + this, + ` + + + + + + +
+
+ + + + + `, + ); + + describe('CSSStyleSheet.prototype', () => { + it('will tag errors from insertRule with __rrweb__', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.insertRule = function () { + // @ts-ignore + window.doSomethignWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + // @ts-ignore + document.styleSheets[0].insertRule( + 'body { background: blue; }', + ); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('__rrweb__'); + }); + + it('will tag errors from deleteRule with __rrweb__', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.deleteRule = function () { + // @ts-ignore + window.doSomethignWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + + // Trigger buggy style sheet delete + setTimeout(() => { + document.styleSheets[0].deleteRule(0); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('__rrweb__'); + }); + + it('will tag errors from CSSGroupingRule.insertRule with __rrweb__', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSGroupingRule.prototype.insertRule = function () { + // @ts-ignore + window.doSomethignWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + document.styleSheets[0].insertRule('@media {}'); + const atMediaRule = document.styleSheets[0] + .cssRules[0] as CSSMediaRule; + + const ruleIdx0 = atMediaRule.insertRule( + 'body { background: #000; }', + 0, + ); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('__rrweb__'); + }); + + it('will tag errors from CSSGroupingRule.deleteRule with __rrweb__', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSGroupingRule.prototype.deleteRule = function () { + // @ts-ignore + window.doSomethignWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + + // Trigger buggy style sheet delete + setTimeout(() => { + document.styleSheets[0].insertRule('@media {}'); + const atMediaRule = document.styleSheets[0] + .cssRules[0] as CSSMediaRule; + + const ruleIdx0 = atMediaRule.deleteRule(0); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('__rrweb__'); + }); + }); + + it('will tag errors from mutation observer with __rrweb__', async () => { + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + recordCanvas: true, + emit: ((window as unknown) as IWindow).emit, + }); + + // Trigger buggy mutation observer + setTimeout(() => { + const el = document.getElementById('in')!; + + // @ts-ignore we want to trigger an error in the mutation observer, which uses this + el.getAttribute = undefined; + + el.setAttribute('data-attr', 'new'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('__rrweb__'); + }); +}); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 384702121f..299d705174 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -17,7 +17,7 @@ import * as fs from 'fs'; export async function launchPuppeteer() { return await puppeteer.launch({ - headless: process.env.PUPPETEER_HEADLESS ? true : false, + headless: process.env.PUPPETEER_DEBUG ? false : true, defaultViewport: { width: 1920, height: 1080, diff --git a/packages/rrweb/typings/sentry/callbackWrapper.d.ts b/packages/rrweb/typings/sentry/callbackWrapper.d.ts new file mode 100644 index 0000000000..9f74ec42d2 --- /dev/null +++ b/packages/rrweb/typings/sentry/callbackWrapper.d.ts @@ -0,0 +1 @@ +export declare const callbackWrapper: (cb: T) => T;