@instana/core
Version:
Core library for Instana's Node.js packages
548 lines (483 loc) • 17.7 kB
JavaScript
/*
* (c) Copyright IBM Corp. 2021
* (c) Copyright Instana Inc. and contributors 2020
*/
;
const url = require('url');
const shimmer = require('../../shimmer');
const hook = require('../../../util/hook');
const tracingUtil = require('../../tracingUtil');
const constants = require('../../constants');
const cls = require('../../cls');
const methodToActionRegex = /^(\w+?)(?:Api)?\.(?:.+) \[as (\w+)\]$/;
const endpointToIdRegex = /^\/[^/]+\/_doc\/([^/]+)$/;
let logger;
let isActive = false;
exports.spanName = 'elasticsearch';
exports.batchable = true;
exports.init = function init(config) {
logger = config.logger;
hook.onModuleLoad('@elastic/elasticsearch', instrument);
};
const connectionUrlCache = {};
function instrument(es, esModuleFilename) {
// v8
if (es.SniffingTransport) {
const OriginalClient = es.Client;
es.Client = function InstanaClient() {
const client = new OriginalClient(...arguments);
if (client.connectionPool && client.connectionPool.connections && client.connectionPool.connections.length > 0) {
const connectionString = client.connectionPool.connections[0].url;
// We will almost certainly need the host and port from the connection URL later in shimGetConnection when
// capturing an actual request. We can as well parse it now and put it in the cache.
parseAndCacheConnectionUrl(connectionString);
}
return client;
};
instrumentTransport(es);
} else {
const ESAPI = tracingUtil.requireModuleFromApplicationUnderMonitoringSafely(esModuleFilename, '..', 'api');
if (isConstructor(ESAPI)) {
instrumentTransport(es);
} else {
instrumentApiLayer(es, ESAPI);
}
}
}
function instrumentApiLayer(es, ESAPI) {
const OriginalClient = es.Client;
if (!OriginalClient || typeof OriginalClient !== 'function') {
return;
}
const actionPaths = [];
forEachApiAction(ESAPI, actionPath => {
actionPaths.push(actionPath);
});
es.Client = function InstrumentedClient() {
const client = new OriginalClient(...arguments);
const clusterInfo = {};
gatherClusterInfo(client, clusterInfo);
actionPaths.forEach(actionPath => {
instrumentApi(client, actionPath, clusterInfo);
});
return client;
};
}
function gatherClusterInfo(client, clusterInfo) {
client.info().then(
_clusterInfo => {
if (_clusterInfo && _clusterInfo.body) {
clusterInfo.clusterName = _clusterInfo.body.cluster_name;
}
},
() => {
setTimeout(() => {
gatherClusterInfo(client, clusterInfo);
}, 60000).unref();
}
);
}
function instrumentApi(client, actionPath, clusterInfo) {
const action = actionPath.join('.');
const parent = getParentByPath(action, client, actionPath);
const originalFunction = getByPath(action, client, actionPath);
if (!parent || typeof originalFunction !== 'function') {
return;
}
parent[actionPath[actionPath.length - 1]] = function instrumentedAction(params, options, originalCallback) {
if (cls.skipExitTracing({ isActive })) {
return originalFunction.apply(this, arguments);
}
let callbackIndex = typeof originalCallback === 'function' ? 2 : -1;
options = options || {};
if (typeof options === 'function') {
originalCallback = options;
options = {};
callbackIndex = 1;
}
if (typeof params === 'function' || params == null) {
originalCallback = params;
params = {};
options = {};
callbackIndex = 0;
}
const ctx = this;
const originalArgs = new Array(arguments.length);
for (let i = 0; i < arguments.length; i++) {
originalArgs[i] = arguments[i];
}
return cls.ns.runAndReturn(() => {
const span = cls.startSpan({
spanName: exports.spanName,
kind: constants.EXIT
});
span.stack = tracingUtil.getStackTrace(instrumentedAction);
span.data.elasticsearch = {
action,
cluster: clusterInfo.clusterName
};
processParams(span, params);
if (callbackIndex >= 0) {
originalArgs[callbackIndex] = cls.ns.bind(function (error, result) {
if (error) {
onError(span, error);
} else {
onSuccess(span, result);
}
return originalCallback.apply(this, arguments);
});
return originalFunction.apply(ctx, originalArgs);
} else {
// eslint-disable-next-line no-useless-catch
try {
return originalFunction.apply(ctx, originalArgs).then(onSuccess.bind(null, span), error => {
onError(span, error);
throw error;
});
} catch (e) {
// Immediately cleanup on synchronous errors.
throw e;
}
}
});
};
}
function onSuccess(span, result) {
const hits = (result.body && result.body.hits) || result.hits;
const responses = (result.body && result.body.responses) || result.responses;
if (hits != null && hits.total != null) {
if (typeof hits.total === 'number') {
span.data.elasticsearch.hits = hits.total;
} else if (typeof hits.total.value === 'number') {
span.data.elasticsearch.hits = hits.total.value;
}
} else if (responses != null && Array.isArray(responses)) {
span.data.elasticsearch.hits = responses.reduce((h, res) => {
if (res.hits && typeof res.hits.total === 'number') {
return h + res.hits.total;
} else if (res.hits && res.hits.total && typeof res.hits.total.value === 'number') {
return h + res.hits.total.value;
}
return h;
}, 0);
}
getConnectionDetailsFromResultMeta(span, result);
span.d = Date.now() - span.ts;
span.transmit();
return result;
}
function onError(span, error) {
span.d = Date.now() - span.ts;
span.ec = 1;
if (error) {
span.data.elasticsearch.error = tracingUtil.getErrorDetails(error);
}
if (error.meta && error.meta.meta) {
getConnectionDetailsFromResultMeta(span, error.meta);
}
span.transmit();
}
function parseAndCacheConnectionUrl(connectionUrl) {
if (connectionUrl && connectionUrl instanceof url.URL) {
// The connection URL is already in the form of a URL object. No need to parse it.
return connectionUrl;
} else if (typeof connectionUrl === 'string') {
// The connection URL is only available as a string. We need to parse it to extract the host and the port.
// Deliberately checking for undefined, since we store a failure to parse an URL as null.
if (connectionUrlCache[connectionUrl] !== undefined) {
return connectionUrlCache[connectionUrl];
}
try {
const parsedConnectionUrl = new URL(connectionUrl);
// We do not want to spend the CPU cycles to parse the URL for each ES request. When we have parsed a given URL
// once, we cache the resulting result and never parse that particular URL again.
connectionUrlCache[connectionUrl] = parsedConnectionUrl;
return parsedConnectionUrl;
} catch (e) {
// We also cache the fact that we failed to parse a given URL, otherwise we would try to parse it again on every
// request.
connectionUrlCache[connectionUrl] = null;
return null;
}
}
}
function getConnectionDetailsFromResultMeta(span, result) {
if (span.data.elasticsearch.address) {
// We have already annotated the connection details, probably via shimGetConnection, no need to deal with connection
// details again for this span.
return;
}
// NOTE: This does not work for version 8.
// Result can also be a part of the error object, both have the meta.connection attribute.
// For the error object it is in error.meta.meta.connection (see onError).
if (!span.data.elasticsearch.cluster && !span.data.elasticsearch.address && result.meta && result.meta.connection) {
const connectionUrl = result.meta.connection.url;
if (connectionUrl && connectionUrl instanceof url.URL) {
span.data.elasticsearch.address = connectionUrl.hostname;
span.data.elasticsearch.port = connectionUrl.port;
}
}
}
function processParams(span, params) {
const action = span.data.elasticsearch.action;
const body = (params && params.body) || (params && params.bulkBody);
if (action === 'mget' && body && body.docs && Array.isArray(body.docs)) {
getSpanDataFromMget1(span, body.docs);
} else if (action === 'mget' && body && body.ids && Array.isArray(body.ids)) {
getSpanDataFromMget2(span, params);
} else if (action === 'msearch' && Array.isArray(body)) {
getSpanDataFromMsearch(span, body);
} else {
span.data.elasticsearch.index = toStringEsMultiParameter(params.index);
span.data.elasticsearch.type = toStringEsMultiParameter(params.type);
span.data.elasticsearch.id = params.id;
if (action && action.indexOf('search') === 0) {
span.data.elasticsearch.query = tracingUtil.shortenDatabaseStatement(JSON.stringify(params));
}
}
}
function toStringEsMultiParameter(param) {
if (param == null) {
return undefined;
}
if (typeof param === 'string') {
if (param === '') {
return '_all';
}
return param;
} else if (Array.isArray(param)) {
return param.join(',');
}
return JSON.stringify(param);
}
function getSpanDataFromMget1(span, docs) {
const indices = [];
const types = [];
const ids = [];
for (let i = 0; i < docs.length; i++) {
collectParamFrom(docs[i], '_index', indices);
collectParamFrom(docs[i], '_type', types);
collectParamFrom(docs[i], '_id', ids);
}
span.data.elasticsearch.index = indices.length > 0 ? indices.join(',') : undefined;
span.data.elasticsearch.type = types.length > 0 ? types.join(',') : undefined;
span.data.elasticsearch.id = ids.length > 0 ? ids.join(',') : undefined;
}
function getSpanDataFromMget2(span, params) {
if (!params) {
return;
}
const body = (params && params.body) || (params && params.bulkBody);
span.data.elasticsearch.index = params.index ? toStringEsMultiParameter(params.index) : undefined;
span.data.elasticsearch.type = params.index ? toStringEsMultiParameter(params.type) : undefined;
span.data.elasticsearch.id = body && body.ids.length > 0 ? body.ids.join(',') : undefined;
}
function getSpanDataFromMsearch(span, body) {
const indices = [];
const types = [];
const query = [];
for (let i = 0; i < body.length; i++) {
collectParamFrom(body[i], 'index', indices);
collectParamFrom(body[i], 'type', types);
collectParamFrom(body[i], 'query', query);
}
span.data.elasticsearch.index = indices.length > 0 ? indices.join(',') : undefined;
span.data.elasticsearch.type = types.length > 0 ? types.join(',') : undefined;
span.data.elasticsearch.query = query.length > 0 ? tracingUtil.shortenDatabaseStatement(query.join(',')) : undefined;
}
function collectParamFrom(bodyItem, key, accumulator) {
if (bodyItem && bodyItem[key]) {
const value = toStringEsMultiParameter(bodyItem[key]);
if (value != null && accumulator.indexOf(value) < 0) {
accumulator.push(value);
}
}
}
function instrumentTransport(es) {
// Starting with 7.9.1, the export of @elastic/elasticsearch/api expects to be called as a constructor. In fact,
// @elastic/elasticsearch#Client now inherits from @elastic/elasticsearch/api. See
// https://github.com/elastic/elasticsearch-js/commit/a064f0f357ea5797cb8a784671b85a6b0c88626d and
// https://github.com/elastic/elasticsearch-js/pull/1314 for details. Also, starting with that version, some API
// methods are added via Object.defineProperties with the default settings and only a getter, making it impossible
// to override/wrap them. For those versions we fall back to instrumenting the transport layer instead of the API.
if (es.Transport && es.Transport.prototype) {
shimmer.wrap(es.Transport.prototype, 'request', shimRequest);
shimmer.wrap(es.Transport.prototype, 'getConnection', shimGetConnection);
} else {
logger.error(
'Cannot instrument @elastic/elasticsearch. Either es.Transport or es.Transport.prototype does not exist.'
);
}
}
// Transport#request calls Transport#getConnection internally to determine which connection to use. That is,
// Transport#getConnection is called while the Elasticsearch exit span is active, and we can use it to capture the
// connection details.
function shimGetConnection(originalGetConnection) {
return function () {
const connectionInfo = originalGetConnection.apply(this, arguments);
if (connectionInfo && connectionInfo.url) {
const span = cls.getCurrentSpan();
if (span && span.n === 'elasticsearch') {
const parsedConnectionUrl = parseAndCacheConnectionUrl(connectionInfo.url);
if (parsedConnectionUrl) {
span.data.elasticsearch.address = parsedConnectionUrl.hostname;
span.data.elasticsearch.port = parsedConnectionUrl.port;
}
}
}
return connectionInfo;
};
}
function shimRequest(esReq) {
return function () {
if (cls.skipExitTracing({ isActive })) {
return esReq.apply(this, arguments);
}
const originalArgs = new Array(arguments.length);
for (let i = 0; i < arguments.length; i++) {
originalArgs[i] = arguments[i];
}
return instrumentedRequest(this, esReq, originalArgs);
};
}
function instrumentedRequest(ctx, origEsReq, originalArgs) {
// normalize arguments
let params = originalArgs[0] || {};
const options = originalArgs[1];
let callbackIndex = 2;
let originalCallback = originalArgs[callbackIndex];
if (typeof originalCallback !== 'function') {
if (typeof options === 'function') {
callbackIndex = 1;
originalCallback = originalArgs[callbackIndex];
} else if (typeof params === 'function') {
callbackIndex = 0;
originalCallback = originalArgs[callbackIndex];
params = {};
} else {
callbackIndex = -1;
originalCallback = null;
}
}
const httpPath = params.path;
return cls.ns.runAndReturn(() => {
const span = cls.startSpan({
spanName: exports.spanName,
kind: constants.EXIT
});
span.stack = tracingUtil.getStackTrace(instrumentedRequest, 1);
span.data.elasticsearch = {
endpoint: httpPath
};
findActionInStack(span);
processParams(span, params);
parseIdFromPath(span, httpPath);
const q = params.querystring;
if (q) {
if (typeof q === 'string') {
span.data.elasticsearch.query = tracingUtil.shortenDatabaseStatement(q);
} else if (typeof q === 'object' && Object.keys(q).length > 0) {
span.data.elasticsearch.query = tracingUtil.shortenDatabaseStatement(JSON.stringify(q));
}
}
if (callbackIndex >= 0) {
originalArgs[callbackIndex] = cls.ns.bind(function (error, result) {
if (error) {
onError(span, error);
} else {
onSuccess(span, result);
}
return originalCallback.apply(this, arguments);
});
return origEsReq.apply(ctx, originalArgs);
} else {
// eslint-disable-next-line no-useless-catch
try {
return origEsReq.apply(ctx, originalArgs).then(onSuccess.bind(null, span), error => {
onError(span, error);
throw error;
});
} catch (e) {
// Immediately cleanup on synchronous errors.
throw e;
}
}
});
}
function findActionInStack(span) {
if (!span.stack) {
return;
}
const esApiFrames = span.stack.filter(frame => frame.c && frame.c.includes('/api'));
if (esApiFrames.length === 0) {
return;
}
const esApiMethod = esApiFrames[esApiFrames.length - 1].m;
if (!esApiMethod) {
return;
}
const matchResult = methodToActionRegex.exec(esApiMethod);
if (matchResult) {
if (matchResult[1] === 'Client') {
// Top level API methods directly on Client, like `Client.indexApi [as index]`:
span.data.elasticsearch.action = `${matchResult[2].toLowerCase()}`;
} else {
// Nested API, like `IndicesApi.indicesRefreshApi [as refresh]`:
span.data.elasticsearch.action = `${matchResult[1].toLowerCase()}.${matchResult[2].toLowerCase()}`;
}
} else {
// fall back: use the full method name
span.data.elasticsearch.action = esApiMethod;
}
}
function parseIdFromPath(span, httpPath) {
if (httpPath) {
const matchResult = endpointToIdRegex.exec(httpPath);
if (matchResult) {
span.data.elasticsearch.id = matchResult[1];
}
}
}
function forEachApiAction(ESAPI, fn) {
const esApiFromBuildApi = ESAPI({
makeRequest: function dummyMakeRequest() {},
ConfigurationError: function DummyConfigurationError() {},
result: {}
});
forEachKeyRecursive(esApiFromBuildApi, [], fn);
}
function forEachKeyRecursive(obj, path, fn) {
if (!obj || typeof obj !== 'object') {
return false;
}
let hadSubKeys = false;
Object.keys(obj).forEach(key => {
const nextPath = path.concat(key);
hadSubKeys = forEachKeyRecursive(obj[key], nextPath, fn);
if (!hadSubKeys) {
fn(nextPath);
}
});
return true;
}
function getByPath(action, obj, path) {
if (path.length === 0) {
return obj;
}
return getByPath(action, obj[path[0]], path.slice(1));
}
function getParentByPath(action, obj, path) {
if (path.length === 1) {
return obj;
}
return getParentByPath(action, obj[path[0]], path.slice(1));
}
function isConstructor(ESAPI) {
return ESAPI && typeof ESAPI.toString === 'function' && ESAPI.toString().includes('this[');
}
exports.activate = function activate() {
isActive = true;
};
exports.deactivate = function deactivate() {
isActive = false;
};