lib_shim_promise-shim.js

/*
 * Copyright 2020 New Relic Corporation. All rights reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

'use strict'

const logger = require('../logger').child({ component: 'PromiseShim' })
const Shim = require('./shim')
const symbols = require('../symbols')
const { ClassWrapSpec } = require('./specs')

/**
 * Checks if function is actually not a function
 * or it is wrapped
 *
 * @private
 * @param {Shim} shim instance of shim
 * @param {Function} fn function to wrap
 * @returns {boolean} is already wrapped or not
 */
function isWrapped(shim, fn) {
  return shim.isFunction(fn) === false || shim.isWrapped(fn)
}

/**
 * A helper class for wrapping promise modules.
 *
 * @augments Shim
 */
/* eslint-disable camelcase */
class PromiseShim extends Shim {
  /**
   * Constructs a shim associated with the given agent instance, specialized for
   * instrumenting promise libraries.
   *
   * @param {Agent} agent The agent this shim will use.
   * @param {string} moduleName The name of the module being instrumented.
   * @param {string} resolvedName The full path to the loaded module.
   * @param {string} shimName Used to persist shim ids across different shim instances.
   * @param {string} pkgVersion version of module
   * @see Shim
   */
  constructor(agent, moduleName, resolvedName, shimName, pkgVersion) {
    super(agent, moduleName, resolvedName, shimName, pkgVersion)
    this._logger = logger.child({ module: moduleName })
    this._class = null
  }

  /**
   * Grants access to the `Contextualizer` class used by the `PromiseShim` to
   * propagate context down promise chains.
   *
   * @private
   * @returns {Contextualizer} contextualizer class
   */
  static get Contextualizer() {
    return Contextualizer
  }

  /**
   * Sets the class used to identify promises from the wrapped promise library.
   *
   * @param {Function} clss - The promise library's class.
   */
  setClass(clss) {
    this._class = clss
  }

  /**
   * Checks if the given object is an instance of a promise from the promise
   * library being wrapped.
   *
   * @param {*} obj - The object to check the instance type of.
   * @returns {boolean} True if the provided object is an instance of a promise from
   *  this promise library.
   * @see PromiseShim#setClass
   */
  isPromiseInstance(obj) {
    return !!this._class && obj instanceof this._class
  }

  /**
   * Wraps the given properties as constructors for the promise library.
   *
   * - `wrapConstructor(nodule, properties)`
   * - `wrapConstructor(func)`
   *
   * It is only necessary to wrap the constructor for the class if there is no
   * other way to access the executor function. Some libraries expose a separate
   * method which is called to execute the executor. If that is available, it is
   * better to wrap that using {@link PromiseShim#wrapExecutorCaller} than to
   * use this method.
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the constructor to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   * @see PromiseShim#wrapExecutorCaller
   */
  wrapConstructor(nodule, properties) {
    return this.wrapClass(
      nodule,
      properties,
      new ClassWrapSpec({
        pre: function prePromise(shim, Promise, name, args) {
          // We are expecting one function argument for executor, anything else is
          // non-standard, do not attempt to wrap. Also do not attempt to wrap if
          // we are not in a transaction.
          if (args.length !== 1 || !shim.isFunction(args[0]) || !shim.getActiveSegment()) {
            return
          }
          _wrapExecutorContext(shim, args)
        },
        post: function postPromise(shim, Promise, name, args) {
          // This extra property is added by `_wrapExecutorContext` in the pre step.
          const executor = args[0]
          const context = executor && executor[symbols.executorContext]
          if (!context || !shim.isFunction(context.executor)) {
            return
          }

          context.promise = this
          Contextualizer.link(null, this, shim.getSegment())
          try {
            // Must run after promise is defined so that `__NR_wrapper` can be set.
            context.executor.apply(context.self, context.args)
          } catch (e) {
            const reject = context.args[1]
            reject(e)
          }
        }
      })
    )
  }

