diff --git a/.ci/.jenkins_tav.yml b/.ci/.jenkins_tav.yml index 5ad0c9cf6a..747a8c0238 100644 --- a/.ci/.jenkins_tav.yml +++ b/.ci/.jenkins_tav.yml @@ -1,29 +1,30 @@ TAV: + - @elastic/elasticsearch + - apollo-server-express + - bluebird + - cassandra-driver + - elasticsearch + - express + - express-graphql + - express-queue + - fastify + - finalhandler - generic-pool - - mysql - - mysql2 - - redis - - koa-router + - got + - graphql - handlebars + - hapi + - ioredis - jade - - pug + - knex + - koa-router + - mimic-response - mongodb-core - - finalhandler - - ioredis + - mysql + - mysql2 - pg - - cassandra-driver - - tedious + - pug + - redis - restify - - fastify - - mimic-response - - got - - bluebird - - apollo-server-express - - knex + - tedious - ws - - graphql - - express-graphql - - elasticsearch - - hapi - - express - - express-queue diff --git a/.tav.yml b/.tav.yml index 68debb604f..2b83d0a696 100644 --- a/.tav.yml +++ b/.tav.yml @@ -194,9 +194,14 @@ koa-router: peerDependencies: koa@2 versions: '>=5.2.0 <8' commands: node test/instrumentation/modules/koa-router/index.js + elasticsearch: versions: '>=8.0.0' commands: node test/instrumentation/modules/elasticsearch.js +'@elastic/elasticsearch': + versions: '*' + commands: node test/instrumentation/modules/@elastic/elasticsearch.js + handlebars: versions: '*' commands: node test/instrumentation/modules/handlebars.js diff --git a/.travis.yml b/.travis.yml index bfaa547534..947262b241 100644 --- a/.travis.yml +++ b/.travis.yml @@ -115,7 +115,7 @@ jobs: - node_js: '12' if: type IN (cron, pull_request) AND NOT branch =~ ^greenkeeper/.* - env: TAV=ws,graphql,express-graphql,elasticsearch,hapi,@hapi/hapi,express,express-queue + env: TAV=ws,graphql,express-graphql,elasticsearch,@elastic/elasticsearch,hapi,@hapi/hapi,express,express-queue script: tav --quiet - node_js: '12' @@ -142,7 +142,7 @@ jobs: - node_js: '11' if: type IN (cron, pull_request) AND NOT branch =~ ^greenkeeper/.* - env: TAV=ws,graphql,express-graphql,elasticsearch,hapi,@hapi/hapi,express,express-queue + env: TAV=ws,graphql,express-graphql,elasticsearch,@elastic/elasticsearch,hapi,@hapi/hapi,express,express-queue script: tav --quiet - node_js: '11' @@ -169,7 +169,7 @@ jobs: - node_js: '10' if: type IN (cron, pull_request) AND NOT branch =~ ^greenkeeper/.* - env: TAV=ws,graphql,express-graphql,elasticsearch,hapi,@hapi/hapi,express,express-queue + env: TAV=ws,graphql,express-graphql,elasticsearch,@elastic/elasticsearch,hapi,@hapi/hapi,express,express-queue script: tav --quiet - node_js: '10' @@ -196,7 +196,7 @@ jobs: - node_js: '8' if: type IN (cron, pull_request) AND NOT branch =~ ^greenkeeper/.* - env: TAV=ws,graphql,express-graphql,elasticsearch,hapi,@hapi/hapi,express,express-queue + env: TAV=ws,graphql,express-graphql,elasticsearch,@elastic/elasticsearch,hapi,@hapi/hapi,express,express-queue script: tav --quiet - node_js: '8' diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index 61550cd664..8caa64d956 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -79,6 +79,7 @@ The Node.js agent will automatically instrument the following modules to give yo |Module |Version |Note |https://www.npmjs.com/package/cassandra-driver[cassandra-driver] |>=3.0.0 |Will instrument all queries |https://www.npmjs.com/package/elasticsearch-js[elasticsearch-js] |>=8.0.0 |Will instrument all queries +|https://www.npmjs.com/package/@elastic/elasticsearch[@elastic/elasticsearch] |* |Will instrument all queries |https://www.npmjs.com/package/graphql[graphql] |>=0.7.0 <15.0.0 |Will instrument all queries |https://www.npmjs.com/package/handlebars[handlebars] |* |Will instrument compile and render calls |https://www.npmjs.com/package/jade[jade] |>=0.5.6 |Will instrument compile and render calls diff --git a/lib/instrumentation/elasticsearch-shared.js b/lib/instrumentation/elasticsearch-shared.js new file mode 100644 index 0000000000..ef21cbba6b --- /dev/null +++ b/lib/instrumentation/elasticsearch-shared.js @@ -0,0 +1,24 @@ +'use strict' + +const queryRegexp = /_((search|msearch)(\/template)?|count)$/ + +exports.setDbContext = function (span, params) { + if (queryRegexp.test(params.path)) { + // The old client exposes body and query, the new client exposes body and querystring + const statement = Array.isArray(params.body) + ? params.body.map(JSON.stringify).join('\n') + : stringifyQuery(params).trim() + + if (statement) { + span.setDbContext({ + type: 'elasticsearch', + statement + }) + } + } +} + +function stringifyQuery (params) { + const query = params.body || params.querystring || params.query + return typeof query === 'string' ? query : JSON.stringify(query) +} diff --git a/lib/instrumentation/index.js b/lib/instrumentation/index.js index a23bae64d6..1cb55157e9 100644 --- a/lib/instrumentation/index.js +++ b/lib/instrumentation/index.js @@ -11,6 +11,7 @@ var shimmer = require('./shimmer') var Transaction = require('./transaction') var MODULES = [ + '@elastic/elasticsearch', 'apollo-server-core', 'bluebird', 'cassandra-driver', diff --git a/lib/instrumentation/modules/@elastic/elasticsearch.js b/lib/instrumentation/modules/@elastic/elasticsearch.js new file mode 100644 index 0000000000..c612dd701e --- /dev/null +++ b/lib/instrumentation/modules/@elastic/elasticsearch.js @@ -0,0 +1,79 @@ +'use strict' + +const { setDbContext } = require('../../elasticsearch-shared') + +module.exports = function (elasticsearch, agent, { version, enabled }) { + if (!enabled) return elasticsearch + + function generateSpan (activeSpans, meta, params) { + const span = agent.startSpan(null, 'db.elasticsearch.request') + if (span === null) return null + + const { request } = meta + span.name = `Elasticsearch: ${params.method} ${params.path}` + + activeSpans.set(request.id, span) + + return span + } + + class ApmClient extends elasticsearch.Client { + constructor (opts) { + super(opts) + + const activeSpans = new Map() + let hasPrepareRequestEvent = false + + this.on('prepare-request', (err, { meta, params }) => { + hasPrepareRequestEvent = true + if (err) { + return agent.captureError(err) + } + + generateSpan(activeSpans, meta, params) + }) + + this.on('request', (err, { meta }) => { + const { request } = meta + + if (err) { + const span = activeSpans.get(request.id) + agent.captureError(err) + if (span !== undefined) { + span.end() + activeSpans.delete(request.id) + } + return + } + + const span = hasPrepareRequestEvent === false + ? generateSpan(activeSpans, meta, request.params) + : activeSpans.get(request.id) + + if (span) setDbContext(span, request.params) + + // TODO: can we signal somehow that here + // we are starting the actual http request? + }) + + this.on('response', (err, { meta }) => { + if (err) { + // TODO: rebuild error object to avoid serialization issues + agent.captureError(err, { custom: err.message }) + // agent.captureError(err, { labels: { meta: JSON.stringify(err.meta) } }) + // agent.captureError(err, { custom: JSON.parse(JSON.stringify(err.meta)) }) + // agent.captureError(err, { custom: err.meta.meta.connection }) + } + + const { request } = meta + const span = activeSpans.get(request.id) + if (span !== undefined) { + span.end() + activeSpans.delete(request.id) + } + }) + } + } + + return Object.assign(elasticsearch, { Client: ApmClient }) +} diff --git a/lib/instrumentation/modules/elasticsearch.js b/lib/instrumentation/modules/elasticsearch.js index c2793deedd..324b59e8a4 100644 --- a/lib/instrumentation/modules/elasticsearch.js +++ b/lib/instrumentation/modules/elasticsearch.js @@ -1,9 +1,8 @@ 'use strict' +var { setDbContext } = require('../elasticsearch-shared') var shimmer = require('../shimmer') -var queryRegexp = /_((search|msearch)(\/template)?|count)$/ - module.exports = function (elasticsearch, agent, { enabled }) { if (!enabled) return elasticsearch @@ -18,26 +17,13 @@ module.exports = function (elasticsearch, agent, { enabled }) { var id = span && span.transaction.id var method = params && params.method var path = params && params.path - var query = params && params.query - var body = params && params.body agent.logger.debug('intercepted call to elasticsearch.Transport.prototype.request %o', { id: id, method: method, path: path }) if (span && method && path) { span.name = `Elasticsearch: ${method} ${path}` - if (queryRegexp.test(path)) { - let statement = Array.isArray(body) - ? body.map(JSON.stringify).join('\n') - : JSON.stringify(body || query) - - if (statement) { - span.setDbContext({ - type: 'elasticsearch', - statement - }) - } - } + setDbContext(span, params) if (typeof cb === 'function') { var args = Array.prototype.slice.call(arguments) diff --git a/package.json b/package.json index 7305f53cee..bd1b63ca4f 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@commitlint/cli": "^8.0.0", "@commitlint/config-conventional": "^8.0.0", "@commitlint/travis-cli": "^8.0.0", + "@elastic/elasticsearch": "elastic/elasticsearch-js#update-events", "@types/node": "^12.0.8", "apollo-server-express": "^2.6.3", "aws-sdk": "^2.477.0", diff --git a/test/config.js b/test/config.js index 554d6b14b7..89c73236d0 100644 --- a/test/config.js +++ b/test/config.js @@ -633,6 +633,7 @@ test('disableInstrumentations', function (t) { var flattenedModules = Instrumentation.modules.reduce((acc, val) => acc.concat(val), []) var modules = new Set(flattenedModules) if (semver.lt(process.version, '8.6.0')) { + modules.delete('@elastic/elasticsearch') modules.delete('restify') } if (semver.lt(process.version, '8.3.0')) { diff --git a/test/instrumentation/modules/@elastic/elasticsearch.js b/test/instrumentation/modules/@elastic/elasticsearch.js new file mode 100644 index 0000000000..6d66c353be --- /dev/null +++ b/test/instrumentation/modules/@elastic/elasticsearch.js @@ -0,0 +1,196 @@ +'use strict' + +process.env.ELASTIC_APM_TEST = true +const host = (process.env.ES_HOST || 'localhost') + ':9200' +const node = 'http://' + host + +const agent = require('../../../..').start({ + serviceName: 'test', + secretToken: 'test', + captureExceptions: false, + metricsInterval: 0, + centralConfig: false +}) + +const test = require('tape') + +const { Client } = require('@elastic/elasticsearch') + +const mockClient = require('../../../_mock_http_client') +const findObjInArray = require('../../../_utils').findObjInArray + +test('client.ping with promise', function userLandCode (t) { + resetAgent(done(t, 'HEAD', '/')) + + agent.startTransaction('foo') + + const client = new Client({ node }) + + client.ping().then(function () { + agent.endTransaction() + agent.flush() + }).catch(t.error) +}) + +test('client.ping with callback', function userLandCode (t) { + resetAgent(done(t, 'HEAD', '/')) + + agent.startTransaction('foo') + + const client = new Client({ node }) + + client.ping(function (err, result) { + t.error(err) + agent.endTransaction() + agent.flush() + }) +}) + +test('client.search with callback', function userLandCode (t) { + resetAgent(done(t, 'GET', '/_search', 'q=pants')) + + agent.startTransaction('foo') + + const client = new Client({ node }) + const query = { q: 'pants' } + + client.search(query, function (err) { + t.error(err) + agent.endTransaction() + agent.flush() + }) +}) + +test('client.searchTemplate with callback', function userLandCode (t) { + const body = { + source: { + query: { + query_string: { + query: '{{q}}' + } + } + }, + params: { + q: 'pants' + } + } + + resetAgent(done(t, 'POST', '/_search/template', JSON.stringify(body))) + + agent.startTransaction('foo') + + const client = new Client({ node }) + + client.searchTemplate({ body }, function (err) { + t.error(err) + agent.endTransaction() + agent.flush() + }) +}) + +test('client.msearch with callback', function userLandCode (t) { + const body = [ + {}, + { + query: { + query_string: { + query: 'pants' + } + } + } + ] + + const statement = body.map(JSON.stringify).join('\n') + + resetAgent(done(t, 'POST', '/_msearch', statement)) + + agent.startTransaction('foo') + + const client = new Client({ node }) + + client.msearch({ body }, function (err) { + t.error(err) + agent.endTransaction() + agent.flush() + }) +}) + +test('client.msearchTempate with callback', function userLandCode (t) { + const body = [ + {}, + { + source: { + query: { + query_string: { + query: '{{q}}' + } + } + }, + params: { + q: 'pants' + } + } + ] + + const statement = body.map(JSON.stringify).join('\n') + + resetAgent(done(t, 'POST', '/_msearch/template', statement)) + + agent.startTransaction('foo') + + const client = new Client({ node }) + + client.msearchTemplate({ body }, function (err) { + t.error(err) + agent.endTransaction() + agent.flush() + }) +}) + +const queryRegexp = /_((search|msearch)(\/template)?|count)$/ +function done (t, method, path, query) { + return function (data) { + t.equal(data.transactions.length, 1, 'should have 1 transaction') + t.equal(data.spans.length, 2, 'should have 2 spans') + + const trans = data.transactions[0] + + t.equal(trans.name, 'foo', 'should have expected transaction name') + t.equal(trans.type, 'custom', 'should have expected transaction type') + + let span1, span2 + { + const type = 'ext.http.http' + span1 = findObjInArray(data.spans, 'type', type) + t.ok(span1, 'should have span with type ' + type) + } { + const type = 'db.elasticsearch.request' + span2 = findObjInArray(data.spans, 'type', type) + t.ok(span2, 'should have span with type ' + type) + } + + t.equal(span1.name, method + ' ' + host + path, 'http span should have expected name') + t.equal(span2.name, 'Elasticsearch: ' + method + ' ' + path, 'elasticsearch span should have expected name') + + t.ok(span2.stacktrace.some(function (frame) { + return frame.function === 'userLandCode' + }), 'include user-land code frame') + + if (queryRegexp.test(path)) { + t.deepEqual(span2.context.db, { type: 'elasticsearch', statement: query || '{}' }, 'elasticsearch span should have db context') + } else { + t.notOk(span2.context, 'elasticsearch span should not have custom context') + } + + t.ok(span1.timestamp > span2.timestamp, 'http span should start after elasticsearch span') + t.ok(span1.timestamp + span1.duration * 1000 < span2.timestamp + span2.duration * 1000, 'http span should end before elasticsearch span') + + t.end() + } +} + +function resetAgent (cb) { + agent._instrumentation.currentTransaction = null + agent._transport = mockClient(cb) + agent.captureError = function (err) { throw err } +} diff --git a/test/instrumentation/modules/elasticsearch.js b/test/instrumentation/modules/elasticsearch.js index 5b6f1deafa..f1f80fa467 100644 --- a/test/instrumentation/modules/elasticsearch.js +++ b/test/instrumentation/modules/elasticsearch.js @@ -196,7 +196,7 @@ function done (t, method, path, query) { }), 'include user-land code frame') if (queryRegexp.test(path)) { - t.deepEqual(span2.context.db, { statement: query || '{}', type: 'elasticsearch' }) + t.deepEqual(span2.context.db, { type: 'elasticsearch', statement: query || '{}' }) } else { t.notOk(span2.context) }