UNPKG

@instana/core

Version:
352 lines (306 loc) 11.5 kB
/* * (c) Copyright IBM Corp. 2021 * (c) Copyright Instana Inc. and contributors 2021 */ /** * This is a reimplementation of the cls-hooked API on top of AsyncLocalStorage. * This implementation is used instead of the cls-hooked based implementation in Node.js runtimes where * AsyncLocalStorage is available. It exposes the same API as the previous implementation, and can be used * interchangeably. */ 'use strict'; const util = require('util'); const assert = require('assert'); const wrapEmitter = require('./emitter-listener'); const { AsyncLocalStorage, executionAsyncId } = require('async_hooks'); const unset = require('./unset'); const CONTEXTS_SYMBOL = 'instanaClsHooked@contexts'; const storage = new AsyncLocalStorage(); module.exports = { createNamespace, reset, destroyNamespace, getNamespace }; /** * @typedef {Object.<string, *>} InstanaCLSContext */ /** * Creates a new CLS namespace. */ class Namespace { /** * @param {string} name */ constructor(name) { /* * The idea behind `sharedContext` is to keep a reference to an already finished context, that we might need to use * as the parent context for the next instrumentation. This workaround is only used in exactly one edge case: * GraphQL client spans for GraphQL subscription updates. In the instrumentation for GraphQL subscription update, we * use it to find the parent entry span (usually an HTTP entry). In this case, the context for the parent entry span * is properly managed by the AsyncLocalStorage and the context is exited correctly when the request is terminated. * Due to some custom queuing mechanism in the graphql-subscriptions module, the subscription update happens outside * of the correct context. * * This workaround is a best effort and can potentially lead to falling back to the wrong parent context in some * cases. We work with the assumption that the stored `sharedContext` is the correct context when the next * instrumentation, which relies on it, needs to find its parent context. But other requests might hav interupted * and overwritten the `sharedContext` at that time. * * This problem already exists in a similar form in the cls-hooked based implementation in ../context.js. * * See also https://github.com/instana/nodejs/commit/1c399d99b8909e0d4b197ab092e2af3f93776cd6. */ this.sharedContext = null; this.name = name; this.active = null; this.id = -1; this._indent = 0; Object.defineProperty(this, 'active', { get() { return storage.getStore(); } }); } /** * Sets a key/value pair in the current CLS context. It can be retrieved later, but only from the same context * or a child context. * @param {string} key * @param {*} value * @returns {Function} */ set(key, value) { const context = storage.getStore(); if (!context) { throw new Error('No context available. ns.run() or ns.bind() must be called first.'); } context[key] = value; return unset.bind(null, context, key, value); } /** * Retrieves a value by key from the current CLS context (or a parent context), assuming the key/value pair has * been set earlier in this context. * * When `fallbackToSharedContext` is set to true, the caller tries to access a parent context, * which got remembered in a previous instrumentation. * * @param {string} key * @param {boolean} [fallbackToSharedContext=false] */ get(key, fallbackToSharedContext = false) { const activeContext = storage.getStore() || (fallbackToSharedContext ? this.sharedContext : undefined); if (!activeContext) { return undefined; } return activeContext[key]; } /** * Creates a new CLS context in this namespace as a child of the currently active context (or as a root context if no * context is currently active). * @returns {InstanaCLSContext} */ createContext() { const activeContext = storage.getStore(); // Prototype inherit existing context if created a new child context within existing context. const context = Object.create(activeContext || Object.prototype); context._ns_name = this.name; context.id = executionAsyncId(); return context; } /** * Creates a new root CLS context in this namespace, independent of the currently active context. * * @returns {InstanaCLSContext} */ createRootContext() { const context = Object.create(Object.prototype); context._ns_name = this.name; context.id = executionAsyncId(); return context; } /** * Runs a function in a new CLS context. The context is left after the function terminates. Asynchronous work started * in this function will happen in that new context. The return value from that function (if any) is discarded. * If you aren't 100% certain that the function never returns a value or that client code never relies on that value, * use runAndReturn instead. * @param {Function} fn * @param {InstanaCLSContext} ctx */ run(fn, ctx) { const context = ctx || this.createContext(); storage.run(context, () => fn(context)); return context; } /** * Runs a function in a new CLS context and returns its return value. The context is left after the function * terminates. Asynchronous work started in this function will happen in that new context. * @param {Function} fn * @param {InstanaCLSContext} ctx */ runAndReturn(fn, ctx) { let value; this.run((/** @type {InstanaCLSContext} */ context) => { value = fn(context); }, ctx); return value; } /** * Runs a function which returns a promise in a new CLS context and returns said promise. The context is left * as soon as the the promise resolves/is rejected. Asynchronous work started in this promise will happen in that new * context. * * If the given function does not create a then-able, an error will be thrown. * * This function assumes that the returned promise is CLS-friendly or wrapped already. * @param {Function} fn * @param {InstanaCLSContext} ctx */ runPromise(fn, ctx) { const context = ctx || this.createContext(); /** * We need the new context to be set as the active context before the promise is called. * Otherwise, the current active context will cause the new span transmission to fail. * Unlike our old code, AsyncLocalStorage.run does not update the active context, so we have to do it manually with * storage.enterWith. This is done by `this.enter`, which calls `enterWith`. * The same happens in the old code, and for the same reasons. */ this.enter(context); const promise = fn(context); if (!promise || !promise.then || !promise.catch) { throw new Error('fn must return a promise.'); } return promise; } /** * Runs a function (which might or might not return a promise) in a new CLS context. If the given function indeed * returns a then-able, this behaves like runPromise. If not, this behaves like runAndReturn. In particular, no error * is thrown if the given function does not return a promise. * * This function assumes that the returned promise (if any) is CLS-friendly or wrapped already. * @param {Function} fn * @param {InstanaCLSContext} ctx */ runPromiseOrRunAndReturn(fn, ctx) { let isPromise = false; const context = ctx || this.createContext(); this.enter(context); const valueOrPromise = fn(context); isPromise = valueOrPromise && valueOrPromise.then && valueOrPromise.catch; if (isPromise) { return valueOrPromise; } } /** * Returns a wrapper around the given function which will enter CLS context which is active at the time of calling * bind and leave that context once the function terminates. If no context is active, a new context will be created. * @param {Function} fn * @param {InstanaCLSContext} context */ bind(fn, context) { context = context || storage.getStore() || this.createContext(); const self = this; return function clsBind() { self.enter(context); return fn.apply(this, arguments); }; } /** * Binds the given emitter to the currently active CLS context. Work triggered by an emit from that emitter will * happen in that CLS context. * @param {import('events').EventEmitter} emitter */ bindEmitter(emitter) { assert.ok(emitter.on && emitter.addListener && emitter.emit, 'can only bind real EEs'); const namespace = this; const thisSymbol = `context@${this.name}`; /** * Capture the context active at the time the emitter is bound. * @param {*} listener */ function attach(listener) { if (!listener) { return; } if (!listener[CONTEXTS_SYMBOL]) { listener[CONTEXTS_SYMBOL] = Object.create(null); } listener[CONTEXTS_SYMBOL][thisSymbol] = { namespace, context: storage.getStore() }; } /** * At emit time, bind the listener within the correct context. * @param {*} unwrapped */ function bind(unwrapped) { if (!(unwrapped && unwrapped[CONTEXTS_SYMBOL])) { return unwrapped; } let wrapped = unwrapped; const unwrappedContexts = unwrapped[CONTEXTS_SYMBOL]; Object.keys(unwrappedContexts).forEach(name => { const thunk = unwrappedContexts[name]; wrapped = thunk.namespace.bind(wrapped, thunk.context); }); return wrapped; } wrapEmitter(emitter, attach, bind); } /** * @param {InstanaCLSContext} context */ enter(context) { assert.ok(context, 'context must be provided for entering'); storage.enterWith(context); this.sharedContext = context; } /** * @param {InstanaCLSContext} context */ exit(context) { assert.ok(context, 'context must be provided for exiting'); } } /** * @param {string} name */ function getNamespace(name) { // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' return process.instanaNamespaces[name]; } /** * @param {string} name */ function createNamespace(name) { assert.ok(name, 'namespace must be given a name.'); const namespace = new Namespace(name); namespace.id = executionAsyncId(); // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' process.instanaNamespaces[name] = namespace; return namespace; } /** * @param {string} name */ function destroyNamespace(name) { const namespace = getNamespace(name); assert.ok(namespace, `can't delete nonexistent namespace! "${name}"`); assert.ok(namespace.id, `don't assign to process.instanaNamespaces directly! ${util.inspect(namespace)}`); // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' process.instanaNamespaces[name] = null; } function reset() { // must unregister async listeners // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' if (process.instanaNamespaces) { // @ts-ignore Object.keys(process.instanaNamespaces).forEach(name => { destroyNamespace(name); }); } // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' process.instanaNamespaces = Object.create(null); } // @ts-ignore: Property 'instanaNamespaces' does not exist on type 'Process' process.instanaNamespaces = process.instanaNamespaces || {};