  /**
   * Wraps the given properties as the caller of promise executors.
   *
   * - `wrapExecutorCaller(nodule, properties)`
   * - `wrapExecutorCaller(func)`
   *
   * Wrapping the executor caller method directly is preferable to wrapping
   * the constructor of the promise class.
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the function to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   * @see PromiseShim#wrapConstructor
   */
  wrapExecutorCaller(nodule, properties) {
    return this.wrap(nodule, properties, function executorWrapper(shim, caller) {
      if (isWrapped(shim, caller)) {
        return
      }

      return function wrappedExecutorCaller(executor) {
        const parent = shim.getActiveSegment()
        if (!this || !parent) {
          return caller.apply(this, arguments)
        }

        if (!this[symbols.context]) {
          Contextualizer.link(null, this, parent)
        }

        const args = shim.argsToArray.apply(shim, arguments)
        _wrapExecutorContext(shim, args)
        const ret = caller.apply(this, args)
        const context = args[0][symbols.executorContext]
        context.promise = this

        // Bluebird catches executor errors and auto-rejects when it catches them,
        // thus we need to do so as well.
        //
        // When adding new libraries, make sure to check that they behave the same
        // way. We may need to enhance the promise spec to handle this variance.
        try {
          executor.apply(context.self, context.args)
        } catch (e) {
          const reject = context.args[1]
          reject(e)
        }
        return ret
      }
    })
  }

  /**
   * Wraps the given properties as methods which take is some value other than
   * a function to call and return a promise.
   *
   * - `wrapCast(nodule, properties)`
   * - `wrapCast(func)`
   *
   * Examples of promise cast methods include `Promise.resolve`, `Promise.all`,
   * and Bluebird's `Promise.delay`. These are static methods which accept some
   * arbitrary value and return a Promise instance.
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the function to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   */
  wrapCast(nodule, properties) {
    return this.wrap(nodule, properties, function castWrapper(shim, cast) {
      if (isWrapped(shim, cast)) {
        return
      }

      return function __NR_wrappedCast() {
        const segment = shim.getSegment()
        const prom = cast.apply(this, arguments)
        if (segment) {
          Contextualizer.link(null, prom, segment)
        }
        return prom
      }
    })
  }

  /**
   * Wraps the given properties as promise chaining methods.
   *
   * - `wrapThen(nodule, properties)`
   * - `wrapThen(func)`
   *
   * NOTE: You must set class used by the library before wrapping then-methods.
   *
   * Examples of promise then methods include `Promise#then`, `Promise#finally`,
   * and Bluebird's `Promise#map`. These are methods which take a function to
   * execute once the promise resolves and hands back a new promise.
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the function to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   * @see PromiseShim#setClass
   * @see PromiseShim#wrapCatch
   */
  wrapThen(nodule, properties) {
    return this.wrap(nodule, properties, _wrapThen, [true])
  }

  /**
   * Wraps the given properties as rejected promise chaining methods.
   *
   * - `wrapCatch(nodule, properties)`
   * - `wrapCatch(func)`
   *
   * NOTE: You must set class used by the library before wrapping catch-methods.
   *
   * Promise catch methods differ from then methods in that only one function
   * will be executed and only if the promise is rejected. Some libraries accept
   * an additional argument to `Promise#catch` which is usually an error class
   * to filter rejections by. This wrap method will handle that case.
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the function to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   * @see PromiseShim#setClass
   * @see PromiseShim#wrapThen
   */
  wrapCatch(nodule, properties) {
    return this.wrap(nodule, properties, _wrapThen, [false])
  }

  /**
   * Wraps the given properties as callback-to-promise conversion methods.
   *
   * - `wrapPromisify(nodule, properties)`
   * - `wrapPromisify(func)`
   *
   * @param {object | Function} nodule
   *  The source of the properties to wrap, or a single function to wrap.
   * @param {string | Array.<string>} [properties]
   *  One or more properties to wrap. If omitted, the `nodule` parameter is
   *  assumed to be the function to wrap.
   * @returns {object | Function} The first parameter to this function, after
   *  wrapping it or its properties.
   */
  wrapPromisify(nodule, properties) {
    return this.wrap(nodule, properties, function promisifyWrapper(shim, promisify) {
      if (isWrapped(shim, promisify)) {
        return
      }

      return function __NR_wrappedPromisify() {
        const promisified = promisify.apply(this, arguments)
        if (typeof promisified !== 'function') {
          return promisified
        }

        Object.keys(promisified).forEach(function forEachProperty(prop) {
          __NR_wrappedPromisified[prop] = promisified[prop]
        })

        return __NR_wrappedPromisified
        /**
         * Returns wrapped promise that binds the active segment accordingly
         *
         * @returns {Promise} promise bound to active segment
         */
        function __NR_wrappedPromisified() {
          const segment = shim.getActiveSegment()
          if (!segment) {
            return promisified.apply(this, arguments)
          }

          const prom = shim.applySegment(promisified, segment, true, this, arguments)
          Contextualizer.link(null, prom, segment)
          return prom
        }
      }
    })
  }
}
module.exports = PromiseShim

