dd-trace
Version:
Datadog APM tracing client for JavaScript
262 lines (219 loc) • 8.37 kB
JavaScript
const log = require('../../dd-trace/src/log')
function copyProperties (original, wrapped) {
// TODO getPrototypeOf is not fast. Should we instead do this in specific
// instrumentations where needed?
const proto = Object.getPrototypeOf(original)
if (proto !== Function.prototype) {
Object.setPrototypeOf(wrapped, proto)
}
const props = Object.getOwnPropertyDescriptors(original)
const keys = Reflect.ownKeys(props)
for (const key of keys) {
try {
Object.defineProperty(wrapped, key, props[key])
} catch (e) {
// TODO: figure out how to handle this without a try/catch
}
}
}
function wrapFunction (original, wrapper) {
if (typeof original === 'function') assertNotClass(original)
const wrapped = safeMode
? safeWrapper(original, wrapper)
: wrapper(original)
if (typeof original === 'function') copyProperties(original, wrapped)
return wrapped
}
const wrapFn = function (original, delegate) {
throw new Error('calling `wrap()` with 2 args is deprecated. Use wrapFunction instead.')
}
// This is only used in safe mode. It's a simple state machine to track if the
// original method was called and if it returned. We need this to determine if
// an error was thrown by the original method, or by us. We'll use one of these
// per call to a wrapped method.
class CallState {
constructor () {
this.called = false
this.completed = false
this.retVal = undefined
}
startCall () {
this.called = true
}
endCall (retVal) {
this.completed = true
this.retVal = retVal
}
}
function isPromise (obj) {
return obj && typeof obj === 'object' && typeof obj.then === 'function'
}
let safeMode = !!process.env.DD_INEJCTION_ENABLED
function setSafe (value) {
safeMode = value
}
function wrapMethod (target, name, wrapper, noAssert) {
if (!noAssert) {
assertMethod(target, name)
assertFunction(wrapper)
}
const original = target[name]
const wrapped = safeMode && original
? safeWrapper(original, wrapper)
: wrapper(original)
const descriptor = Object.getOwnPropertyDescriptor(target, name)
const attributes = {
configurable: true,
...descriptor
}
if (typeof original === 'function') copyProperties(original, wrapped)
if (descriptor) {
if (descriptor.get || descriptor.set) {
attributes.get = () => wrapped
} else {
attributes.value = wrapped
}
// TODO: create a single object for multiple wrapped methods
if (descriptor.configurable === false) {
return Object.create(target, {
[name]: attributes
})
}
} else { // no descriptor means original was on the prototype
attributes.value = wrapped
attributes.writable = true
}
Object.defineProperty(target, name, attributes)
return target
}
function safeWrapper (original, wrapper) {
// In this mode, we make a best-effort attempt to handle errors that are thrown
// by us, rather than wrapped code. With such errors, we log them, and then attempt
// to return the result as if no wrapping was done at all.
//
// Caveats:
// * If the original function is called in a later iteration of the event loop,
// and we throw _then_, then it won't be caught by this. In practice, we always call
// the original function synchronously, so this is not a problem.
// * While async errors are dealt with here, errors in callbacks are not. This
// is because we don't necessarily know _for sure_ that any function arguments
// are wrapped by us. We could wrap them all anyway and just make that assumption,
// or just assume that the last argument is always a callback set by us if it's a
// function, but those don't seem like things we can rely on. We could add a
// `shimmer.markCallbackAsWrapped()` function that's a no-op outside safe-mode,
// but that means modifying every instrumentation. Even then, the complexity of
// this code increases because then we'd need to effectively do the reverse of
// what we're doing for synchronous functions. This is a TODO.
// We're going to hold on to current callState in this variable in this scope,
// which is fine because any time we reference it, we're referencing it synchronously.
// We'll use it in the our wrapper (which, again, is called syncrhonously), and in the
// errorHandler, which will already have been bound to this callState.
let currentCallState
// Rather than calling the original function directly from the shim wrapper, we wrap
// it again so that we can track if it was called and if it returned. This is because
// we need to know if an error was thrown by the original function, or by us.
// We could do this inside the `wrapper` function defined below, which would simplify
// managing the callState, but then we'd be calling `wrapper` on each invocation, so
// instead we do it here, once.
const innerWrapped = wrapper(function (...args) {
// We need to stash the callState here because of recursion.
const callState = currentCallState
callState.startCall()
const retVal = original.apply(this, args)
if (isPromise(retVal)) {
retVal.then(callState.endCall.bind(callState))
} else {
callState.endCall(retVal)
}
return retVal
})
// This is the crux of what we're doing in safe mode. It handles errors
// that _we_ cause, by logging them, and transparently providing results
// as if no wrapping was done at all. That means detecting (via callState)
// whether the function has already run or not, and if it has, returning
// the result, and otherwise calling the original function unwrapped.
const handleError = function (args, callState, e) {
if (callState.completed) {
// error was thrown after original function returned/resolved, so
// it was us. log it.
log.error('Shimmer error was thrown after original function returned/resolved', e)
// original ran and returned something. return it.
return callState.retVal
}
if (!callState.called) {
// error was thrown before original function was called, so
// it was us. log it.
log.error('Shimmer error was thrown before original function was called', e)
// original never ran. call it unwrapped.
return original.apply(this, args)
}
// error was thrown during original function execution, so
// it was them. throw.
throw e
}
// The wrapped function is the one that will be called by the user.
// It calls our version of the original function, which manages the
// callState. That way when we use the errorHandler, it can tell where
// the error originated.
return function (...args) {
currentCallState = new CallState()
const errorHandler = handleError.bind(this, args, currentCallState)
try {
const retVal = innerWrapped.apply(this, args)
return isPromise(retVal) ? retVal.catch(errorHandler) : retVal
} catch (e) {
return errorHandler(e)
}
}
}
function wrap (target, name, wrapper) {
return typeof name === 'function'
? wrapFn(target, name)
: wrapMethod(target, name, wrapper)
}
function massWrap (targets, names, wrapper) {
targets = toArray(targets)
names = toArray(names)
for (const target of targets) {
for (const name of names) {
wrap(target, name, wrapper)
}
}
}
function toArray (maybeArray) {
return Array.isArray(maybeArray) ? maybeArray : [maybeArray]
}
function assertMethod (target, name) {
if (!target) {
throw new Error('No target object provided.')
}
if (typeof target !== 'object' && typeof target !== 'function') {
throw new Error('Invalid target.')
}
if (!target[name]) {
throw new Error(`No original method ${name}.`)
}
if (typeof target[name] !== 'function') {
throw new Error(`Original method ${name} is not a function.`)
}
}
function assertFunction (target) {
if (!target) {
throw new Error('No function provided.')
}
if (typeof target !== 'function') {
throw new Error('Target is not a function.')
}
}
function assertNotClass (target) {
if (Function.prototype.toString.call(target).startsWith('class')) {
throw new Error('Target is a native class constructor and cannot be wrapped.')
}
}
module.exports = {
wrap,
wrapFunction,
massWrap,
setSafe
}