Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(elasticsearch): support @elastic/elasticsearch client #1877

Merged
merged 42 commits into from
Dec 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
5115292
feat(elasticsearch): add support @elastic/elasticsearch client
trentm Nov 17, 2020
2d9c3fb
fix: use WeakMap to avoid possible leak, also useful to avoid request…
trentm Nov 18, 2020
364b71d
chore: setDbContext, tests
trentm Nov 19, 2020
03c82ee
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Nov 19, 2020
e30ab07
chore: remove forgotten test.only, prefix pathIsAQuery with '/' to av…
trentm Nov 19, 2020
abd3f4c
chore: destination context, a start at tests for error handling
trentm Nov 20, 2020
77f2097
chore: retry handling
trentm Nov 20, 2020
3553e0a
chore: key the map on the *result* object to simplify the code a bit
trentm Nov 20, 2020
fae5c21
chore: test that request.abort() works
trentm Nov 20, 2020
6e2d2dd
chore: drop diff vs master
trentm Nov 20, 2020
32a6078
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Nov 20, 2020
0418c40
fix: buglet adding 'null' span to active spans
trentm Nov 20, 2020
be8c419
chore: db context for _async_search, forward compat guards
trentm Nov 21, 2020
319b94c
chore: logging statement for 'request' event that seems common in som…
trentm Nov 23, 2020
ae032aa
chore: changelog and docs
trentm Nov 25, 2020
866046f
chore: add tav testing of new elasticsearch client
trentm Nov 25, 2020
afb7f7f
feat: add _sql and _eql to set of endpoints capturing the query
trentm Nov 27, 2020
d296e6f
chore: improve comment on req body capturing regex pattern
trentm Nov 27, 2020
c043341
chore: carefully control how ES client errors are captured
trentm Nov 27, 2020
2bb8f00
chore: make standard happy
trentm Nov 27, 2020
be28162
chore: share some functionality between the two elasticsearch modules
trentm Nov 27, 2020
6a89a78
chore: standard format
trentm Nov 27, 2020
7311b61
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Nov 27, 2020
8f09111
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Nov 30, 2020
a726415
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Dec 1, 2020
079c044
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Dec 11, 2020
471766f
fix 'error.exception.module' heuristic to correctly handle @-namespac…
trentm Dec 11, 2020
7f31ea4
fix formatting of changelog entry
trentm Dec 11, 2020
20984f2
use the new captureAttributes=false feature to avoid having to 'delet…
trentm Dec 11, 2020
c18221a
make fmt
trentm Dec 11, 2020
5837879
fix regex matching of frame path to work on windows
trentm Dec 14, 2020
90f4084
test: attempt to avoid a race in very overloaded/slow test infra (Tra…
trentm Dec 14, 2020
2266875
test: drop most TimeoutError-related tests
trentm Dec 14, 2020
ae819cf
test: change another timing-dependent (i.e. racy) test case to not be…
trentm Dec 14, 2020
b829eee
make fmt
trentm Dec 14, 2020
27595b0
add to CI TAV tests; bump ES server version used for testing from 6.6…
trentm Dec 14, 2020
c3eab5f
mysterious are the ways of yaml syntax
trentm Dec 15, 2020
a6c1f43
refactor, no functional change
trentm Dec 15, 2020
ae71b45
test: only test abort on versions of the package that support it
trentm Dec 15, 2020
be38d2b
test: exclude @elastic/[email protected] because of a bug that brok…
trentm Dec 15, 2020
ec08c86
Merge branch 'master' into trentm/new-elasticsearch-client
trentm Dec 15, 2020
1ed610d
fix doc buglet in auto-merge of changelog from merge with master
trentm Dec 15, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ci/.jenkins_tav.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
TAV:
- '@elastic/elasticsearch'
- '@hapi/hapi'
- '@koa/router'
- apollo-server-express
Expand Down
2 changes: 1 addition & 1 deletion .ci/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ services:
retries: 30

elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:6.6.0
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.1
environment:
trentm marked this conversation as resolved.
Show resolved Hide resolved
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "network.host="
Expand Down
9 changes: 9 additions & 0 deletions .tav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ koa-router:
elasticsearch:
versions: '>=8.0.0'
commands: node test/instrumentation/modules/elasticsearch.js

# @elastic/elasticsearch
trentm marked this conversation as resolved.
Show resolved Hide resolved
# - Version 7.7.0 included a change that broke usage with Node.js < 10.
# Fixed in 7.7.1: https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/changelog-client.html#_7_7_1
# Note: When Node.js v8 support is dropped, `versions` can be simplified.
'@elastic/elasticsearch':
versions: '>=7.0.0 <7.7.0 || >7.7.0 <8.0.0'
commands: node test/instrumentation/modules/@elastic/elasticsearch.js

handlebars:
versions: '*'
commands: node test/instrumentation/modules/handlebars.js
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,24 @@ Notes:
[float]
===== Features

* feat: Add automatic instrumentation of the https://github.com/elastic/elasticsearch-js[@elastic/elasticsearch] package {pull}1877[#1870]
+
The instrumentation of the legacy "elasticsearch" package has also changed
slightly to commonalize:
+
** "span.context.destination" is set on all Elasticsearch spans, not just a
subset of query-like API endpoints.
** For query-like API endpoints (e.g. `/_search`), the capturing of query details
on "span.context.db.statement" has changed (a) to include *both* the
query params and the request body if both exist (separated by `\n\n`) and
(b) to *URL encode* the query params, rather than JSON encoding.

* feat: Add `captureAttributes` boolean option to `apm.captureError()` to
allow *disabling* the automatic capture of Error object properties. This
is useful for cases where those properties should not be sent to the APM
Server, e.g. for performance (large string fields) or security (PII data).
{pull}1912[#1912]

* feat: Add `log_level` central config support. {pull}1908[#1908] +
Spec: https://github.com/elastic/apm/blob/master/specs/agents/logging.md

Expand Down
1 change: 1 addition & 0 deletions docs/supported-technologies.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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[elasticsearch] |>=8.0.0 |Will instrument all queries
|https://www.npmjs.com/package/@elastic/elasticsearch[@elastic/elasticsearch] |>=7.0.0 <8.0.0 |Will instrument all queries
|https://www.npmjs.com/package/graphql[graphql] |>=0.7.0 <16.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
Expand Down
63 changes: 63 additions & 0 deletions lib/instrumentation/elasticsearch-shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use strict'

// Shared functionality between the instrumentations of:
// - elasticsearch - the legacy Elasticsearch JS client
// - @elastic/elasticsearch - the new Elasticsearch JS client

const querystring = require('querystring')

// URL paths matching the following pattern will have their query params and
// request body captured in the span (as `context.db.statement`). We match
// a complete URL path component to attempt to avoid accidental matches of
// user data, like `GET /my_index_search/...`.
const pathIsAQuery = /\/(_search|_msearch|_count|_async_search|_sql|_eql)(\/|$)/

// (This is exported for testing.)
exports.pathIsAQuery = pathIsAQuery

// Set the span's `context.db` from the Elasticsearch request querystring and
// body, if the request path looks like it is a query API.
//
// Some ES endpoints, e.g. '_search', support both query params and a body.
// We encode both into 'span.context.db.statement', separated by '\n\n'
// if both are present. E.g. for a possible msearch:
//
// search_type=query_then_fetch&typed_keys=false
//
// {}
// {"query":{"query_string":{"query":"pants"}}}
exports.setElasticsearchDbContext = function (span, path, query, body) {
if (pathIsAQuery.test(path)) {
// From @elastic/elasticsearch: A read of Transport.js suggests query and
// body will always be serialized strings, however the documented
// `TransportRequestParams` allows for non-strings, so we will be defensive.
//
// From legacy elasticsearch: query will be an object and body will be an
// object, or an array of objects, e.g. for bulk endpoints.
const parts = []
if (query) {
if (typeof (query) === 'string') {
parts.push(query)
} else if (typeof (query) === 'object') {
const encodedQuery = querystring.encode(query)
if (encodedQuery) {
parts.push(encodedQuery)
}
}
}
if (body) {
if (typeof (body) === 'string') {
parts.push(body)
} else if (Array.isArray(body)) {
parts.push(body.map(JSON.stringify).join('\n')) // ndjson
} else if (typeof (body) === 'object') {
parts.push(JSON.stringify(body))
}
}

span.setDbContext({
type: 'elasticsearch',
statement: parts.join('\n\n')
})
}
}
1 change: 1 addition & 0 deletions lib/instrumentation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ var shimmer = require('./shimmer')
var Transaction = require('./transaction')

var MODULES = [
'@elastic/elasticsearch',
trentm marked this conversation as resolved.
Show resolved Hide resolved
'apollo-server-core',
trentm marked this conversation as resolved.
Show resolved Hide resolved
'bluebird',
'cassandra-driver',
Expand Down
123 changes: 123 additions & 0 deletions lib/instrumentation/modules/@elastic/elasticsearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use strict'

// Instrument the @elastic/elasticsearch module.
//
// This uses to 'request' and 'response' events from the Client (documented at
// https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/observability.html)
// to hook into all ES server interactions.

const { getDBDestination } = require('../../context')
const { setElasticsearchDbContext } = require('../../elasticsearch-shared')

module.exports = function (elasticsearch, agent, { version, enabled }) {
if (!enabled) {
return elasticsearch
}
if (!elasticsearch.Client) {
agent.logger.debug('@elastic/elasticsearch@%s is not supported (no `elasticsearch.Client`) - aborting...', version)
return elasticsearch
}

function generateSpan (params) {
const span = agent.startSpan(null, 'db', 'elasticsearch', 'request')
if (span === null) {
return null
}

span.name = `Elasticsearch: ${params.method} ${params.path}`
return span
}

class ApmClient extends elasticsearch.Client {
trentm marked this conversation as resolved.
Show resolved Hide resolved
constructor (...args) {
super(...args)

trentm marked this conversation as resolved.
Show resolved Hide resolved
// Mapping an ES client event `result` to its active span.
// - Use WeakMap to avoid a leak from possible spans that don't end.
// - WeakMap allows us to key off the ES client `request` object itself,
// which means we don't need to rely on `request.id`, which might be
// unreliable because it is user-settable (see `generateRequestId` at
// https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/observability.html)
const spanFromEsResult = new WeakMap()
trentm marked this conversation as resolved.
Show resolved Hide resolved

this.on('request', (err, result) => {
const request = result.meta.request
agent.logger.debug('intercepted call to @elastic/elasticsearch "request" event %o',
{ id: request.id, method: request.params.method, path: request.params.path })

let span = spanFromEsResult.get(result)

// As of @elastic/[email protected] this event's `err` will
// always be null, but we'll be future-proof.
if (err) {
agent.captureError(err)
if (span !== undefined) {
span.end()
spanFromEsResult.delete(result)
}
return
}

// With retries (see `makeRequest` in Transport.js) each attempt will
// emit this "request" event using the same `result` object. The
// intent is to have one Elasticsearch span plus an HTTP span for each
// attempt.
if (!span) {
span = generateSpan(request.params)
if (span) {
spanFromEsResult.set(result, span)
}
}
if (!span) {
return
}

setElasticsearchDbContext(span, request.params.path,
request.params.querystring, request.params.body)

const { hostname, port } = result.meta.connection.url
span.setDestinationContext(
getDBDestination(span, hostname, port))
})

this.on('response', (err, result) => {
// TODO set "span.outcome" (from err and result.statusCode)

if (err) {
// Error properties are specified here:
// https://github.com/elastic/elasticsearch-js/blob/master/lib/errors.d.ts
// - We capture some data from ResponseError, which is for
// Elasticsearch API errors:
// https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#common-options-error-options
// - Otherwise we explicitly turn off `captureAttributes` to avoid
// grabbing potentially large and sensitive properties like
// `err.data` on DeserializationError.
const errOpts = {
captureAttributes: false
}
if (err.name === 'ResponseError' && err.body && err.body.error) {
// Include some data from the Elasticsearch API response body:
// https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#common-options-error-options
errOpts.custom = {
type: err.body.error.type,
reason: err.body.error.reason,
caused_by: err.body.error.caused_by,
status: err.body.status
}
}

agent.captureError(err, errOpts)
}

const span = spanFromEsResult.get(result)
if (span !== undefined) {
span.end()
spanFromEsResult.delete(result)
}
})
}
}

agent.logger.debug('subclassing @elastic/elasticsearch.Client')
return Object.assign(elasticsearch, { Client: ApmClient })
}
30 changes: 9 additions & 21 deletions lib/instrumentation/modules/elasticsearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

var shimmer = require('../shimmer')
var { getDBDestination } = require('../context')

var queryRegexp = /_((search|msearch)(\/template)?|count)$/
const { setElasticsearchDbContext } = require('../elasticsearch-shared')
trentm marked this conversation as resolved.
Show resolved Hide resolved

module.exports = function (elasticsearch, agent, { enabled }) {
if (!enabled) return elasticsearch
Expand All @@ -19,33 +18,22 @@ 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)) {
const statement = Array.isArray(body)
? body.map(JSON.stringify).join('\n')
: JSON.stringify(body || query)
setElasticsearchDbContext(span, path, params && params.query,
params && params.body)

if (statement) {
span.setDbContext({
type: 'elasticsearch',
statement
})
}
// get the remote host information from elasticsearch Transport options
const transportConfig = this._config
let host, port
if (typeof transportConfig === 'object' && transportConfig.host) {
[host, port] = transportConfig.host.split(':')
}
span.setDestinationContext(getDBDestination(span, host, port))
// Get the remote host information from elasticsearch Transport options.
const transportConfig = this._config
let host, port
if (typeof transportConfig === 'object' && transportConfig.host) {
[host, port] = transportConfig.host.split(':')
}
span.setDestinationContext(getDBDestination(span, host, port))

if (typeof cb === 'function') {
var args = Array.prototype.slice.call(arguments)
Expand Down
23 changes: 21 additions & 2 deletions lib/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,32 @@ function getCulprit (frames) {
return filename ? fnName + ' (' + filename + ')' : fnName
}

// Infer the node.js module name from the top frame filename, if possible.
// Examples:
// /home/bob/.../node_modules/mymodule/index.js
// ^^^^^^^^
// /home/bob/.../node_modules/@myorg/mymodule/index.js
// ^^^^^^^^^^^^^^^
// or on Windows:
// C:\Users\bob\...\node_modules\@myorg\mymodule\lib\subpath\index.js
// ^^^^^^^^^^^^^^^
let SEP = path.sep
if (SEP === '\\') {
SEP = '\\' + SEP // Escape this for use in a regex.
}
const MODULE_NAME_REGEX = new RegExp(`node_modules${SEP}([^${SEP}]*)(${SEP}([^${SEP}]*))?`)
function getModule (frames) {
if (frames.length === 0) return
var frame = frames[0]
if (!frame.library_frame) return
var match = frame.filename.match(/node_modules\/([^/]*)/)
var match = frame.filename.match(MODULE_NAME_REGEX)
if (!match) return
return match[1]
var moduleName = match[1]
if (moduleName && moduleName[0] === '@' && match[3]) {
// Normalize the module name separator to '/', even on Windows.
moduleName += '/' + match[3]
}
return moduleName
trentm marked this conversation as resolved.
Show resolved Hide resolved
}

function tryJsonStringify (obj) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"@babel/cli": "^7.8.4",
"@babel/core": "^7.8.4",
"@babel/preset-env": "^7.8.4",
"@elastic/elasticsearch": "^7.10.0",
"@hapi/hapi": "^18.4.1",
"@koa/router": "^9.0.1",
"@types/node": "^13.7.4",
Expand Down
Loading