// -------------------------------------------------------------------------- //

/**
 * @param {object} shim instance of shim
 * @param {Array} args arguments passed to executor function
 * @private
 */
function _wrapExecutorContext(shim, args) {
  const context = {
    executor: args[0],
    promise: null,
    self: null,
    args: null
  }
  contextExporter[symbols.executorContext] = context
  args[0] = contextExporter

  /**
   *
   * @param {Function} resolve function of promise
   * @param {Function} reject function of promise
   */
  function contextExporter(resolve, reject) {
    context.self = this
    context.args = shim.argsToArray.apply(shim, arguments)
    context.args[0] = _wrapResolver(context, resolve)
    context.args[1] = _wrapResolver(context, reject)
  }
}

/**
 * @private
 * @param {object} context context object
 * @param {Function} fn function that is wrapped
 * @returns {Function} wrapped function
 * @private
 */
function _wrapResolver(context, fn) {
  return function wrappedResolveReject(val) {
    const promise = context.promise
    if (promise && promise[symbols.context]) {
      promise[symbols.context].getSegment().touch()
    }
    fn(val)
  }
}

/**
 *
 * @private
 * @param {object} params object passed to wrapHandler
 * @param {Function} params.handler to wrap
 * @param {number} params.index index of argument
 * @param {number} params.argsLength length of args
 * @param {boolean} params.useAllParams flag to use all params
 * @param {object} params.ctx context passed in to store the next handler and isWrapped
 * @param {object} params.shim shim instance
 * @returns {Function} wrapped function
 */
function wrapHandler({ handler, index, argsLength, useAllParams, ctx, shim }) {
  if (
    isWrapped(shim, handler) ||
    (!useAllParams && index !== argsLength - 1) // Don't want all and not last
  ) {
    ctx.isWrapped = shim.isWrapped(handler)
    return handler
  }

  return function __NR_wrappedThenHandler() {
    if (!ctx.handler || !ctx.handler[symbols.context]) {
      return handler.apply(this, arguments)
    }

    let promSegment = ctx.handler[symbols.context].getSegment()
    const segment = promSegment || shim.getSegment()
    if (segment && segment !== promSegment) {
      ctx.handler[symbols.context].setSegment(segment)
      promSegment = segment
    }

    let ret = null
    try {
      ret = shim.applySegment(handler, promSegment, true, this, arguments)
    } finally {
      if (ret && typeof ret.then === 'function') {
        ret = ctx.handler[symbols.context].continueContext(ret)
      }
    }
    return ret
  }
}

/**
 * @param {object} shim instance of shim
 * @param {Function} fn function that is wrapped
 * @param {string} _name function name(unused)
 * @param {boolean} useAllParams flag to indicate if all params of function are used
 * @returns {Function|undefined} wrapped function
 * @private
 */
function _wrapThen(shim, fn, _name, useAllParams) {
  // Don't wrap non-functions.
  if (isWrapped(shim, fn)) {
    return
  }

  return function __NR_wrappedThen() {
    if (!(this instanceof shim._class)) {
      return fn.apply(this, arguments)
    }

    const thenSegment = shim.getSegment()
    const promise = this

    // store isWrapped and current handler on context object to be passed into wrapHandler
    const ctx = { isWrapped: false, handler: null }

    const args = new Array(arguments.length)
    for (let i = 0; i < arguments.length; ++i) {
      args[i] = wrapHandler({
        shim,
        handler: arguments[i],
        index: i,
        argsLength: arguments.length,
        useAllParams,
        ctx
      })
    }
    ctx.handler = fn.apply(this, args)

    // If we got a promise (which we should have), link the parent's context.
    if (!ctx.isWrapped && ctx.handler instanceof shim._class && ctx.handler !== promise) {
      Contextualizer.link(promise, ctx.handler, thenSegment)
    }
    return ctx.handler
  }
}

