Skip to content

Commit

Permalink
πŸ› [RUM-3599] do not define undefined instrumented method (#2662)
Browse files Browse the repository at this point in the history
* πŸ› [RUM-3599] do not define undefined instrumented method

* use the polyfilled `startsWith`

* πŸ‘ŒπŸ“ add some documentation regarding event handlers
  • Loading branch information
BenoitZugmeyer authored Mar 22, 2024
1 parent 6a3e170 commit 17251cb
Show file tree
Hide file tree
Showing 2 changed files with 48 additions and 16 deletions.
31 changes: 23 additions & 8 deletions packages/core/src/tools/instrumentMethod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,23 @@ describe('instrumentMethod', () => {
expect(instrumentationSpy).toHaveBeenCalledBefore(originalSpy)
})

it('sets a method originally undefined', () => {
it('does not set a method originally undefined', () => {
const object: { method?: () => number } = {}

instrumentMethod(object, 'method', noop)

expect(object.method).toBeUndefined()
})

it('sets an event handler even if it was originally undefined', () => {
const object: { onevent?: () => void } = {}

const instrumentationSpy = jasmine.createSpy()
instrumentMethod(object, 'method', instrumentationSpy)
instrumentMethod(object, 'onevent', instrumentationSpy)

expect(object.method).toBeDefined()
object.method!()
expect(object.onevent).toBeDefined()

object.onevent!()
expect(instrumentationSpy).toHaveBeenCalled()
})

Expand Down Expand Up @@ -133,27 +141,34 @@ describe('instrumentMethod', () => {
})

it('should not throw errors if original method was undefined', () => {
const object: { method?: () => number } = {}
const object: { onevent?: () => number } = {}
const instrumentationStub = () => 2
const { stop } = instrumentMethod(object, 'method', instrumentationStub)
const { stop } = instrumentMethod(object, 'onevent', instrumentationStub)

thirdPartyInstrumentation(object)

stop()

expect(object.method).not.toThrow()
expect(object.onevent).not.toThrow()
})
})
})

function thirdPartyInstrumentation(object: { method?: () => number }) {
function thirdPartyInstrumentation(object: { method?: () => number; onevent?: () => void }) {
const originalMethod = object.method
if (typeof originalMethod === 'function') {
object.method = () => {
originalMethod()
return THIRD_PARTY_RESULT
}
}

const originalOnEvent = object.onevent
object.onevent = () => {
if (originalOnEvent) {
originalOnEvent()
}
}
}
})

Expand Down
33 changes: 25 additions & 8 deletions packages/core/src/tools/instrumentMethod.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { setTimeout } from './timer'
import { callMonitored } from './monitor'
import { noop } from './utils/functionUtils'
import { arrayFrom } from './utils/polyfills'
import { arrayFrom, startsWith } from './utils/polyfills'

/**
* Object passed to the callback of an instrumented method call. See `instrumentMethod` for more
Expand Down Expand Up @@ -35,11 +35,23 @@ type PostCallCallback<TARGET extends { [key: string]: any }, METHOD extends keyo
* Instruments a method on a object, calling the given callback before the original method is
* invoked. The callback receives an object with information about the method call.
*
* This function makes sure that we are "good citizens" regarding third party instrumentations: when
* removing the instrumentation, the original method is usually restored, but if a third party
* instrumentation was set after ours, we keep it in place and just replace our instrumentation with
* a noop.
*
* Note: it is generally better to instrument methods that are "owned" by the object instead of ones
* that are inherited from the prototype chain. Example:
* * do: `instrumentMethod(Array.prototype, 'push', ...)`
* * don't: `instrumentMethod([], 'push', ...)`
*
* This method is also used to set event handler properties (ex: window.onerror = ...), as it has
* the same requirements as instrumenting a method:
* * if the event handler is already set by a third party, we need to call it and not just blindly
* override it.
* * if the event handler is set by a third party after us, we need to keep it in place when
* removing ours.
*
* @example
*
* instrumentMethod(window, 'fetch', ({ target, parameters, onPostCall }) => {
Expand All @@ -50,12 +62,20 @@ type PostCallCallback<TARGET extends { [key: string]: any }, METHOD extends keyo
* })
* })
*/
export function instrumentMethod<TARGET extends { [key: string]: any }, METHOD extends keyof TARGET>(
export function instrumentMethod<TARGET extends { [key: string]: any }, METHOD extends keyof TARGET & string>(
targetPrototype: TARGET,
method: METHOD,
onPreCall: (this: null, callInfos: InstrumentedMethodCall<TARGET, METHOD>) => void
) {
const original = targetPrototype[method]
let original = targetPrototype[method]

if (typeof original !== 'function') {
if (startsWith(method, 'on')) {
original = noop as TARGET[METHOD]
} else {
return { stop: noop }
}
}

let instrumentation = createInstrumentedMethod(original, onPreCall)

Expand Down Expand Up @@ -86,7 +106,6 @@ function createInstrumentedMethod<TARGET extends { [key: string]: any }, METHOD
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return function (this: TARGET) {
const parameters = arrayFrom(arguments) as Parameters<TARGET[METHOD]>
let result

let postCallCallback: PostCallCallback<TARGET, METHOD> | undefined

Expand All @@ -100,10 +119,8 @@ function createInstrumentedMethod<TARGET extends { [key: string]: any }, METHOD
},
])

if (typeof original === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
result = original.apply(this, parameters)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = original.apply(this, parameters)

if (postCallCallback) {
callMonitored(postCallCallback, null, [result])
Expand Down

0 comments on commit 17251cb

Please sign in to comment.