@instana/core
Version:
Core library for Instana's Node.js packages
352 lines (306 loc) • 11.5 kB
JavaScript
/*
* (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.
*/
;
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 || {};