/**
 * @private
 */
class Context {
  constructor(segment) {
    this.segments = [segment]
  }

  branch() {
    return this.segments.push(null) - 1
  }
}

/**
 * @private
 */
class Contextualizer {
  constructor(idx, context) {
    this.parentIdx = -1
    this.idx = idx
    this.context = context
    this.child = null
  }

  /**
   * Iterate over the children and assign parent/child relationship
   * via parentIdx and idx of contextualizer.
   *
   * The first child needs to be updated to have its own branch as well. And
   * each of that child's children must be updated with the new parent index.
   * This is the only non-constant-time action for linking, but it only
   * happens with branching promise chains specifically when the 2nd branch
   * is added.
   *
   * Note: This does not account for branches of branches. That may result
   * in improperly parented segments.
   *
   * @param {Contextualizer} ctxlzr instance of contextualizer
   */
  static buildContextTree(ctxlzr) {
    // When the branch-point is the 2nd through nth link in the chain, it is
    // necessary to track its segment separately so the branches can parent
    // their segments on the branch-point.
    if (ctxlzr.parentIdx !== -1) {
      ctxlzr.idx = ctxlzr.context.branch()
    }

    let parent = ctxlzr
    let child = ctxlzr.child
    const branchIdx = ctxlzr.context.branch()
    do {
      child.parentIdx = parent.idx
      child.idx = branchIdx
      parent = child
      child = child.child
    } while (child)

    // We set the child to something falsey that isn't `null` so we can
    // distinguish between having no child, having one child, and having
    // multiple children.
    ctxlzr.child = false
  }

  static link(prev, next, segment) {
    let ctxlzr = prev && prev[symbols.context]
    if (ctxlzr && !ctxlzr.isActive()) {
      ctxlzr = prev[symbols.context] = null
    }

    if (ctxlzr) {
      // If prev has one child already, branch the context and update the child.
      if (ctxlzr.child) {
        Contextualizer.buildContextTree(ctxlzr)
      }

      // If this is a branching link then create a new branch for the next promise.
      // Otherwise, we can just piggy-back on the previous link's spot.
      const idx = ctxlzr.child === false ? ctxlzr.context.branch() : ctxlzr.idx

      // Create a new context for this next promise.
      next[symbols.context] = new Contextualizer(idx, ctxlzr.context)
      next[symbols.context].parentIdx = ctxlzr.idx

      // If this was our first child, remember it in case we have a 2nd.
      if (ctxlzr.child === null) {
        ctxlzr.child = next[symbols.context]
      }
    } else if (segment) {
      // This next promise is the root of a chain. Either there was no previous
      // promise or the promise was created out of context.
      next[symbols.context] = new Contextualizer(0, new Context(segment))
    }
  }

  isActive() {
    const segments = this.context.segments
    const segment = segments[this.idx] || segments[this.parentIdx] || segments[0]
    return segment && segment.transaction.isActive()
  }

  getSegment() {
    const segments = this.context.segments
    let segment = segments[this.idx]
    if (segment == null) {
      segment = segments[this.idx] = segments[this.parentIdx] || segments[0]
    }
    return segment
  }

  setSegment(segment) {
    return (this.context.segments[this.idx] = segment)
  }

  toJSON() {
    // No-op.
  }

  continueContext(prom) {
    const self = this
    const nextContext = prom[symbols.context]
    if (!nextContext) {
      return prom
    }

    // If we have `finally`, use that to sneak our context update.
    if (typeof prom.finally === 'function') {
      return prom.finally(__NR_continueContext)
    }

    // No `finally` means we need to hook into resolve and reject individually and
    // pass through whatever happened.
    return prom.then(
      function __NR_thenContext(val) {
        __NR_continueContext()
        return val
      },
      function __NR_catchContext(err) {
        __NR_continueContext()
        throw err // Re-throwing promise rejection, this is not New Relic's error.
      }
    )

    /**
     *
     */
    function __NR_continueContext() {
      self.setSegment(nextContext.getSegment())
    }
  }
}
/* eslint-enable camelcase */