diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index efc5b03af2..99a929f093 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -53,6 +53,9 @@ Notes: [float] ===== Chores +* Inline the `elastic-apm-http-client` package code into this repo. + ({issues}3506[#3506]) + [[release-notes-3.48.0]] ==== 3.48.0 - 2023/07/07 diff --git a/NOTICE.md b/NOTICE.md index d1662e2c4d..14dbf8a28c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -41,6 +41,26 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` +## container-info + +- **path:** [lib/container-info.js](lib/container-info.js) +- **author:** Stephen Belanger +- **project url:** https://github.com/Qard/container-info +- **original file:** https://github.com/Qard/container-info/blob/master/index.js +- **license:** MIT License (MIT), http://opensource.org/licenses/MIT + +``` +### Copyright (c) 2018 Stephen Belanger + +#### Licensed under MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + ## shimmer - **path:** [lib/instrumentation/shimmer.js](lib/instrumentation/shimmer.js) diff --git a/lib/apm-client/apm-client.js b/lib/apm-client/apm-client.js index 8763197e1a..82ecd29d2d 100644 --- a/lib/apm-client/apm-client.js +++ b/lib/apm-client/apm-client.js @@ -6,14 +6,17 @@ 'use strict' -const ElasticAPMHttpClient = require('elastic-apm-http-client') - -const { CENTRAL_CONFIG_OPTS } = require('../config/schema') +const fs = require('fs') +const version = require('../../package').version +const { CENTRAL_CONFIG_OPTS, INTAKE_STRING_MAX_SIZE } = require('../config/schema') const { normalize } = require('../config/config') +const { CloudMetadata } = require('../cloud-metadata') +const { isLambdaExecutionEnvironment } = require('../lambda') const logging = require('../logging') +const { HttpApmClient } = require('./http-apm-client') const { NoopApmClient } = require('./noop-apm-client') -const { getHttpClientConfig } = require('./http-apm-client') +const { isAzureFunctionsEnvironment, getAzureFunctionsExtraMetadata } = require('../instrumentation/azure-functions') /** * Returns an APM client suited for the configuration provided @@ -28,7 +31,7 @@ function createApmClient (config, agent) { return config.transport(config, agent) } - const client = new ElasticAPMHttpClient(getHttpClientConfig(config, agent)) + const client = new HttpApmClient(getHttpClientConfig(config, agent)) client.on('config', remoteConf => { agent.logger.debug({ remoteConf }, 'central config received') @@ -104,6 +107,171 @@ function createApmClient (config, agent) { return client } +/** + * Returns a HTTP client configuration based on agent configuration options + * + * @param {Object} conf The agent configuration object + * @param {Object} agent + * @returns {Object} + */ +function getHttpClientConfig (conf, agent) { + let clientLogger = null + if (!logging.isLoggerCustom(agent.logger)) { + // https://www.elastic.co/guide/en/ecs/current/ecs-event.html#field-event-module + clientLogger = agent.logger.child({ 'event.module': 'apmclient' }) + } + const isLambda = isLambdaExecutionEnvironment() + + const clientConfig = { + agentName: 'nodejs', + agentVersion: version, + agentActivationMethod: agent._agentActivationMethod, + serviceName: conf.serviceName, + serviceVersion: conf.serviceVersion, + frameworkName: conf.frameworkName, + frameworkVersion: conf.frameworkVersion, + globalLabels: maybePairsToObject(conf.globalLabels), + configuredHostname: conf.hostname, + environment: conf.environment, + + // Sanitize conf + truncateKeywordsAt: INTAKE_STRING_MAX_SIZE, + truncateLongFieldsAt: conf.longFieldMaxLength, + // truncateErrorMessagesAt: see below + + // HTTP conf + secretToken: conf.secretToken, + apiKey: conf.apiKey, + userAgent: userAgentFromConf(conf), + serverUrl: conf.serverUrl, + serverCaCert: loadServerCaCertFile(conf.serverCaCertFile), + rejectUnauthorized: conf.verifyServerCert, + serverTimeout: conf.serverTimeout * 1000, + + // APM Agent Configuration via Kibana: + centralConfig: conf.centralConfig, + + // Streaming conf + size: conf.apiRequestSize, + time: conf.apiRequestTime * 1000, + maxQueueSize: conf.maxQueueSize, + + // Debugging/testing options + logger: clientLogger, + payloadLogFile: conf.payloadLogFile, + apmServerVersion: conf.apmServerVersion, + + // Container conf + containerId: conf.containerId, + kubernetesNodeName: conf.kubernetesNodeName, + kubernetesNamespace: conf.kubernetesNamespace, + kubernetesPodName: conf.kubernetesPodName, + kubernetesPodUID: conf.kubernetesPodUID + } + + // `service_node_name` is ignored in Lambda and Azure Functions envs. + if (conf.serviceNodeName) { + if (isLambda) { + agent.logger.warn({ serviceNodeName: conf.serviceNodeName }, 'ignoring "serviceNodeName" config setting in Lambda environment') + } else if (isAzureFunctionsEnvironment) { + agent.logger.warn({ serviceNodeName: conf.serviceNodeName }, 'ignoring "serviceNodeName" config setting in Azure Functions environment') + } else { + clientConfig.serviceNodeName = conf.serviceNodeName + } + } + + // Extra metadata handling. + if (isLambda) { + // Tell the Client to wait for a subsequent `.setExtraMetadata()` call + // before allowing intake requests. This will be called by `apm.lambda()` + // on first Lambda function invocation. + clientConfig.expectExtraMetadata = true + } else if (isAzureFunctionsEnvironment) { + clientConfig.extraMetadata = getAzureFunctionsExtraMetadata() + } else if (conf.cloudProvider !== 'none') { + clientConfig.cloudMetadataFetcher = new CloudMetadata(conf.cloudProvider, conf.logger, conf.serviceName) + } + + if (conf.errorMessageMaxLength !== undefined) { + // As of v10 of the http client, truncation of error messages will default + // to `truncateLongFieldsAt` if `truncateErrorMessagesAt` is not specified. + clientConfig.truncateErrorMessagesAt = conf.errorMessageMaxLength + } + + return clientConfig +} + +// Return the User-Agent string the agent will use for its comms to APM Server. +// +// Per https://github.com/elastic/apm/blob/main/specs/agents/transport.md#user-agent +// the pattern is roughly this: +// $repoName/$version ($serviceName $serviceVersion) +// +// The format of User-Agent is governed by https://datatracker.ietf.org/doc/html/rfc7231. +// User-Agent = product *( RWS ( product / comment ) ) +// We do not expect `$repoName` and `$version` to have surprise/invalid values. +// From `validateServiceName` above, we know that `$serviceName` is null or a +// string limited to `/^[a-zA-Z0-9 _-]+$/`. However, `$serviceVersion` is +// provided by the user and could have invalid characters. +// +// `comment` is defined by +// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 as: +// comment = "(" *( ctext / quoted-pair / comment ) ")" +// obs-text = %x80-FF +// ctext = HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text +// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) +// +// `commentBadChar` below *approximates* these rules, and is used to replace +// invalid characters with '_' in the generated User-Agent string. This +// replacement isn't part of the APM spec. +function userAgentFromConf (conf) { + let userAgent = `apm-agent-nodejs/${version}` + + // This regex *approximately* matches the allowed syntax for a "comment". + // It does not handle "quoted-pair" or a "comment" in a comment. + const commentBadChar = /[^\t \x21-\x27\x2a-\x5b\x5d-\x7e\x80-\xff]/g + const commentParts = [] + if (conf.serviceName) { + commentParts.push(conf.serviceName) + } + if (conf.serviceVersion) { + commentParts.push(conf.serviceVersion.replace(commentBadChar, '_')) + } + if (commentParts.length > 0) { + userAgent += ` (${commentParts.join(' ')})` + } + + return userAgent +} + +/** + * Reads te server CA cert file and returns a buffer with its contents + * @param {string | undefined} serverCaCertFile + * @param {any} logger + * @returns {Buffer} + */ +function loadServerCaCertFile (serverCaCertFile, logger) { + if (serverCaCertFile) { + try { + return fs.readFileSync(serverCaCertFile) + } catch (err) { + logger.error('Elastic APM initialization error: Can\'t read server CA cert file %s (%s)', serverCaCertFile, err.message) + } + } +} + +function maybePairsToObject (pairs) { + return pairs ? pairsToObject(pairs) : undefined +} + +function pairsToObject (pairs) { + return pairs.reduce((object, [key, value]) => { + object[key] = value + return object + }, {}) +} + module.exports = { - createApmClient + createApmClient, + userAgentFromConf } diff --git a/lib/apm-client/http-apm-client.js b/lib/apm-client/http-apm-client.js deleted file mode 100644 index 33f5f56ced..0000000000 --- a/lib/apm-client/http-apm-client.js +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and other contributors where applicable. - * Licensed under the BSD 2-Clause License; you may not use this file except in - * compliance with the BSD 2-Clause License. - */ - -'use strict' - -const fs = require('fs') -const version = require('../../package').version -const logging = require('../logging') -const { INTAKE_STRING_MAX_SIZE } = require('../config/schema') -const { CloudMetadata } = require('../cloud-metadata') -const { isLambdaExecutionEnvironment } = require('../lambda') -const { isAzureFunctionsEnvironment, getAzureFunctionsExtraMetadata } = require('../instrumentation/azure-functions') - -/** - * Returns a HTTP client configuration based on agent configuration options - * - * @param {Object} conf The agent configuration object - * @param {Object} agent - * @returns {Object} - */ -function getHttpClientConfig (conf, agent) { - let clientLogger = null - if (!logging.isLoggerCustom(agent.logger)) { - // https://www.elastic.co/guide/en/ecs/current/ecs-event.html#field-event-module - clientLogger = agent.logger.child({ 'event.module': 'apmclient' }) - } - const isLambda = isLambdaExecutionEnvironment() - - const clientConfig = { - agentName: 'nodejs', - agentVersion: version, - agentActivationMethod: agent._agentActivationMethod, - serviceName: conf.serviceName, - serviceVersion: conf.serviceVersion, - frameworkName: conf.frameworkName, - frameworkVersion: conf.frameworkVersion, - globalLabels: maybePairsToObject(conf.globalLabels), - configuredHostname: conf.hostname, - environment: conf.environment, - - // Sanitize conf - truncateKeywordsAt: INTAKE_STRING_MAX_SIZE, - truncateLongFieldsAt: conf.longFieldMaxLength, - // truncateErrorMessagesAt: see below - - // HTTP conf - secretToken: conf.secretToken, - apiKey: conf.apiKey, - userAgent: userAgentFromConf(conf), - serverUrl: conf.serverUrl, - serverCaCert: loadServerCaCertFile(conf.serverCaCertFile), - rejectUnauthorized: conf.verifyServerCert, - serverTimeout: conf.serverTimeout * 1000, - - // APM Agent Configuration via Kibana: - centralConfig: conf.centralConfig, - - // Streaming conf - size: conf.apiRequestSize, - time: conf.apiRequestTime * 1000, - maxQueueSize: conf.maxQueueSize, - - // Debugging/testing options - logger: clientLogger, - payloadLogFile: conf.payloadLogFile, - apmServerVersion: conf.apmServerVersion, - - // Container conf - containerId: conf.containerId, - kubernetesNodeName: conf.kubernetesNodeName, - kubernetesNamespace: conf.kubernetesNamespace, - kubernetesPodName: conf.kubernetesPodName, - kubernetesPodUID: conf.kubernetesPodUID - } - - // `service_node_name` is ignored in Lambda and Azure Functions envs. - if (conf.serviceNodeName) { - if (isLambda) { - agent.logger.warn({ serviceNodeName: conf.serviceNodeName }, 'ignoring "serviceNodeName" config setting in Lambda environment') - } else if (isAzureFunctionsEnvironment) { - agent.logger.warn({ serviceNodeName: conf.serviceNodeName }, 'ignoring "serviceNodeName" config setting in Azure Functions environment') - } else { - clientConfig.serviceNodeName = conf.serviceNodeName - } - } - - // Extra metadata handling. - if (isLambda) { - // Tell the Client to wait for a subsequent `.setExtraMetadata()` call - // before allowing intake requests. This will be called by `apm.lambda()` - // on first Lambda function invocation. - clientConfig.expectExtraMetadata = true - } else if (isAzureFunctionsEnvironment) { - clientConfig.extraMetadata = getAzureFunctionsExtraMetadata() - } else if (conf.cloudProvider !== 'none') { - clientConfig.cloudMetadataFetcher = new CloudMetadata(conf.cloudProvider, conf.logger, conf.serviceName) - } - - if (conf.errorMessageMaxLength !== undefined) { - // As of v10 of the http client, truncation of error messages will default - // to `truncateLongFieldsAt` if `truncateErrorMessagesAt` is not specified. - clientConfig.truncateErrorMessagesAt = conf.errorMessageMaxLength - } - - return clientConfig -} - -// Return the User-Agent string the agent will use for its comms to APM Server. -// -// Per https://github.com/elastic/apm/blob/main/specs/agents/transport.md#user-agent -// the pattern is roughly this: -// $repoName/$version ($serviceName $serviceVersion) -// -// The format of User-Agent is governed by https://datatracker.ietf.org/doc/html/rfc7231. -// User-Agent = product *( RWS ( product / comment ) ) -// We do not expect `$repoName` and `$version` to have surprise/invalid values. -// From `validateServiceName` above, we know that `$serviceName` is null or a -// string limited to `/^[a-zA-Z0-9 _-]+$/`. However, `$serviceVersion` is -// provided by the user and could have invalid characters. -// -// `comment` is defined by -// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 as: -// comment = "(" *( ctext / quoted-pair / comment ) ")" -// obs-text = %x80-FF -// ctext = HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text -// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) -// -// `commentBadChar` below *approximates* these rules, and is used to replace -// invalid characters with '_' in the generated User-Agent string. This -// replacement isn't part of the APM spec. -function userAgentFromConf (conf) { - let userAgent = `apm-agent-nodejs/${version}` - - // This regex *approximately* matches the allowed syntax for a "comment". - // It does not handle "quoted-pair" or a "comment" in a comment. - const commentBadChar = /[^\t \x21-\x27\x2a-\x5b\x5d-\x7e\x80-\xff]/g - const commentParts = [] - if (conf.serviceName) { - commentParts.push(conf.serviceName) - } - if (conf.serviceVersion) { - commentParts.push(conf.serviceVersion.replace(commentBadChar, '_')) - } - if (commentParts.length > 0) { - userAgent += ` (${commentParts.join(' ')})` - } - - return userAgent -} - -/** - * Reads te server CA cert file and returns a buffer with its contents - * @param {string | undefined} serverCaCertFile - * @param {any} logger - * @returns {Buffer} - */ -function loadServerCaCertFile (serverCaCertFile, logger) { - if (serverCaCertFile) { - try { - return fs.readFileSync(serverCaCertFile) - } catch (err) { - logger.error('Elastic APM initialization error: Can\'t read server CA cert file %s (%s)', serverCaCertFile, err.message) - } - } -} - -function maybePairsToObject (pairs) { - return pairs ? pairsToObject(pairs) : undefined -} - -function pairsToObject (pairs) { - return pairs.reduce((object, [key, value]) => { - object[key] = value - return object - }, {}) -} - -module.exports = { - getHttpClientConfig, - userAgentFromConf -} diff --git a/lib/apm-client/http-apm-client/CHANGELOG.md b/lib/apm-client/http-apm-client/CHANGELOG.md new file mode 100644 index 0000000000..ab4534bb38 --- /dev/null +++ b/lib/apm-client/http-apm-client/CHANGELOG.md @@ -0,0 +1,271 @@ +# elastic-apm-http-client changelog + +Note: After v12.0.0 the `elastic-apm-http-client` package code was included +in this repo. This repo was the only intended user of the http-client package. + +## v12.0.0 + +- **Breaking change.** The `hostname` configuration option has been renamed to + `configuredHostname`. As well, the hostname detection has changed to prefer + using a FQDN, if available. See [the spec](https://github.com/elastic/apm/blob/main/specs/agents/metadata.md#hostname). + (https://github.com/elastic/apm-agent-nodejs/issues/3310) + +- The APM client will send `metadata.system.detected_hostname` and + `metadata.system.configured_hostname` as appropriate for APM server versions + >=7.4, rather than the now deprecated `metadata.system.hostname`. + See [the spec](https://github.com/elastic/apm/blob/main/specs/agents/metadata.md#hostname). + +## v11.4.0 + +- Add support for pre-registering of partial transactions for AWS Lambda. + This adds `client.lambdaShouldRegisterTransactions()` and + `client.lambdaRegisterTransaction(transaction, awsRequestId)` so the + APM agent can register a partial transaction with the Elastic Lambda + extension before executing the user's handler. In some error cases + (`uncaughtException`, `unhandledRejection`, Lambda timeout), the extension + can report that transaction when the APM agent is unable. + (https://github.com/elastic/apm-agent-nodejs/issues/3136) + +## v11.3.1 + +- Tweak logic to only exclude `metadata.service.agent.activation_method` when + the APM server version is known to be 8.7.0 -- i.e. optimistically assume + it is a version that is fine. The APM server 8.7.0 issue isn't so severe that + we want a fast first serverless function invocation to not send the field. + (https://github.com/elastic/apm/pull/783) + +## v11.3.0 + +- Ensure `metadata.service.agent.activation_method` is only sent for APM + server version 8.7.1 or later. APM server 8.7.0 included a bug where + receiving `activation_method` is harmful. + (https://github.com/elastic/apm-agent-nodejs/issues/3230) + + This change adds the `client.supportsActivationMethodField()` method. + +## v11.2.0 + +- Support a new `agentActivationMethod` string config var that is added to + `metadata.service.agent.activation_method`. Spec: + https://github.com/elastic/apm/blob/main/specs/agents/metadata.md#activation-method + +## v11.1.0 + +- Add an `extraMetadata` config option, which is an object to merge into the + built metadata object. This is an alternative to the existing + `cloudMetadataFetcher` and `expectExtraMetadata` options which provide ways + to asynchronously provide metadata. Only one (or zero) of these three options + may be used. + +## v11.0.4 + +- Update the default `serverUrl` to "http://127.0.0.1:8200". We no longer use + "localhost" to avoid ambiguity if localhost resolves to multiple addresses + (e.g. IPv4 and IPv6). APM server only listens on IPv4 by default. + (https://github.com/elastic/apm-agent-nodejs/pull/3049) + +## v11.0.3 + +- Prevent a possible tight loop in central config fetching. + (https://github.com/elastic/apm-agent-nodejs/issues/3029) + +## v11.0.2 + +**Bad release. Upgrade to 11.0.3.** + +- Add guards to ensure that a crazy `Cache-Control: max-age=...` response + header cannot accidentally result in inappropriate intervals for fetching + central config. The re-fetch delay is clamped to `[5 seconds, 1 day]`. + (https://github.com/elastic/apm-agent-nodejs/issues/2941) + +- Improve container-info gathering to support AWS ECS/Fargate environments. + (https://github.com/elastic/apm-agent-nodejs/issues/2914) + +## v11.0.1 + +- Fix an issue when running in a Lambda function, where a missing or erroring + APM Lambda extension could result in apmclient back-off such that (a) the + end-of-lambda-invocation signaling (`?flushed=true`) would not happen and + (b) premature "beforeExit" event could result in the Lambda Runtime + responding `null` before the Lambda function could respond + (https://github.com/elastic/apm-agent-nodejs/issues/1831). + +## v11.0.0 + +- Add support for coordinating data flushing in an AWS Lambda environment. The + following two API additions are used to ensure that (a) the Elastic Lambda + extension is signaled at invocation end [per spec](https://github.com/elastic/apm/blob/main/specs/agents/tracing-instrumentation-aws-lambda.md#data-flushing) + and (b) a new intake request is not started when a Lambda function invocation + is not active. + + - `Client#lambdaStart()` should be used to indicate when a Lambda function + invocation begins. + - `Client#flush([opts,] cb)` now supports an optional `opts.lambdaEnd` + boolean. Set it to true to indicate this is a flush at the end of a Lambda + function invocation. + + This is a **BREAKING CHANGE**, because current versions of elastic-apm-node + depend on `^10.4.0`. If this were released as another 10.x, then usage of + current elastic-apm-node with this version of the client would break + behavior in a Lambda environment. + +- Add the `freeSocketTimeout` option, with a default of 4000 (ms), and switch + from Node.js's core `http.Agent` to the [agentkeepalive package](https://github.com/node-modules/agentkeepalive) + to fix ECONNRESET issues with HTTP Keep-Alive usage talking to APM Server + (https://github.com/elastic/apm-agent-nodejs/issues/2594). + +## v10.4.0 + +- Add APM Server version checking to the client. On creation the client will + call the [APM Server Information API](https://www.elastic.co/guide/en/apm/server/current/server-info.html) + to get the server version and save that. + + The new `Client#supportsKeepingUnsampledTransaction()` boolean method returns + `true` if APM Server is a version that requires unsampled transactions to + be sent. This will be used by the APM Agent to [drop unsampled transactions + for newer APM Servers](https://github.com/elastic/apm-agent-nodejs/issues/2455). + + There is a new `apmServerVersion: ` config option to tell the Client + to skip fetching the APM Server version and use the given value. This config + option is intended mainly for internal test suite usage. + +## v10.3.0 + +- Add the `expectExtraMetadata: true` configuration option and + `Client#setExtraMetadata(metadata)` method to provide a mechanism for the + Node.js APM Agent to pass in metadata asynchronously and be sure that the + client will not begin an intake request until that metadata is provided. + This is to support passing in [AWS Lambda metadata that cannot be gathered + until the first Lambda function + invocation](https://github.com/elastic/apm-agent-nodejs/issues/2404). + (Note: The `expectExtraMetadata` option cannot be used in combination with + `cloudMetadataFetcher`.) + +- Use `Z_BEST_SPEED` for gzip compression per + https://github.com/elastic/apm/blob/main/specs/agents/transport.md#compression + +## v10.2.0 + +- The client will no longer append data to the configured `userAgent` string. + Before this it would append " elastic-apm-http-client/$ver node/$ver". This + is to support [the APM agents spec for + User-Agent](https://github.com/elastic/apm/blob/main/specs/agents/transport.md#user-agent). + + +## v10.1.0 + +- Fix client handling of an AWS Lambda environment: + 1. `client.flush()` will initiate a quicker completion of the current intake + request. + 2. The process 'beforeExit' event is *not* used to start a graceful shutdown + of the client, because the Lambda Runtime sometimes uses 'beforeExit' to + handle *freezing* of the Lambda VM instance. That VM instance is typically + unfrozen and used again, for which this Client is still needed. + +## v10.0.0 + +- All truncation of string fields (per `truncate*At` config options) have + changed from truncating at a number of unicode chars, rather than a number + of bytes. This is both faster and matches [the json-schema spec](https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.3.1) + for [apm-server intake fields](https://www.elastic.co/guide/en/apm/server/current/events-api.html#events-api-schema-definition) + that specify `maxLength`. +- BREAKING CHANGE: The `truncateQueriesAt` config option has been removed. +- In its place the `truncateLongFieldsAt` config option has been added to cover + `span.context.db.statement` and a number of other possibly-long fields (per + [spec](https://github.com/elastic/apm/blob/main/specs/agents/field-limits.md#long_field_max_length-configuration)). + This *does* mean that in rare cases of long field values longer than the + default 10000 chars, this change will result in those values being truncated. +- The `truncateErrorMessagesAt` config option has been deprecated, in favor + of `truncateLongFieldsAt`. Note, however, that `truncateLongFieldsAt` does + *not* support the special case `-1` value to disable truncation. If + `truncateErrorMessagesAt` is not specified, the value for + `truncateLongFieldsAt` is used. This means the effective default is now 10000, + no longer 2048. + +## v9.9.0 + +- feat: Use uninstrumented HTTP(S) client request functions to avoid tracing + requests made by the APM agent itself. + ([#161](https://github.com/elastic/apm-nodejs-http-client/pull/161)) + +## v9.8.1 + +- perf: eliminate encodeObject stack and faster loop in `_writeBatch` + ([#159](https://github.com/elastic/apm-nodejs-http-client/pull/159)) +- test: start testing with node 16 + ([#157](https://github.com/elastic/apm-nodejs-http-client/pull/157)) + +## v9.8.0 + +- Add `client.addMetadataFilter(fn)`. See the + [APM agent issue](https://github.com/elastic/apm-agent-nodejs/issues/1916). + +## v9.7.1 + +- Fix to ensure the `client.flush(cb)` callback is called in the (expected to + be rare) case where there are no active handles -- i.e., the process is + exiting. + ([#150](https://github.com/elastic/apm-nodejs-http-client/issues/150)) + +## v9.7.0 + +- A number of changes were made to fix issues with the APM agent under heavy + load and with a slow or non-responsive APM server. + ([#144](https://github.com/elastic/apm-nodejs-http-client/pull/144)) + + 1. A new `maxQueueSize` config option is added (default 1024 for now) to + control how many events (transactions, spans, errors, metricsets) + will be queued before being dropped if events are incoming faster + than can be sent to APM server. This ensures the APM agent memory usage + does not grow unbounded. + + 2. JSON encoding of events (when uncorking) is done in limited size + batches to control the amount of single chunk CPU eventloop blocking + time. (See MAX_WRITE_BATCH_SIZE in Client._writev.) Internal stats + are collected to watch for long(est) batch processing times. + + 3. The handling of individual requests to the APM Server intake API has + be rewritten to handle some error cases -- especially from a + non-responsive APM server -- and to ensure that only one intake + request is being performed at a time. Two new config options -- + `intakeResTimeout` and `intakeResTimeoutOnEnd` -- have been added to + allow fine control over some parts of this handling. See the comment on + `makeIntakeRequest` for the best overview. + + 4. Support for backoff on intake API requests has been implemented per + https://github.com/elastic/apm/blob/main/specs/agents/transport.md#transport-errors + +- Started testing against node v15 in preparation for supporting the coming + node v16. + +## v9.6.0 + +- Fix config initialization such that the keep-alive agent is used all the + time, as intended. Before this change the keep-alive HTTP(S) agent would only + be used if a second call to `client.config(...)` was made. For the [Elastic + APM Agent](https://github.com/elastic/apm-agent-nodejs)'s usage of this + module, that was when any of the express, fastify, restify, hapi, or koa + modules was instrumented. ([#139](https://github.com/elastic/apm-nodejs-http-client/pull/139)) + + A compatibility note for direct users of this APM http-client: + Options passed to the + [`Writable`](https://nodejs.org/api/stream.html#stream_new_stream_writable_options) + and [`http[s].Agent`](https://nodejs.org/api/http.html#http_new_agent_options) + constructors no longer include the full options object passed to the + [Client constructor](https://github.com/elastic/apm-nodejs-http-client/blob/main/README.md#new-clientoptions). + Therefore usage of *undocumented* options can no longer be used. + +## v9.5.1 + +- Fix possible crash when polling apm-server for config. Specifically it + could happen with the Elastic Node.js APM agent when: + + 1. using node.js v12; + 2. instrumenting one of hapi, restify, koa, express, or fastify; and + 3. on a *second* request to APM server *that fails* (non-200 response). + + https://github.com/elastic/apm-agent-nodejs/issues/1749 + +## v9.5.0 + +(This changelog was started after the 9.5.0 release.) diff --git a/lib/apm-client/http-apm-client/README.md b/lib/apm-client/http-apm-client/README.md new file mode 100644 index 0000000000..923661b9e1 --- /dev/null +++ b/lib/apm-client/http-apm-client/README.md @@ -0,0 +1,485 @@ +# http-apm-client + +A low-level HTTP client for communicating with the Elastic APM intake +API version 2. This code was moved here from the once-separate +[Elastic APM HTTP client repo](https://github.com/elastic/apm-nodejs-http-client) + + +## Example Usage + +```js +const { HttpApmClient } = require('path/to/lib/http-apm-client') + +const client = new HttpApmClient({ + serviceName: 'My App', + agentName: 'my-nodejs-agent', + agentVersion: require('./package.json').version, + userAgent: 'My Custom Elastic APM Agent' + // ... other options. +}) + +const span = { + name: 'SELECT FROM users', + duration: 42, + start: 0, + type: 'db.mysql.query' +} + +client.sendSpan(span) +``` + +## API + +### `new HttpApmClient(options)` + +Construct a new `client` object. Data given to the client will be +converted to ndjson, compressed using gzip, and streamed to the APM +Server. + +Arguments: + +- `options` - An object containing config options (see below). All options + are optional, except those marked "(required)". + +Data sent to the APM Server as part of the [metadata object](https://www.elastic.co/guide/en/apm/server/current/metadata-api.html). +See also the "Cloud & Extra Metadata" section below. + +- `agentName` - (required) The APM agent name +- `agentVersion` - (required) The APM agent version +- `agentActivationMethod` - An enum string ([spec](https://github.com/elastic/apm/blob/main/specs/agents/metadata.md#activation-method)) that identifies the way this agent was activated/started +- `serviceName` - (required) The name of the service being instrumented +- `serviceNodeName` - Unique name of the service being instrumented +- `serviceVersion` - The version of the service being instrumented +- `frameworkName` - If the service being instrumented is running a + specific framework, use this config option to log its name +- `frameworkVersion` - If the service being instrumented is running a + specific framework, use this config option to log its version +- `configuredHostname` - A user-configured hostname, if any, e.g. from the `ELASTIC_APM_HOSTNAME` envvar. + See . +- `environment` - Environment name (default: `process.env.NODE_ENV || 'development'`) +- `containerId` - Docker container id, if not given will be parsed from `/proc/self/cgroup` +- `kubernetesNodeName` - Kubernetes node name +- `kubernetesNamespace` - Kubernetes namespace +- `kubernetesPodName` - Kubernetes pod name, if not given will be the hostname +- `kubernetesPodUID` - Kubernetes pod id, if not given will be parsed from `/proc/self/cgroup` +- `globalLabels` - An object of key/value pairs to use to label all data reported (only applied when using APM Server 7.1+) + +HTTP client configuration: + +- `userAgent` - (required) The HTTP user agent that your module should + identify itself as +- `secretToken` - The Elastic APM intake API secret token +- `apiKey` - Elastic APM API key +- `serverUrl` - The APM Server URL (default: `http://127.0.0.1:8200`) +- `headers` - An object containing extra HTTP headers that should be + used when making HTTP requests to he APM Server +- `rejectUnauthorized` - Set to `false` if the client shouldn't verify + the APM Server TLS certificates (default: `true`) +- `serverCaCert` - The CA certificate used to verify the APM Server's + TLS certificate, and has the same requirements as the `ca` option + of [`tls.createSecureContext`](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options). +- `serverTimeout` - HTTP request timeout in milliseconds. If no data is + sent or received on the socket for this amount of time, the request + will be aborted. It's not recommended to set a `serverTimeout` lower + than the `time` config option. That might result in healthy requests + being aborted prematurely. (default: `15000` ms) +- `keepAlive` - If set the `false` the client will not reuse sockets + between requests (default: `true`) +- `keepAliveMsecs` - When using the `keepAlive` option, specifies the + initial delay for TCP Keep-Alive packets. Ignored when the `keepAlive` + option is `false` or `undefined` (default: `1000` ms) +- `maxSockets` - Maximum number of sockets to allow per host (default: + `Infinity`) +- `maxFreeSockets` - Maximum number of sockets to leave open in a free + state. Only relevant if `keepAlive` is set to `true` (default: `256`) +- `freeSocketTimeout` - A number of milliseconds of inactivity on a free + (kept-alive) socket after which to timeout and recycle the socket. Set this to + a value less than the HTTP Keep-Alive timeout of the APM server to avoid + [ECONNRESET exceptions](https://medium.com/ssense-tech/reduce-networking-errors-in-nodejs-23b4eb9f2d83). + This defaults to 4000ms to be less than the [node.js HTTP server default of + 5s](https://nodejs.org/api/http.html#serverkeepalivetimeout) (useful when + using a Node.js-based mock APM server) and the [Go lang Dialer `KeepAlive` + default of 15s](https://pkg.go.dev/net#Dialer) (when talking to the Elastic + APM Lambda extension). (default: `4000`) + +Cloud & Extra Metadata Configuration. Zero or one of the following three +options may be used. + +- `cloudMetadataFetcher` - An object with a `getCloudMetadata(cb)` method + for fetching metadata related to the current cloud environment. The callback + is of the form `function (err, cloudMetadata)` and the returned `cloudMetadata` + will be set on `metadata.cloud` for intake requests to APM Server. If + provided, this client will not begin any intake requests until the callback + is called. +- `expectExtraMetadata` - A boolean option to indicate that the client should + not allow any intake requests to begin until `cloud.setExtraMetadata(...)` + has been called. It is the responsibility of the caller to call + `cloud.setExtraMetadata()`. If not, then the Client will never perform an + intake request. +- `extraMetadata` - An object with extra metadata to merge into the metadata + object created from the individual fields above. + +APM Agent Configuration via Kibana: + +- `centralConfig` - Whether or not the client should poll the APM + Server regularly for new agent configuration. If set to `true`, the + `config` event will be emitted when there's an update to an agent config + option (default: `false`). _Requires APM Server v7.3 or later and that + the APM Server is configured with `kibana.enabled: true`._ + +Streaming configuration: + +- `size` - The maxiumum compressed body size (in bytes) of each HTTP + request to the APM Server. An overshoot of up to the size of the + internal zlib buffer should be expected as the buffer is flushed after + this limit is reached. The default zlib buffer size is 16kB. (default: + `768000` bytes) +- `time` - The maxiumum number of milliseconds a streaming HTTP request + to the APM Server can be ongoing before it's ended. Set to `-1` to + disable (default: `10000` ms) +- `bufferWindowTime` - Objects written in quick succession are buffered + and grouped into larger clusters that can be processed as a whole. + This config option controls the maximum time that buffer can live + before it's flushed (counted in milliseconds). Set to `-1` for no + buffering (default: `20` ms) +- `bufferWindowSize` - Objects written in quick succession are buffered + and grouped into larger clusters that can be processed as a whole. + This config option controls the maximum size of that buffer (counted + in number of objects). Set to `-1` for no max size (default: `50` + objects) +- `maxQueueSize` - The maximum number of buffered events (transactions, + spans, errors, metricsets). Events are buffered when the agent can't keep + up with sending them to the APM Server or if the APM server is down. + If the queue is full, events are rejected which means transactions, spans, + etc. will be lost. This guards the application from consuming unbounded + memory, possibly overusing CPU (spent on serializing events), and possibly + crashing in case the APM server is unavailable for a long period of time. A + lower value will decrease the heap overhead of the agent, while a higher + value makes it less likely to lose events in case of a temporary spike in + throughput. (default: 1024) +- `intakeResTimeout` - The time (in milliseconds) by which a response from the + [APM Server events intake API](https://www.elastic.co/guide/en/apm/server/current/events-api.html) + is expected *after all the event data for that request has been sent*. This + allows a smaller timeout than `serverTimeout` to handle an APM server that + is accepting connections but is slow to respond. (default: `10000` ms) +- `intakeResTimeoutOnEnd` - The same as `intakeResTimeout`, but used when + the client has ended, hence for the possible last request to APM server. This + is typically a lower value to not hang an ending process that is waiting for + that APM server request to complete. (default: `1000` ms) + +Data sanitizing configuration: + +- `truncateKeywordsAt` - Maximum size in unicode characters for strings stored + as Elasticsearch keywords. Strings larger than this will be trucated + (default: `1024`) +- `truncateLongFieldsAt` - The maximum size in unicode characters for a + specific set of long string fields. String values above this length will be + truncated. Default: `10000`. This applies to the following fields: + - `transaction.context.request.body`, `error.context.request.body` + - `transaction.context.message.body`, `span.context.message.body`, `error.context.message.body` + - `span.context.db.statement` + - `error.exception.message` (unless `truncateErrorMessagesAt` is specified) + - `error.log.message` (unless `truncateErrorMessagesAt` is specified) +- `truncateStringsAt` - The maximum size in unicode characters for strings. + String values above this length will be truncated (default: `1024`) +- `truncateErrorMessagesAt` - **DEPRECATED:** prefer `truncateLongFieldsAt`. + The maximum size in unicode characters for error messages. Messages above this + length will be truncated. Set to `-1` to disable truncation. This applies to + the following properties: `error.exception.message` and `error.log.message`. + (default: `2048`) + +Other options: + +- `logger` - A [pino](https://getpino.io) logger to use for trace and + debug-level logging. +- `payloadLogFile` - Specify a file path to which a copy of all data + sent to the APM Server should be written. The data will be in ndjson + format and will be uncompressed. Note that using this option can + impact performance. +- `apmServerVersion` - A string version to assume is the version of the + APM Server at `serverUrl`. This option is typically only used for testing. + Normally this client will fetch the APM Server version at startup via a + `GET /` request. Setting this option avoids that request. + +### Event: `config` + +Emitted every time a change to the agent config is pulled from the APM +Server. The listener is passed the updated config options as a key/value +object. + +Each key is the lowercase version of the environment variable, without +the `ELASTIC_APM_` prefix, e.g. `transaction_sample_rate` instead of +`ELASTIC_APM_TRANSACTION_SAMPLE_RATE`. + +If no central configuration is set up for the given `serviceName` / +`environment` when the client is started, this event will be emitted +once with an empty object. This will also happen after central +configuration for the given `serviceName` / `environment` is deleted. + +### Event: `close` + +The `close` event is emitted when the client and any of its underlying +resources have been closed. The event indicates that no more events will +be emitted, and no more data can be sent by the client. + +### Event: `error` + +Emitted if an error occurs. The listener callback is passed a single +Error argument when called. + +### Event: `finish` + +The `finish` event is emitted after the `client.end()` method has been +called, and all data has been flushed to the underlying system. + +### Event: `request-error` + +Emitted if an error occurs while communicating with the APM Server. The +listener callback is passed a single Error argument when called. + +The request to the APM Server that caused the error is terminated and +the data included in that request is lost. This is normally only +important to consider for requests to the Intake API. + +If a non-2xx response was received from the APM Server, the status code +will be available on `error.code`. + +For requests to the Intake API where the response is a structured error +message, the `error` object will have the following properties: + +- `error.accepted` - An integer indicating how many events was accepted + as part of the failed request. If 100 events was sent to the APM + Server as part of the request, and the error reports only 98 as + accepted, it means that two events either wasn't received or couldn't + be processed for some reason +- `error.errors` - An array of error messages. Each element in the array + is an object containing a `message` property (String) and an optional + `document` property (String). If the `document` property is given it + will contain the failed event as it was received by the APM Server + +If the response contained an error body that could not be parsed by the +client, the raw body will be available on `error.response`. + +The client is not closed when the `request-error` event is emitted. + +### `client.sent` + +An integer indicating the number of events (spans, transactions, errors, or +metricsets) sent by the client. An event is considered sent when the HTTP +request used to transmit it has ended. Note that errors in requests to APM +server may mean this value is not the same as the number of events *accepted* +by the APM server. + +### `client.config(options)` + +Update the configuration given to the `Client` constructor. All +configuration options can be updated except: + +- `size` +- `time` +- `keepAlive` +- `keepAliveMsecs` +- `maxSockets` +- `maxFreeSockets` +- `centralConfig` + +### `client.supportsKeepingUnsampledTransaction()` + +This method returns a boolean indicating whether the remote APM Server (per +the configured `serverUrl`) is of a version that requires unsampled transactions +to be sent. + +This defaults to `true` if the remote APM server version is not known -- either +because the background fetch of the APM Server version hasn't yet completed, +or the version could not be fetched. + +### `client.supportsActivationMethodField()` + +This method returns a boolean indicating whether the remote APM Server (per +the configured `serverUrl`) is of a version that supports the +`metadata.service.agent.activation_method` field. This is true for APM server +versions >=8.7.1. It defaults to true if the APM server version is not (yet) +known. + +### `client.addMetadataFilter(fn)` + +Add a filter function for the ["metadata" object](https://www.elastic.co/guide/en/apm/server/current/metadata-api.html) +sent to APM server. This will be called once at client creation, and possibly +again later if `client.config()` is called to reconfigure the client or +`client.addMetadataFilter(fn)` is called to add additional filters. + +Here is an example of a filter that removes the `metadata.process.argv` field: + +```js +apm.addMetadataFilter(function dropArgv(md) { + if (md.process && md.process.argv) { + delete md.process.argv + } + return md +}) +``` + +It is up to the user to ensure the returned object conforms to the +[metadata schema](https://www.elastic.co/guide/en/apm/server/current/metadata-api.html), +otherwise APM data injest will be broken. An example of that (when used with +the Node.js APM agent) is this in the application's log: + +``` +[2021-04-14T22:28:35.419Z] ERROR (elastic-apm-node): APM Server transport error (400): Unexpected APM Server response +APM Server accepted 0 events in the last request +Error: validation error: 'metadata' required + Document: {"metadata":null} +``` + +See the [APM Agent `addMetadataFilter` documentation](https://www.elastic.co/guide/en/apm/agent/nodejs/current/agent-api.html#apm-add-metadata-filter) +for further details. + +### `client.setExtraMetadata([metadata])` + +Add extra metadata to be included in the "metadata" object sent to APM Server in +intake requests. The given `metadata` object is merged into the metadata +determined from the client configuration. + +The reason this exists is to allow some metadata to be provided asynchronously, +especially in combination with the `expectExtraMetadata` configuration option +to ensure that event data is not sent to APM Server until this extra metadata +is provided. For example, in an AWS Lambda function some metadata is not +available until the first function invocation -- which is some async time after +Client creation. + +### `client.lambdaStart()` + +Tells the client that a Lambda function invocation has started. +See [Notes on Lambda Usage](#notes-on-lambda-usage) below. + +### `client.lambdaShouldRegisterTransactions()` + +This returns a boolean indicating if the APM agent -- when running in a Lambda +environment -- should bother calling `client.lambdaRegisterTransaction(...)`. +This can help the APM agent avoid some processing gathering transaction data. + +Typically the reason this would return `false` is when the Lambda extension is +too old to support registering transactions. + + +### `client.lambdaRegisterTransaction(transaction, awsRequestId)` + +Tells the Lambda Extension about the ongoing transaction, so that data can be +used to report the transaction in certain error cases -- e.g. a Lambda handler +timeout. See [Notes on Lambda Usage](#notes-on-lambda-usage) below. + +Arguments: + +- `transaction` - A transaction object that can be serialized to JSON. +- `awsRequestId` - The AWS request ID for this invocation. This is a UUID + available on the Lambda context object. + +### `client.sendSpan(span[, callback])` + +Send a span to the APM Server. + +Arguments: + +- `span` - A span object that can be serialized to JSON +- `callback` - Callback is called when the `span` have been flushed to + the underlying system + +### `client.sendTransaction(transaction[, callback])` + +Send a transaction to the APM Server. + +Arguments: + +- `transaction` - A transaction object that can be serialized to JSON +- `callback` - Callback is called when the `transaction` have been + flushed to the underlying system + +### `client.sendError(error[, callback])` + +Send a error to the APM Server. + +Arguments: + +- `error` - A error object that can be serialized to JSON +- `callback` - Callback is called when the `error` have been flushed to + the underlying system + +### `client.sendMetricSet(metricset[, callback])` + +Send a metricset to the APM Server. + +Arguments: + +- `error` - A error object that can be serialized to JSON +- `callback` - Callback is called when the `metricset` have been flushed to + the underlying system + +### `client.flush([opts,] [callback])` + +Flush the internal buffer and end the current HTTP request to the APM +Server. If no HTTP request is in process nothing happens. In an AWS Lambda +environment this will also initiate a quicker shutdown of the intake request, +because the APM agent always flushes at the end of a Lambda handler. + +Arguments: + +- `opts`: + - `opts.lambdaEnd` - An optional boolean to indicate if this is the final + flush at the end of the Lambda function invocation. The client will do + some extra handling if this is the case. See notes in `client.lambdaStart()` + above. +- `callback` - Callback is called when the internal buffer has been + flushed and the HTTP request ended. If no HTTP request is in progress + the callback is called in the next tick. + +### `client.end([callback])` + +Calling the `client.end()` method signals that no more data will be sent +to the `client`. If the internal buffer contains any data, this is +flushed before ending. + +Arguments: + +- `callback` - If provided, the optional `callback` function is attached + as a listener for the 'finish' event + +### `client.destroy()` + +Destroy the `client`. After this call, the client has ended and +subsequent calls to `sendSpan()`, `sendTransaction()`, `sendError()`, +`flush()`, or `end()` will result in an error. + +## Notes on Lambda usage + +To properly handle [data flushing for instrumented Lambda functions](https://github.com/elastic/apm/blob/main/specs/agents/tracing-instrumentation-aws-lambda.md#data-flushing) +this Client should be used as follows in a Lambda environment. + + 1. Ensure that metadata is set before any of the following calls. Typically + in Lambda this is done by (a) configuring the client with + `expectExtraMetadata` and (b) calling `setExtraMetadata()` at the start of + the first invocation. + + 2. When a Lambda invocation starts, `client.lambdaStart()` must be called. + The Client prevents intake requests to APM Server when in a Lambda + environment when a function invocation is *not* active. This is to ensure + that an intake request does not accidentally span a period when a Lambda VM + is frozen, which can lead to timeouts and lost APM data. + + 3. When the transaction for this Lambda invocation has been created, + `await client.lambdaRegisterTransaction(, )` should be + called. This is used to pass transaction details to the Lambda Extension so + a transaction can be reported in certain failure modes (e.g. a Lambda + handler timeout). + + `client.lambdaShouldRegisterTransactions()` can be used to avoid gathering + data for this call. + + 4. When a Lambda invocation finishes, `client.flush({lambdaEnd: true}, cb)` + must be called. + + The `lambdaEnd: true` tells the Client to (a) mark the lambda as inactive so + a subsequent intake request is not started until the next invocation, and + (b) signal the Elastic AWS Lambda Extension that this invocation is done. + The user's Lambda handler should not finish until `cb` is called. This + ensures that the extension receives tracing data and the end signal before + the Lambda Runtime freezes the VM. diff --git a/lib/apm-client/http-apm-client/central-config.js b/lib/apm-client/http-apm-client/central-config.js new file mode 100644 index 0000000000..5125b7cadb --- /dev/null +++ b/lib/apm-client/http-apm-client/central-config.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Central config-related utilities for the APM http client. + +const INTERVAL_DEFAULT_S = 300 // 5 min +const INTERVAL_MIN_S = 5 +const INTERVAL_MAX_S = 86400 // 1d + +/** + * Determine an appropriate delay until the next fetch of Central Config. + * Default to 5 minutes, minimum 5s, max 1d. + * + * The maximum of 1d ensures we don't get surprised by an overflow value to + * `setTimeout` per https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value + * + * @param {Number|undefined} seconds - A number of seconds, typically pulled + * from a `Cache-Control: max-age=${seconds}` header on a previous central + * config request. + * @returns {Number} + */ +function getCentralConfigIntervalS (seconds) { + if (typeof seconds !== 'number' || isNaN(seconds) || seconds <= 0) { + return INTERVAL_DEFAULT_S + } + return Math.min(Math.max(seconds, INTERVAL_MIN_S), INTERVAL_MAX_S) +} + +module.exports = { + getCentralConfigIntervalS, + + // These are exported for testing. + INTERVAL_DEFAULT_S, + INTERVAL_MIN_S, + INTERVAL_MAX_S +} diff --git a/lib/apm-client/http-apm-client/container-info.js b/lib/apm-client/http-apm-client/container-info.js new file mode 100644 index 0000000000..9518dde6b4 --- /dev/null +++ b/lib/apm-client/http-apm-client/container-info.js @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const fs = require('fs') + +const uuidSource = '[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12}' +const containerSource = '[0-9a-f]{64}' +const taskSource = '[0-9a-f]{32}' +const awsEcsSource = '[0-9a-f]{32}-[0-9]{10}' + +const lineReg = /^(\d+):([^:]*):(.+)$/ +const podReg = new RegExp(`pod(${uuidSource})(?:.slice)?$`) +const containerReg = new RegExp(`(${uuidSource}|${containerSource})(?:.scope)?$`) +const taskReg = new RegExp(`^/ecs/(${taskSource})/.*$`) + +let ecsMetadata +resetEcsMetadata(process.env.ECS_CONTAINER_METADATA_FILE) + +function resetEcsMetadata (file) { + ecsMetadata = ecsMetadataSync(file) +} + +function parseLine (line) { + const [id, groups, path] = (line.match(lineReg) || []).slice(1) + const data = { id, groups, path } + const parts = path.split('/') + const basename = parts.pop() + const controllers = groups.split(',') + if (controllers) data.controllers = controllers + + const containerId = (basename.match(containerReg) || [])[1] + if (containerId) data.containerId = containerId + + const podId = (parts.pop().match(podReg) || [])[1] + if (podId) data.podId = podId.replace(/_/g, '-') + + const taskId = (path.match(taskReg) || [])[1] + if (taskId) data.taskId = taskId + + // if we reach the end and there's still no conatinerId match + // and there's not an ECS metadata file, try the ECS regular + // expression in order to get a container id in fargate + if (!containerId && !ecsMetadata) { + if (basename.match(awsEcsSource)) { + data.containerId = basename + } + } + return data +} + +function parse (contents) { + const data = { + entries: [] + } + + for (let line of contents.split('\n')) { + line = line.trim() + if (line) { + const lineData = parseLine(line) + data.entries.push(lineData) + if (lineData.containerId) { + data.containerId = lineData.containerId + } + if (lineData.podId) { + data.podId = lineData.podId + } + if (lineData.taskId) { + data.taskId = lineData.taskId + if (ecsMetadata) { + data.containerId = ecsMetadata.ContainerID + } + } + } + } + + return data +} + +function containerInfo (pid = 'self') { + return new Promise((resolve) => { + fs.readFile(`/proc/${pid}/cgroup`, (err, data) => { + resolve(err ? undefined : parse(data.toString())) + }) + }) +} + +function containerInfoSync (pid = 'self') { + try { + const data = fs.readFileSync(`/proc/${pid}/cgroup`) + return parse(data.toString()) + } catch (err) {} +} + +function ecsMetadataSync (ecsMetadataFile) { + try { + return ecsMetadataFile && JSON.parse(fs.readFileSync(ecsMetadataFile)) + } catch (err) {} +} + +module.exports = containerInfo +containerInfo.sync = containerInfoSync +containerInfo.parse = parse +containerInfo.resetEcsMetadata = resetEcsMetadata // Exported for testing-only. diff --git a/lib/apm-client/http-apm-client/detect-hostname.js b/lib/apm-client/http-apm-client/detect-hostname.js new file mode 100644 index 0000000000..2c5c527905 --- /dev/null +++ b/lib/apm-client/http-apm-client/detect-hostname.js @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Detect the current hostname, preferring the FQDN if possible. +// Spec: https://github.com/elastic/apm/blob/main/specs/agents/metadata.md#hostname + +const os = require('os') +const { spawnSync } = require('child_process') + +/** + * *Synchronously* detect the current hostname, preferring the FQDN. + * This is sent to APM server as `metadata.system.detected_hostname` + * and is intended to fit the ECS `host.name` value + * (https://www.elastic.co/guide/en/ecs/current/ecs-host.html#field-host-name). + * + * @returns {String} + */ +function detectHostname () { + let hostname = null + let out + const fallback = os.hostname() + + switch (os.platform()) { + case 'win32': + // https://learn.microsoft.com/en-us/dotnet/api/system.net.dns.gethostentry + out = spawnSync( + 'powershell.exe', + ['[System.Net.Dns]::GetHostEntry($env:computerName).HostName'], + { encoding: 'utf8', shell: true, timeout: 2000 } + ) + if (!out.error) { + hostname = out.stdout.trim() + break + } + + // https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/hostname + out = spawnSync( + 'hostname.exe', + { encoding: 'utf8', shell: true, timeout: 2000 } + ) + if (!out.error) { + hostname = out.stdout.trim() + break + } + + if ('COMPUTERNAME' in process.env) { + hostname = process.env['COMPUTERNAME'].trim() // eslint-disable-line dot-notation + } + break + + default: + out = spawnSync('/bin/hostname', ['-f'], { encoding: 'utf8', shell: false, timeout: 500 }) + if (!out.error) { + hostname = out.stdout.trim() + } + // I'm going a little off of the APM spec here by *not* falling back to + // HOSTNAME or HOST envvars. Latest discussion point is here: + // https://github.com/elastic/apm/pull/517#issuecomment-940973458 + // My understanding is HOSTNAME is a *Bash*-set envvar. + break + } + + if (!hostname) { + hostname = fallback + } + hostname = hostname.trim().toLowerCase() + return hostname +} + +module.exports = { + detectHostname +} + +// ---- main + +if (require.main === module) { + console.log(detectHostname()) +} diff --git a/lib/apm-client/http-apm-client/index.js b/lib/apm-client/http-apm-client/index.js new file mode 100644 index 0000000000..add9f542ef --- /dev/null +++ b/lib/apm-client/http-apm-client/index.js @@ -0,0 +1,1756 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const assert = require('assert') +const crypto = require('crypto') +const fs = require('fs') +const http = require('http') +const https = require('https') +const util = require('util') +const { performance } = require('perf_hooks') +const { URL } = require('url') +const zlib = require('zlib') + +const HttpAgentKeepAlive = require('agentkeepalive') +const HttpsAgentKeepAlive = HttpAgentKeepAlive.HttpsAgent +const Filters = require('object-filter-sequence') +const querystring = require('querystring') +const Writable = require('readable-stream').Writable +const getContainerInfo = require('./container-info') +const eos = require('end-of-stream') +const semver = require('semver') +const streamToBuffer = require('fast-stream-to-buffer') +const StreamChopper = require('stream-chopper') + +const { detectHostname } = require('./detect-hostname') +const ndjson = require('./ndjson') +const { NoopLogger } = require('./logging') +const truncate = require('./truncate') +const { getCentralConfigIntervalS } = require('./central-config') + +module.exports = { + HttpApmClient: Client +} + +// These symbols are used as markers in the client stream to indicate special +// flush handling. +const kFlush = Symbol('flush') +const kLambdaEndFlush = Symbol('lambdaEndFlush') +function isFlushMarker (obj) { + return obj === kFlush || obj === kLambdaEndFlush +} + +const requiredOpts = [ + 'agentName', + 'agentVersion', + 'serviceName', + 'userAgent' +] + +// Get handles on uninstrumented functions for making HTTP(S) requests before +// the APM agent has a chance to wrap them. This allows the Client to make +// requests to APM server without interfering with the APM agent's tracing +// of the user application. +const httpGet = http.get +const httpRequest = http.request +const httpsGet = https.get +const httpsRequest = https.request + +const containerInfo = getContainerInfo.sync() + +const isLambdaExecutionEnvironment = !!process.env.AWS_LAMBDA_FUNCTION_NAME + +// All sockets on the agent are unreffed when they are created. This means that +// when the user process's event loop is done, and these are the only handles +// left, the process 'beforeExit' event will be emitted. By listening for this +// we can make sure to end the requests properly before process exit. This way +// we don't keep the process running until the `time` timeout happens. +// +// An exception to this is AWS Lambda which, in some cases (sync function +// handlers that use a callback), will wait for 'beforeExit' to freeze the +// Lambda instance VM *for later re-use*. This means we never want to shutdown +// the `Client` on 'beforeExit'. +const clientsToAutoEnd = [] +if (!isLambdaExecutionEnvironment) { + process.once('beforeExit', function () { + clientsToAutoEnd.forEach(function (client) { + if (!client) { + // Clients remove themselves from the array when they end. + return + } + client._gracefulExit() + }) + }) +} + +util.inherits(Client, Writable) + +Client.encoding = Object.freeze({ + METADATA: Symbol('metadata'), + TRANSACTION: Symbol('transaction'), + SPAN: Symbol('span'), + ERROR: Symbol('error'), + METRICSET: Symbol('metricset') +}) + +function Client (opts) { + if (!(this instanceof Client)) return new Client(opts) + + Writable.call(this, { objectMode: true }) + + this._corkTimer = null + this._agent = null + this._activeIntakeReq = false + this._onIntakeReqConcluded = null + this._transport = null + this._configTimer = null + this._backoffReconnectCount = 0 + this._intakeRequestGracefulExitFn = null // set in makeIntakeRequest + this._encodedMetadata = null + this._cloudMetadata = null + this._extraMetadata = null + this._metadataFilters = new Filters() + // _lambdaActive indicates if a Lambda function invocation is active. It is + // only meaningful if `isLambdaExecutionEnvironment`. + this._lambdaActive = false + // Whether to forward `.lambdaRegisterTransaction()` calls to the Lambda + // extension. This will be set false if a previous attempt failed. + this._lambdaShouldRegisterTransactions = true + + // Internal runtime stats for developer debugging/tuning. + this._numEvents = 0 // number of events given to the client + this._numEventsDropped = 0 // number of events dropped because overloaded + this._numEventsEnqueued = 0 // number of events written through to chopper + this.sent = 0 // number of events sent to APM server (not necessarily accepted) + this._slowWriteBatch = { // data on slow or the slowest _writeBatch + numOver10Ms: 0, + // Data for the slowest _writeBatch: + encodeTimeMs: 0, + fullTimeMs: 0, + numEvents: 0, + numBytes: 0 + } + + this.config(opts) + this._log = this._conf.logger || new NoopLogger() + + // `_apmServerVersion` is one of: + // - `undefined`: the version has not yet been fetched + // - `null`: the APM server version is unknown, could not be determined + // - a semver.SemVer instance + this._apmServerVersion = this._conf.apmServerVersion ? semver.SemVer(this._conf.apmServerVersion) : undefined + if (!this._apmServerVersion) { + this._fetchApmServerVersion() + } + + const numExtraMdOpts = [ + this._conf.cloudMetadataFetcher, + this._conf.expectExtraMetadata, + this._conf.extraMetadata + ].reduce((accum, curr) => curr ? accum + 1 : accum, 0) + if (numExtraMdOpts > 1) { + throw new Error('it is an error to configure a Client with more than one of "cloudMetadataFetcher", "expectExtraMetadata", or "extraMetadata"') + } else if (this._conf.cloudMetadataFetcher) { + // Start stream in corked mode, uncork when cloud metadata is fetched and + // assigned. Also, the _maybeUncork will not uncork until _encodedMetadata + // is set. + this._log.trace('corking (cloudMetadataFetcher)') + this.cork() + this._fetchAndEncodeMetadata(() => { + // _fetchAndEncodeMetadata will have set/memoized the encoded + // metadata to the _encodedMetadata property. + + // This reverses the cork() call in the constructor above. "Maybe" uncork, + // in case the client has been destroyed before this callback is called. + this._maybeUncork() + this._log.trace('uncorked (cloudMetadataFetcher)') + + // the `cloud-metadata` event allows listeners to know when the + // agent has finished fetching and encoding its metadata for the + // first time + this.emit('cloud-metadata', this._encodedMetadata) + }) + } else if (this._conf.expectExtraMetadata) { + // Uncorking will happen in the expected `.setExtraMetadata()` call. + this._log.trace('corking (expectExtraMetadata)') + this.cork() + } else if (this._conf.extraMetadata) { + this.setExtraMetadata(this._conf.extraMetadata) + } else { + this._resetEncodedMetadata() + } + + this._chopper = new StreamChopper({ + size: this._conf.size, + time: this._conf.time, + type: StreamChopper.overflow, + transform () { + return zlib.createGzip({ + level: zlib.constants.Z_BEST_SPEED + }) + } + }) + const onIntakeError = (err) => { + if (this.destroyed === false) { + this.emit('request-error', err) + } + } + this._chopper.on('stream', getChoppedStreamHandler(this, onIntakeError)) + + // We don't expect the chopper stream to end until the client is ending. + // Make sure to clean up if this does happen unexpectedly. + const fail = () => { + if (this._writableState.ending === false) this.destroy() + } + eos(this._chopper, fail) + + this._index = clientsToAutoEnd.length + clientsToAutoEnd.push(this) + + // The 'beforeExit' event is significant in Lambda invocation completion + // handling, so we log it for debugging. + if (isLambdaExecutionEnvironment && this._log.isLevelEnabled('trace')) { + process.prependListener('beforeExit', () => { + this._log.trace('process "beforeExit"') + }) + } + + if (this._conf.centralConfig) { + this._pollConfig() + } +} + +// Return current internal stats. +Client.prototype._getStats = function () { + return { + numEvents: this._numEvents, + numEventsDropped: this._numEventsDropped, + numEventsEnqueued: this._numEventsEnqueued, + numEventsSent: this.sent, + slowWriteBatch: this._slowWriteBatch, + backoffReconnectCount: this._backoffReconnectCount + } +} + +Client.prototype.config = function (opts) { + this._conf = Object.assign(this._conf || {}, opts) + + this._conf.globalLabels = normalizeGlobalLabels(this._conf.globalLabels) + + const missing = requiredOpts.filter(name => !this._conf[name]) + if (missing.length > 0) throw new Error('Missing required option(s): ' + missing.join(', ')) + + // default values + if (!this._conf.size && this._conf.size !== 0) this._conf.size = 750 * 1024 + if (!this._conf.time && this._conf.time !== 0) this._conf.time = 10000 + if (!this._conf.serverTimeout && this._conf.serverTimeout !== 0) this._conf.serverTimeout = 15000 + if (!this._conf.serverUrl) this._conf.serverUrl = 'http://127.0.0.1:8200' + if (!this._conf.environment) this._conf.environment = process.env.NODE_ENV || 'development' + if (!this._conf.truncateKeywordsAt) this._conf.truncateKeywordsAt = 1024 + if (!this._conf.truncateStringsAt) this._conf.truncateStringsAt = 1024 + if (!this._conf.truncateCustomKeysAt) this._conf.truncateCustomKeysAt = 1024 + if (!this._conf.truncateLongFieldsAt) this._conf.truncateLongFieldsAt = 10000 + // The deprecated `truncateErrorMessagesAt` will be honored if specified. + if (!this._conf.bufferWindowTime) this._conf.bufferWindowTime = 20 + if (!this._conf.bufferWindowSize) this._conf.bufferWindowSize = 50 + if (!this._conf.maxQueueSize) this._conf.maxQueueSize = 1024 + if (!this._conf.intakeResTimeout) this._conf.intakeResTimeout = 10000 + if (!this._conf.intakeResTimeoutOnEnd) this._conf.intakeResTimeoutOnEnd = 1000 + this._conf.keepAlive = this._conf.keepAlive !== false + this._conf.centralConfig = this._conf.centralConfig || false + if (!('keepAliveMsecs' in this._conf)) this._conf.keepAliveMsecs = 1000 + if (!('maxSockets' in this._conf)) this._conf.maxSockets = Infinity + if (!('maxFreeSockets' in this._conf)) this._conf.maxFreeSockets = 256 + if (!('freeSocketTimeout' in this._conf)) this._conf.freeSocketTimeout = 4000 + + // processed values + this._conf.serverUrl = new URL(this._conf.serverUrl) + + this._conf.detectedHostname = detectHostname() + + if (containerInfo) { + if (!this._conf.containerId && containerInfo.containerId) { + this._conf.containerId = containerInfo.containerId + } + if (!this._conf.kubernetesPodUID && containerInfo.podId) { + this._conf.kubernetesPodUID = containerInfo.podId + } + if (!this._conf.kubernetesPodName && containerInfo.podId) { + // https://kubernetes.io/docs/concepts/workloads/pods/#working-with-pods + // suggests a pod name should just be the shorter "DNS label", and my + // guess is k8s defaults a pod name to just the *short* hostname, not + // the FQDN. + this._conf.kubernetesPodName = this._conf.detectedHostname.split('.', 1)[0] + } + } + + let AgentKeepAlive + switch (this._conf.serverUrl.protocol) { + case 'http:': + this._transport = http + this._transportRequest = httpRequest + this._transportGet = httpGet + AgentKeepAlive = HttpAgentKeepAlive + break + case 'https:': + this._transport = https + this._transportRequest = httpsRequest + this._transportGet = httpsGet + AgentKeepAlive = HttpsAgentKeepAlive + break + default: + throw new Error('Unknown protocol ' + this._conf.serverUrl.protocol) + } + + // Only reset `this._agent` if the serverUrl has changed to avoid + // unnecessarily abandoning keep-alive connections. + if (!this._agent || (opts && 'serverUrl' in opts)) { + if (this._agent) { + this._agent.destroy() + } + this._agent = new AgentKeepAlive({ + keepAlive: this._conf.keepAlive, + keepAliveMsecs: this._conf.keepAliveMsecs, + freeSocketTimeout: this._conf.freeSocketTimeout, + timeout: this._conf.serverTimeout, + maxSockets: this._conf.maxSockets, + maxFreeSockets: this._conf.maxFreeSockets + }) + } + + // http request options + this._conf.requestIntake = getIntakeRequestOptions(this._conf, this._agent) + this._conf.requestConfig = getConfigRequestOptions(this._conf, this._agent) + this._conf.requestSignalLambdaEnd = getSignalLambdaEndRequestOptions(this._conf, this._agent) + this._conf.requestRegisterTransaction = getRegisterTransactionRequestOptions(this._conf, this._agent) + + // fixes bug where cached/memoized _encodedMetadata wouldn't be + // updated when client was reconfigured + if (this._encodedMetadata) { + this._resetEncodedMetadata() + } +} + +/** + * Set extra additional metadata to be sent to APM Server in intake requests. + * + * If the Client was configured with `expectExtraMetadata: true` then will + * uncork the client to allow intake requests to begin. + * + * If this is called multiple times, it is additive. + */ +Client.prototype.setExtraMetadata = function (extraMetadata) { + if (!this._extraMetadata) { + this._extraMetadata = extraMetadata + } else { + metadataMergeDeep(this._extraMetadata, extraMetadata) + } + this._resetEncodedMetadata() + + if (this._conf.expectExtraMetadata) { + this._log.trace('maybe uncork (expectExtraMetadata)') + this._maybeUncork() + } +} + +/** + * Add a filter function used to filter the "metadata" object sent to APM + * server. See the APM Agent `addMetadataFilter` documentation for details. + * https://www.elastic.co/guide/en/apm/agent/nodejs/current/agent-api.html#apm-add-metadata-filter + */ +Client.prototype.addMetadataFilter = function (fn) { + assert.strictEqual(typeof fn, 'function', 'fn arg must be a function') + this._metadataFilters.push(fn) + if (this._encodedMetadata) { + this._resetEncodedMetadata() + } +} + +/** + * (Re)set `_encodedMetadata` from this._conf, this._cloudMetadata, + * this._extraMetadata and possible this._metadataFilters. + */ +Client.prototype._resetEncodedMetadata = function () { + // Make a deep clone so that the originals are not modified when (a) adding + // `.cloud` and (b) filtering. This isn't perf-sensitive code, so this JSON + // cycle for cloning should suffice. + let metadata = metadataFromConf(this._conf, this) + if (this._cloudMetadata) { + metadata.cloud = deepClone(this._cloudMetadata) + } + if (this._extraMetadata) { + metadataMergeDeep(metadata, deepClone(this._extraMetadata)) + } + + // Possible filters from APM agent's `apm.addMetadataFilter()`. + if (this._metadataFilters && this._metadataFilters.length > 0) { + metadata = this._metadataFilters.process(metadata) + } + + // This is the only code path that should set `_encodedMetadata`. + this._encodedMetadata = this._encode({ metadata }, Client.encoding.METADATA) + this._log.trace({ _encodedMetadata: this._encodedMetadata }, '_resetEncodedMetadata') +} + +Client.prototype._pollConfig = function () { + const opts = this._conf.requestConfig + if (this._conf.lastConfigEtag) { + opts.headers['If-None-Match'] = this._conf.lastConfigEtag + } + + const req = this._transportGet(opts, res => { + res.on('error', err => { + // Not sure this event can ever be emitted, but just in case + res.destroy(err) + }) + + this._scheduleNextConfigPoll(getMaxAge(res)) + + if ( + res.statusCode === 304 || // No new config since last time + res.statusCode === 403 || // Central config not enabled in APM Server + res.statusCode === 404 // Old APM Server that doesn't support central config + ) { + res.resume() + return + } + + streamToBuffer(res, (err, buf) => { + if (err) { + this.emit('request-error', processConfigErrorResponse(res, buf, err)) + return + } + + if (res.statusCode === 200) { + // 200: New config available (or no config for the given service.name / service.environment) + const etag = res.headers.etag + if (etag) this._conf.lastConfigEtag = etag + + let config + try { + config = JSON.parse(buf) + } catch (parseErr) { + this.emit('request-error', processConfigErrorResponse(res, buf, parseErr)) + return + } + this.emit('config', config) + } else { + this.emit('request-error', processConfigErrorResponse(res, buf)) + } + }) + }) + + req.on('error', err => { + this._scheduleNextConfigPoll() + this.emit('request-error', err) + }) +} + +Client.prototype._scheduleNextConfigPoll = function (seconds) { + if (this._configTimer !== null) return + + const delayS = getCentralConfigIntervalS(seconds) + this._configTimer = setTimeout(() => { + this._configTimer = null + this._pollConfig() + }, delayS * 1000) + + this._configTimer.unref() +} + +// re-ref the open socket handles +Client.prototype._ref = function () { + Object.keys(this._agent.sockets).forEach(remote => { + this._agent.sockets[remote].forEach(function (socket) { + socket.ref() + }) + }) +} + +Client.prototype._write = function (obj, enc, cb) { + if (isFlushMarker(obj)) { + this._writeFlush(obj, cb) + } else { + const t = process.hrtime() + const chunk = this._encode(obj, enc) + this._numEventsEnqueued++ + this._chopper.write(chunk, cb) + this._log.trace({ + fullTimeMs: deltaMs(t), + numEvents: 1, + numBytes: chunk.length + }, '_write: encode object') + } +} + +Client.prototype._writev = function (objs, cb) { + // Limit the size of individual writes to manageable batches, primarily to + // limit large sync pauses due to `_encode`ing in `_writeBatch`. This value + // is not particularly well tuned. It was selected to get sync pauses under + // 10ms on a developer machine. + const MAX_WRITE_BATCH_SIZE = 32 + + let offset = 0 + + const processBatch = () => { + if (this.destroyed) { + cb() + return + } + + let flushIdx = -1 + const limit = Math.min(objs.length, offset + MAX_WRITE_BATCH_SIZE) + for (let i = offset; i < limit; i++) { + if (isFlushMarker(objs[i].chunk)) { + flushIdx = i + break + } + } + + if (offset === 0 && flushIdx === -1 && objs.length <= MAX_WRITE_BATCH_SIZE) { + // A shortcut if there is no flush marker and the whole `objs` fits in a batch. + this._writeBatch(objs, cb) + } else if (flushIdx === -1) { + // No flush marker in this batch. + this._writeBatch(objs.slice(offset, limit), + limit === objs.length ? cb : processBatch) + offset = limit + } else if (flushIdx > offset) { + // There are some events in the queue before a flush marker. + this._writeBatch(objs.slice(offset, flushIdx), processBatch) + offset = flushIdx + } else if (flushIdx === objs.length - 1) { + // The next item is a flush marker, and it is the *last* item in the queue. + this._writeFlush(objs[flushIdx].chunk, cb) + } else { + // The next item in the queue is a flush. + this._writeFlush(objs[flushIdx].chunk, processBatch) + offset++ + } + } + + processBatch() +} + +// Write a batch of events (excluding specially handled "flush" events) to +// the stream chopper. +Client.prototype._writeBatch = function (objs, cb) { + const t = process.hrtime() + const chunks = [] + for (var i = 0; i < objs.length; i++) { + const obj = objs[i] + chunks.push(this._encode(obj.chunk, obj.encoding)) + } + const chunk = chunks.join('') + const encodeTimeMs = deltaMs(t) + + this._numEventsEnqueued += objs.length + this._chopper.write(chunk, cb) + const fullTimeMs = deltaMs(t) + + if (fullTimeMs > this._slowWriteBatch.fullTimeMs) { + this._slowWriteBatch.encodeTimeMs = encodeTimeMs + this._slowWriteBatch.fullTimeMs = fullTimeMs + this._slowWriteBatch.numEvents = objs.length + this._slowWriteBatch.numBytes = chunk.length + } + if (fullTimeMs > 10) { + this._slowWriteBatch.numOver10Ms++ + } + this._log.trace({ + encodeTimeMs, + fullTimeMs, + numEvents: objs.length, + numBytes: chunk.length + }, '_writeBatch') +} + +Client.prototype._writeFlush = function (flushMarker, cb) { + this._log.trace({ activeIntakeReq: this._activeIntakeReq, lambdaEnd: flushMarker === kLambdaEndFlush }, '_writeFlush') + + let onFlushed = cb + if (isLambdaExecutionEnvironment && flushMarker === kLambdaEndFlush) { + onFlushed = () => { + // Signal the Elastic AWS Lambda extension that it is done passing data + // for this invocation, then call `cb()` so the wrapped Lambda handler + // can finish. + this._signalLambdaEnd(cb) + } + } + + if (this._activeIntakeReq) { + this._onIntakeReqConcluded = onFlushed + this._chopper.chop() + } else { + this._chopper.chop(onFlushed) + } +} + +Client.prototype._maybeCork = function () { + if (!this._writableState.corked) { + if (isLambdaExecutionEnvironment && !this._lambdaActive) { + this.cork() + } else if (this._conf.bufferWindowTime !== -1) { + this.cork() + if (this._corkTimer && this._corkTimer.refresh) { + // the refresh function was added in Node 10.2.0 + this._corkTimer.refresh() + } else { + this._corkTimer = setTimeout(() => { + this.uncork() + }, this._conf.bufferWindowTime) + } + } + } else if (this._writableState.length >= this._conf.bufferWindowSize) { + this._maybeUncork() + } +} + +Client.prototype._maybeUncork = function () { + if (!this._encodedMetadata) { + // The client must remain corked until cloud metadata has been + // fetched-or-skipped. + return + } else if (isLambdaExecutionEnvironment && !this._lambdaActive) { + // In a Lambda env, we must only uncork when an invocation is active, + // otherwise we could start an intake request just before the VM is frozen. + return + } + + if (this._writableState.corked) { + // Wait till next tick, so that the current write that triggered the call + // to `_maybeUncork` have time to be added to the queue. If we didn't do + // this, that last write would trigger a single call to `_write`. + process.nextTick(() => { + if (this.destroyed === false && !(isLambdaExecutionEnvironment && !this._lambdaActive)) { + this.uncork() + } + }) + + if (this._corkTimer) { + clearTimeout(this._corkTimer) + this._corkTimer = null + } + } +} + +Client.prototype._encode = function (obj, enc) { + const out = {} + switch (enc) { + case Client.encoding.SPAN: + out.span = truncate.span(obj.span, this._conf) + break + case Client.encoding.TRANSACTION: + out.transaction = truncate.transaction(obj.transaction, this._conf) + break + case Client.encoding.METADATA: + out.metadata = truncate.metadata(obj.metadata, this._conf) + break + case Client.encoding.ERROR: + out.error = truncate.error(obj.error, this._conf) + break + case Client.encoding.METRICSET: + out.metricset = truncate.metricset(obj.metricset, this._conf) + break + } + return ndjson.serialize(out) +} + +Client.prototype.lambdaStart = function () { + this._lambdaActive = true +} + +/** + * Indicate whether the APM agent -- when in a Lambda environment -- should + * bother calling `.lambdaRegisterTransaction(...)`. + * + * @returns {boolean} + */ +Client.prototype.lambdaShouldRegisterTransactions = function () { + return this._lambdaShouldRegisterTransactions +} + +/** + * Tell the local Lambda extension about the just-started transaction. This + * allows the extension to report the transaction in certain error cases + * where the APM agent isn't able to *end* the transaction and report it, + * e.g. if the function is about to timeout, or if the process crashes. + * + * The expected request is as follows, and a 200 status code is expected in + * response: + * + * POST /register/transaction + * Content-Type: application/vnd.elastic.apm.transaction+ndjson + * x-elastic-aws-request-id: ${awsRequestId} + * + * {"metadata":{...}} + * {"transaction":{...partial transaction data...}} + * + * @param {object} trans - a mostly complete APM Transaction object. It should + * have a default `outcome` value. `duration` and `result` (and possibly + * `outcome`) fields will be set by the Elastic Lambda extension if this + * transaction is used. + * @param {import('crypto').UUID} awsRequestId + * @returns {Promise || undefined} So this can, and should, be `await`ed. + * If returning a promise, it will only resolve, never reject. + */ +Client.prototype.lambdaRegisterTransaction = function (trans, awsRequestId) { + if (!isLambdaExecutionEnvironment) { + return + } + if (!this._lambdaShouldRegisterTransactions) { + return + } + assert(this._encodedMetadata, '_encodedMetadata is set') + + // We expect to be talking to the localhost Elastic Lambda extension, so we + // want a shorter timeout than `_conf.serverTimeout`. + const TIMEOUT_MS = 5000 + const startTime = performance.now() + + return new Promise((resolve, reject) => { + this._log.trace({ awsRequestId, traceId: trans.trace_id, transId: trans.id }, 'lambdaRegisterTransaction start') + var out = this._encode({ transaction: trans }, Client.encoding.TRANSACTION) + + const finish = errOrErrMsg => { + const durationMs = performance.now() - startTime + if (errOrErrMsg) { + this._log.debug({ awsRequestId, err: errOrErrMsg, durationMs }, 'lambdaRegisterTransaction unsuccessful') + this._lambdaShouldRegisterTransactions = false + } else { + this._log.trace({ awsRequestId, durationMs }, 'lambdaRegisterTransaction success') + } + resolve() // always resolve, never reject + } + + // Every `POST /register/transaction` request must set the + // `x-elastic-aws-request-id` header. Instead of creating a new options obj + // each time, we just modify in-place. + this._conf.requestRegisterTransaction.headers['x-elastic-aws-request-id'] = awsRequestId + + const req = this._transportRequest(this._conf.requestRegisterTransaction, res => { + res.on('error', err => { + // Not sure this event can ever be emitted, but just in case. + res.destroy(err) + }) + res.resume() + if (res.statusCode !== 200) { + finish(`unexpected response status code: ${res.statusCode}`) + return + } + res.on('end', function () { + finish() + }) + }) + req.setTimeout(TIMEOUT_MS) + req.on('timeout', () => { + req.destroy(new Error(`timeout (${TIMEOUT_MS}ms) registering lambda transaction`)) + }) + req.on('error', err => { + finish(err) + }) + req.write(this._encodedMetadata) + req.write(out) + req.end() + }) +} + +// With the cork/uncork handling on this stream, `this.write`ing on this +// stream when already destroyed will lead to: +// Error: Cannot call write after a stream was destroyed +// when the `_corkTimer` expires. +Client.prototype._isUnsafeToWrite = function () { + return this.destroyed +} + +Client.prototype._shouldDropEvent = function () { + this._numEvents++ + const shouldDrop = this._writableState.length >= this._conf.maxQueueSize + if (shouldDrop) { + this._numEventsDropped++ + } + return shouldDrop +} + +Client.prototype.sendSpan = function (span, cb) { + if (this._isUnsafeToWrite() || this._shouldDropEvent()) { + return + } + this._maybeCork() + return this.write({ span }, Client.encoding.SPAN, cb) +} + +Client.prototype.sendTransaction = function (transaction, cb) { + if (this._isUnsafeToWrite() || this._shouldDropEvent()) { + return + } + this._maybeCork() + return this.write({ transaction }, Client.encoding.TRANSACTION, cb) +} + +Client.prototype.sendError = function (error, cb) { + if (this._isUnsafeToWrite() || this._shouldDropEvent()) { + return + } + this._maybeCork() + return this.write({ error }, Client.encoding.ERROR, cb) +} + +Client.prototype.sendMetricSet = function (metricset, cb) { + if (this._isUnsafeToWrite() || this._shouldDropEvent()) { + return + } + this._maybeCork() + return this.write({ metricset }, Client.encoding.METRICSET, cb) +} + +/** + * If possible, start a flush of currently queued APM events to APM server. + * + * "If possible," because there are some guards on uncorking. See `_maybeUncork`. + * + * @param {Object} opts - Optional. + * - {Boolean} opts.lambdaEnd - Optional. Default false. Setting this true + * tells the client to also handle the end of a Lambda function invocation. + * @param {Function} cb - Optional. `cb()` will be called when the data has + * be sent to APM Server (or failed in the attempt). + */ +Client.prototype.flush = function (opts, cb) { + if (typeof opts === 'function') { + cb = opts + opts = {} + } else if (!opts) { + opts = {} + } + const lambdaEnd = !!opts.lambdaEnd + + // Write the special "flush" signal. We do this so that the order of writes + // and flushes are kept. If we where to just flush the client right here, the + // internal Writable buffer might still contain data that hasn't yet been + // given to the _write function. + + if (lambdaEnd && isLambdaExecutionEnvironment && this._lambdaActive) { + // To flush the current data and ensure that subsequently sent events *in + // the same tick* do not start a new intake request, we must uncork + // synchronously -- rather than the nextTick uncork done in `_maybeUncork()`. + assert(this._encodedMetadata, 'client.flush({lambdaEnd:true}) must not be called before metadata has been set') + const rv = this.write(kLambdaEndFlush, cb) + this.uncork() + this._lambdaActive = false + return rv + } else { + this._maybeUncork() + return this.write(kFlush, cb) + } +} + +// A handler that can be called on process "beforeExit" to attempt quick and +// orderly shutdown of the client. It attempts to ensure that the current +// active intake API request to APM server is completed quickly. +Client.prototype._gracefulExit = function () { + this._log.trace('_gracefulExit') + + if (this._intakeRequestGracefulExitFn) { + this._intakeRequestGracefulExitFn() + } + + // Calling _ref here, instead of relying on the _ref call in `_final`, + // is necessary because `client.end()` does *not* result in the Client's + // `_final()` being called when the process is exiting. + this._ref() + this.end() +} + +Client.prototype._final = function (cb) { + this._log.trace('_final') + if (this._configTimer) { + clearTimeout(this._configTimer) + this._configTimer = null + } + clientsToAutoEnd[this._index] = null // remove global reference to ease garbage collection + this._ref() + this._chopper.end() + cb() +} + +Client.prototype._destroy = function (err, cb) { + this._log.trace({ err }, '_destroy') + if (this._configTimer) { + clearTimeout(this._configTimer) + this._configTimer = null + } + if (this._corkTimer) { + clearTimeout(this._corkTimer) + this._corkTimer = null + } + clientsToAutoEnd[this._index] = null // remove global reference to ease garbage collection + this._chopper.destroy() + this._agent.destroy() + cb(err) +} + +// Return the appropriate backoff delay (in milliseconds) before a next possible +// request to APM server. +// Spec: https://github.com/elastic/apm/blob/main/specs/agents/transport.md#transport-errors +// +// In a Lambda environment, a backoff delay can be harmful: The backoff +// setTimeout is unref'd, to not hold the process open. A subsequent Lambda +// function invocation during that timer will result in no active handles and +// a process "beforeExit" event. That event is interpreted by the Lambda Runtime +// as "the Lambda function callback was never called", and it terminates the +// function and responds with `null`. The solution is to never backoff in a +// Lambda environment -- we expect and assume the Lambda extension is working, +// and pass responsibility for backoff to the extension. +Client.prototype._getBackoffDelay = function (isErr) { + let reconnectCount = this._backoffReconnectCount + if (isErr && !isLambdaExecutionEnvironment) { + this._backoffReconnectCount++ + } else { + this._backoffReconnectCount = 0 + reconnectCount = 0 + } + + // min(reconnectCount++, 6) ** 2 ± 10% + const delayS = Math.pow(Math.min(reconnectCount, 6), 2) + const jitterS = delayS * (0.2 * Math.random() - 0.1) + const delayMs = (delayS + jitterS) * 1000 + return delayMs +} + +function getChoppedStreamHandler (client, onerror) { + // Make a request to the apm-server intake API. + // https://www.elastic.co/guide/en/apm/server/current/events-api.html + // + // In normal operation this works as follows: + // - The StreamChopper (`this._chopper`) calls this function with a newly + // created Gzip stream, to which it writes encoded event data. + // - It `gzipStream.end()`s the stream when: + // (a) approximately `apiRequestSize` of data have been written, + // (b) `apiRequestTime` seconds have passed, or + // (c) `_chopper.chop()` is explicitly called via `client.flush()`, + // e.g. used by the Node.js APM agent after `client.sendError()`. + // - This function makes the HTTP POST to the apm-server, pipes the gzipStream + // to it, and waits for the completion of the request and the apm-server + // response. + // - Then it calls the given `next` callback to signal StreamChopper that + // another chopped stream can be created, when there is more the send. + // + // Of course, things can go wrong. Here are the known ways this pipeline can + // conclude. + // - intake response success - A successful response from the APM server. This + // is the normal operation case described above. + // - gzipStream error - An "error" event on the gzip stream. + // - intake request error - An "error" event on the intake HTTP request, e.g. + // ECONNREFUSED or ECONNRESET. + // - intakeResTimeout - A timer started *after* we are finished sending data + // to the APM server by which we require a response (including its body). By + // default this is 10s -- a very long time to allow for a slow or far + // apm-server. If we hit this, APM server is problematic anyway, so the + // delay doesn't add to the problems. + // - serverTimeout - An idle timeout value (default 30s) set on the socket. + // This is a catch-all fallback for an otherwised wedged connection. If this + // is being hit, there is some major issue in the application (possibly a + // bug in the APM agent). + // - process completion - The Client takes pains to always `.unref()` its + // handles to never keep a using process open if it is ready to exit. When + // the process is ready to exit, the following happens: + // - The "beforeExit" handler above will call `client._gracefulExit()` ... + // - ... which calls `client._ref()` to *hold the process open* to + // complete this request, and `client.end()` to end the `gzipStream` so + // this request can complete soon. + // - We then expect this request to complete quickly and the process will + // then finish exiting. A subtlety is if the APM server is not responding + // then we'll wait on the shorter `intakeResTimeoutOnEnd` (by default 1s). + return function makeIntakeRequest (gzipStream, next) { + const reqId = crypto.randomBytes(16).toString('hex') + const log = client._log.child({ reqId }) + const startTime = process.hrtime() + const timeline = [] + let bytesWritten = 0 + let intakeRes + let intakeReqSocket = null + let intakeResTimer = null + let intakeRequestGracefulExitCalled = false + const intakeResTimeout = client._conf.intakeResTimeout + const intakeResTimeoutOnEnd = client._conf.intakeResTimeoutOnEnd + + // `_activeIntakeReq` is used to coordinate the callback to `client.flush(db)`. + client._activeIntakeReq = true + + // Handle conclusion of this intake request. Each "part" is expected to call + // `completePart()` at least once -- multiple calls are okay for cases like + // the "error" and "close" events on a stream being called. When a part + // errors or all parts are completed, then we can conclude. + let concluded = false + const completedFromPart = { + gzipStream: false, + intakeReq: false, + intakeRes: false + } + let numToComplete = Object.keys(completedFromPart).length + const completePart = (part, err) => { + log.trace({ err, concluded }, 'completePart %s', part) + timeline.push([deltaMs(startTime), `completePart ${part}`, err && err.message]) + assert(part in completedFromPart, `'${part}' is in completedFromPart`) + + if (concluded) { + return + } + + // If this is the final part to complete, then we are ready to conclude. + let allPartsCompleted = false + if (!completedFromPart[part]) { + completedFromPart[part] = true + numToComplete-- + if (numToComplete === 0) { + allPartsCompleted = true + } + } + if (!err && !allPartsCompleted) { + return + } + + // Conclude. + concluded = true + if (err) { + // There was an error: clean up resources. + + // Note that in Node v8, destroying the gzip stream results in it + // emitting an "error" event as follows. No harm, however. + // Error: gzip stream error: zlib binding closed + // at Gzip._transform (zlib.js:369:15) + // ... + destroyStream(gzipStream) + intakeReq.destroy() + if (intakeResTimer) { + log.trace('cancel intakeResTimer') + clearTimeout(intakeResTimer) + intakeResTimer = null + } + } + client._intakeRequestGracefulExitFn = null + + client.sent = client._numEventsEnqueued + client._activeIntakeReq = false + const backoffDelayMs = client._getBackoffDelay(!!err) + if (err) { + log.trace({ timeline, bytesWritten, backoffDelayMs, err }, + 'conclude intake request: error') + onerror(err) + } else { + log.trace({ timeline, bytesWritten, backoffDelayMs }, + 'conclude intake request: success') + } + if (client._onIntakeReqConcluded) { + client._onIntakeReqConcluded() + client._onIntakeReqConcluded = null + } + + if (backoffDelayMs > 0) { + setTimeout(next, backoffDelayMs).unref() + } else { + setImmediate(next) + } + } + + // Provide a function on the client for it to signal this intake request + // to gracefully shutdown, i.e. finish up quickly. + client._intakeRequestGracefulExitFn = () => { + intakeRequestGracefulExitCalled = true + if (intakeReqSocket) { + log.trace('_intakeRequestGracefulExitFn: re-ref intakeReqSocket') + intakeReqSocket.ref() + } + if (intakeResTimer) { + log.trace('_intakeRequestGracefulExitFn: reset intakeResTimer to short timeout') + clearTimeout(intakeResTimer) + intakeResTimer = setTimeout(() => { + completePart('intakeRes', + new Error('intake response timeout: APM server did not respond ' + + `within ${intakeResTimeoutOnEnd / 1000}s of graceful exit signal`)) + }, intakeResTimeoutOnEnd).unref() + } + } + + // Start the request and set its timeout. + const intakeReq = client._transportRequest(client._conf.requestIntake) + if (Number.isFinite(client._conf.serverTimeout)) { + intakeReq.setTimeout(client._conf.serverTimeout) + } + // TODO: log intakeReq and intakeRes when + // https://github.com/elastic/ecs-logging-nodejs/issues/67 is implemented. + log.trace('intake request start') + + // Handle events on the intake request. + // https://nodejs.org/api/http.html#http_http_request_options_callback docs + // emitted events on the req and res objects for different scenarios. + intakeReq.on('timeout', () => { + log.trace('intakeReq "timeout"') + // `.destroy(err)` will result in an "error" event. + intakeReq.destroy(new Error(`APM Server response timeout (${client._conf.serverTimeout}ms)`)) + }) + + intakeReq.on('socket', function (socket) { + intakeReqSocket = socket + // Unref the socket for this request so that the Client does not keep + // the node process running if it otherwise would be done. (This is + // tested by the "unref-client" test in test/side-effects.js.) + // + // The HTTP keep-alive agent will unref sockets when unused, and ref them + // during a request. Given that the normal makeIntakeRequest behaviour + // is to keep a request open for up to 10s (`apiRequestTime`), we must + // manually unref the socket. + // + // The exception is when in a Lambda environment, where we *do* want to + // keep the node process running to complete this intake request. + // Otherwise a 'beforeExit' event can be sent, which the Lambda runtime + // interprets as "the Lambda handler callback was never called". + if (!isLambdaExecutionEnvironment && !intakeRequestGracefulExitCalled) { + log.trace('intakeReq "socket": unref it') + intakeReqSocket.unref() + } + }) + + intakeReq.on('response', (intakeRes_) => { + intakeRes = intakeRes_ + log.trace({ statusCode: intakeRes.statusCode, reqFinished: intakeReq.finished }, + 'intakeReq "response"') + let err + const chunks = [] + + if (!intakeReq.finished) { + // Premature response from APM server. Typically this is for errors + // like "queue is full", for which the response body will be parsed + // below. However, set an `err` as a fallback for the unexpected case + // that is with a 2xx response. + if (intakeRes.statusCode >= 200 && intakeRes.statusCode < 300) { + err = new Error(`premature apm-server response with statusCode=${intakeRes.statusCode}`) + } + // There is no point (though no harm) in sending more data to the APM + // server. In case reading the error response body takes a while, pause + // the gzip stream until it is destroyed in `completePart()`. + gzipStream.pause() + } + + // Handle events on the intake response. + intakeRes.on('error', (intakeResErr) => { + // I am not aware of a way to get an "error" event on the + // IncomingMessage (see also https://stackoverflow.com/q/53691119), but + // handling it here is preferable to an uncaughtException. + intakeResErr = wrapError(intakeResErr, 'intake response error event') + completePart('intakeRes', intakeResErr) + }) + intakeRes.on('data', (chunk) => { + chunks.push(chunk) + }) + // intakeRes.on('close', () => { log.trace('intakeRes "close"') }) + // intakeRes.on('aborted', () => { log.trace('intakeRes "aborted"') }) + intakeRes.on('end', () => { + log.trace('intakeRes "end"') + if (intakeResTimer) { + clearTimeout(intakeResTimer) + intakeResTimer = null + } + if (intakeRes.statusCode < 200 || intakeRes.statusCode > 299) { + err = processIntakeErrorResponse(intakeRes, Buffer.concat(chunks)) + } + completePart('intakeRes', err) + }) + }) + + // intakeReq.on('abort', () => { log.trace('intakeReq "abort"') }) + // intakeReq.on('close', () => { log.trace('intakeReq "close"') }) + intakeReq.on('finish', () => { + log.trace('intakeReq "finish"') + completePart('intakeReq') + }) + intakeReq.on('error', (err) => { + log.trace('intakeReq "error"') + completePart('intakeReq', err) + }) + + // Handle events on the gzip stream. + gzipStream.on('data', (chunk) => { + bytesWritten += chunk.length + }) + gzipStream.on('error', (gzipErr) => { + log.trace('gzipStream "error"') + gzipErr = wrapError(gzipErr, 'gzip stream error') + completePart('gzipStream', gzipErr) + }) + gzipStream.on('finish', () => { + // If the apm-server is not reading its input and the gzip data is large + // enough to fill buffers, then the gzip stream will emit "finish", but + // not "end". Therefore, this "finish" event is the best indicator that + // the ball is now in the apm-server's court. + // + // We now start a timer waiting on the response, provided we still expect + // one (we don't if the request has already errored out, e.g. + // ECONNREFUSED) and it hasn't already completed (e.g. if it replied + // quickly with "queue is full"). + log.trace('gzipStream "finish"') + if (!completedFromPart.intakeReq && !completedFromPart.intakeRes) { + const timeout = (client._writableState.ending || intakeRequestGracefulExitCalled + ? intakeResTimeoutOnEnd + : intakeResTimeout) + log.trace({ timeout }, 'start intakeResTimer') + intakeResTimer = setTimeout(() => { + completePart('intakeRes', + new Error('intake response timeout: APM server did not respond ' + + `within ${timeout / 1000}s of gzip stream finish`)) + }, timeout).unref() + } + }) + // Watch the gzip "end" event for its completion, because the "close" event + // that we would prefer to use, *does not get emitted* for the + // `client.sendSpan(callback) + client.flush()` test case with + // *node v12-only*. + gzipStream.on('end', () => { + log.trace('gzipStream "end"') + completePart('gzipStream') + }) + // gzipStream.on('close', () => { log.trace('gzipStream "close"') }) + + // Hook up writing data to a file (only intended for local debugging). + // Append the intake data to `payloadLogFile`, if given. This is only + // intended for local debugging because it can have a significant perf + // impact. + if (client._conf.payloadLogFile) { + const payloadLogStream = fs.createWriteStream(client._conf.payloadLogFile, { flags: 'a' }) + gzipStream.pipe(zlib.createGunzip()).pipe(payloadLogStream) + } + + // Send the metadata object (always first) and hook up the streams. + assert(client._encodedMetadata, 'client._encodedMetadata is set') + gzipStream.write(client._encodedMetadata) + gzipStream.pipe(intakeReq) + } +} + +/** + * Some behaviors in the APM depend on the APM Server version. These are + * exposed as `Client#supports...` boolean methods. + * + * These `Client#supports...` method names, if not always the implementation, + * intentionally match those from the Java agent: + * https://github.com/elastic/apm-agent-java/blob/master/apm-agent-core/src/main/java/co/elastic/apm/agent/report/ApmServerClient.java#L322-L349 + */ +Client.prototype.supportsKeepingUnsampledTransaction = function () { + // Default to assuming we are using a pre-8.0 APM Server if we haven't + // yet fetched the version. There is no harm in sending unsampled transactions + // to APM Server >=v8.0. + if (!this._apmServerVersion) { + return true + } else { + return this._apmServerVersion.major < 8 + } +} +Client.prototype.supportsActivationMethodField = function () { + // APM server 8.7.0 had a bug where continuing to send `activation_method` is + // harmful. + if (!this._apmServerVersion) { + return true // Optimistically assume APM server isn't v8.7.0. + } else { + return semver.gte(this._apmServerVersion, '8.7.1') + } +} +Client.prototype.supportsConfiguredAndDetectedHostname = function () { + if (!this._apmServerVersion) { + return true // Optimistically assume APM server is >=7.4. + } else { + return semver.gte(this._apmServerVersion, '7.4.0') + } +} + +/** + * Signal to the Elastic AWS Lambda extension that a lambda function execution + * is done. + * https://github.com/elastic/apm/blob/main/specs/agents/tracing-instrumentation-aws-lambda.md#data-flushing + * + * @param {Function} cb() is called when finished. There are no arguments. + */ +Client.prototype._signalLambdaEnd = function (cb) { + this._log.trace('_signalLambdaEnd start') + const startTime = performance.now() + const finish = errOrErrMsg => { + const durationMs = performance.now() - startTime + if (errOrErrMsg) { + this._log.error({ err: errOrErrMsg, durationMs }, '_signalLambdaEnd error') + } else { + this._log.trace({ durationMs }, '_signalLambdaEnd success') + } + cb() + } + + // We expect to be talking to the localhost Elastic Lambda extension, so we + // want a shorter timeout than `_conf.serverTimeout`. + const TIMEOUT_MS = 5000 + + const req = this._transportRequest(this._conf.requestSignalLambdaEnd, res => { + res.on('error', err => { + // Not sure this event can ever be emitted, but just in case. + res.destroy(err) + }) + res.resume() + if (res.statusCode !== 202) { + finish(`unexpected response status code: ${res.statusCode}`) + return + } + res.on('end', function () { + finish() + }) + }) + req.setTimeout(TIMEOUT_MS) + req.on('timeout', () => { + req.destroy(new Error(`timeout (${TIMEOUT_MS}ms) signaling Lambda invocation done`)) + }) + req.on('error', err => { + finish(err) + }) + req.end() +} + +/** + * Fetch the APM Server version and set `this._apmServerVersion`. + * https://www.elastic.co/guide/en/apm/server/current/server-info.html + * + * If fetching/parsing fails then the APM server version will be set to `null` + * to indicate "unknown version". + */ +Client.prototype._fetchApmServerVersion = function () { + const setVerUnknownAndNotify = (errmsg) => { + this._apmServerVersion = null // means "unknown version" + this._resetEncodedMetadata() + if (isLambdaExecutionEnvironment) { + // In a Lambda environment, where the process can be frozen, it is not + // unusual for this request to hit an error. As long as APM Server version + // fetching is not critical to tracing of Lambda invocations, then it is + // preferable to not add an error message to the users log. + this._log.debug('verfetch: ' + errmsg) + } else { + this.emit('request-error', new Error(errmsg)) + } + } + const headers = getHeaders(this._conf) + // Explicitly do *not* pass in `this._agent` -- the keep-alive http.Agent + // used for intake requests -- because the socket.ref() handling in + // `Client#_ref()` conflicts with the socket.unref() below. + const reqOpts = getBasicRequestOptions('GET', '/', headers, this._conf) + reqOpts.timeout = 30000 + + const req = this._transportGet(reqOpts, res => { + res.on('error', err => { + // Not sure this event can ever be emitted, but just in case + res.destroy(err) + }) + + if (res.statusCode !== 200) { + res.resume() + setVerUnknownAndNotify(`unexpected status from APM Server information endpoint: ${res.statusCode}`) + return + } + + const chunks = [] + res.on('data', chunk => { + chunks.push(chunk) + }) + res.on('end', () => { + if (chunks.length === 0) { + setVerUnknownAndNotify('APM Server information endpoint returned no body, often this indicates authentication ("apiKey" or "secretToken") is incorrect') + return + } + + let serverInfo + try { + serverInfo = JSON.parse(Buffer.concat(chunks)) + } catch (parseErr) { + setVerUnknownAndNotify(`could not parse APM Server information endpoint body: ${parseErr.message}`) + return + } + + if (serverInfo) { + // APM Server 7.0.0 dropped the "ok"-level in the info endpoint body. + const verStr = serverInfo.ok ? serverInfo.ok.version : serverInfo.version + try { + this._apmServerVersion = semver.SemVer(verStr) + } catch (verErr) { + setVerUnknownAndNotify(`could not parse APM Server version "${verStr}": ${verErr.message}`) + return + } + this._resetEncodedMetadata() + this._log.debug({ apmServerVersion: verStr }, 'fetched APM Server version') + } else { + setVerUnknownAndNotify(`could not determine APM Server version from information endpoint body: ${JSON.stringify(serverInfo)}`) + } + }) + }) + + req.on('socket', socket => { + // Unref our socket to ensure this request does not keep the process alive. + socket.unref() + }) + req.on('timeout', () => { + this._log.trace('_fetchApmServerVersion timeout') + req.destroy(new Error(`timeout (${reqOpts.timeout}ms) fetching APM Server version`)) + }) + req.on('error', err => { + setVerUnknownAndNotify(`error fetching APM Server version: ${err.message}`) + }) +} + +/** + * Fetches cloud metadata, if any, and encodes metadata (to `_encodedMetadata`). + * + * @param {function} cb - Called, with no arguments, when complete. + */ +Client.prototype._fetchAndEncodeMetadata = function (cb) { + assert(this._conf.cloudMetadataFetcher, '_fetchAndEncodeMetadata should not be called without a configured cloudMetadataFetcher') + this._conf.cloudMetadataFetcher.getCloudMetadata((err, cloudMetadata) => { + if (err) { + // We ignore this error (other than logging it). A common case, when + // not running on one of the big 3 clouds, is "all callbacks failed", + // which is *fine*. Because it is a common "error" we don't log the + // stack trace. + this._log.trace('getCloudMetadata err: %s', err) + } else if (cloudMetadata) { + this._cloudMetadata = cloudMetadata + } + this._resetEncodedMetadata() + cb() + }) +} + +function getIntakeRequestOptions (opts, agent) { + const headers = getHeaders(opts) + headers['Content-Type'] = 'application/x-ndjson' + headers['Content-Encoding'] = 'gzip' + + return getBasicRequestOptions('POST', '/intake/v2/events', headers, opts, agent) +} + +function getSignalLambdaEndRequestOptions (opts, agent) { + const headers = getHeaders(opts) + headers['Content-Length'] = 0 + + return getBasicRequestOptions('POST', '/intake/v2/events?flushed=true', headers, opts, agent) +} + +function getRegisterTransactionRequestOptions (opts, agent) { + const headers = getHeaders(opts) + headers['Content-Type'] = 'application/vnd.elastic.apm.transaction+ndjson' + return getBasicRequestOptions('POST', '/register/transaction', headers, opts, agent) +} + +function getConfigRequestOptions (opts, agent) { + const path = '/config/v1/agents?' + querystring.stringify({ + 'service.name': opts.serviceName, + 'service.environment': opts.environment + }) + + const headers = getHeaders(opts) + + return getBasicRequestOptions('GET', path, headers, opts, agent) +} + +function getBasicRequestOptions (method, defaultPath, headers, opts, agent) { + return { + agent, + rejectUnauthorized: opts.rejectUnauthorized !== false, + ca: opts.serverCaCert, + hostname: opts.serverUrl.hostname, + port: opts.serverUrl.port, + method, + path: opts.serverUrl.pathname === '/' ? defaultPath : opts.serverUrl.pathname + defaultPath, + headers + } +} + +function getHeaders (opts) { + const headers = {} + if (opts.secretToken) headers.Authorization = 'Bearer ' + opts.secretToken + if (opts.apiKey) headers.Authorization = 'ApiKey ' + opts.apiKey + headers.Accept = 'application/json' + headers['User-Agent'] = opts.userAgent + return Object.assign(headers, opts.headers) +} + +function metadataFromConf (opts, client) { + var payload = { + service: { + name: opts.serviceName, + environment: opts.environment, + runtime: { + name: process.release.name, + version: process.versions.node + }, + language: { + name: 'javascript' + }, + agent: { + name: opts.agentName, + version: opts.agentVersion + }, + framework: undefined, + version: undefined, + node: undefined + }, + process: { + pid: process.pid, + ppid: process.ppid, + title: process.title, + argv: process.argv + }, + system: { + architecture: process.arch, + platform: process.platform, + container: undefined, + kubernetes: undefined + }, + labels: opts.globalLabels + } + + // On `system.*hostname` fields: + // - `hostname` was deprecated in APM server v7.4, replaced by the next two. + // - Around Elastic v8.9, ECS changed `host.name` to prefer the FQDN, + // hence APM agents now prefer FQDN for `detected_hostname`. + if (client.supportsConfiguredAndDetectedHostname()) { + payload.system.detected_hostname = opts.detectedHostname + if (opts.configuredHostname) { + payload.system.configured_hostname = opts.configuredHostname + } + } else { + payload.system.hostname = opts.configuredHostname || opts.detectedHostname + } + + if (opts.agentActivationMethod && client.supportsActivationMethodField()) { + payload.service.agent.activation_method = opts.agentActivationMethod + } + + if (opts.serviceNodeName) { + payload.service.node = { + configured_name: opts.serviceNodeName + } + } + + if (opts.serviceVersion) payload.service.version = opts.serviceVersion + + if (opts.frameworkName || opts.frameworkVersion) { + payload.service.framework = { + name: opts.frameworkName, + version: opts.frameworkVersion + } + } + + if (opts.containerId) { + payload.system.container = { + id: opts.containerId + } + } + + if (opts.kubernetesNodeName || opts.kubernetesNamespace || opts.kubernetesPodName || opts.kubernetesPodUID) { + payload.system.kubernetes = { + namespace: opts.kubernetesNamespace, + node: opts.kubernetesNodeName + ? { name: opts.kubernetesNodeName } + : undefined, + pod: (opts.kubernetesPodName || opts.kubernetesPodUID) + ? { name: opts.kubernetesPodName, uid: opts.kubernetesPodUID } + : undefined + } + } + + return payload +} + +function destroyStream (stream) { + if (stream instanceof zlib.Gzip || + stream instanceof zlib.Gunzip || + stream instanceof zlib.Deflate || + stream instanceof zlib.DeflateRaw || + stream instanceof zlib.Inflate || + stream instanceof zlib.InflateRaw || + stream instanceof zlib.Unzip) { + // Zlib streams doesn't have a destroy function in Node.js 6. On top of + // that simply calling destroy on a zlib stream in Node.js 8+ will result + // in a memory leak as the handle isn't closed (an operation normally done + // by calling close). So until that is fixed, we need to manually close the + // handle after destroying the stream. + // + // PR: https://github.com/nodejs/node/pull/23734 + if (typeof stream.destroy === 'function') { + // Manually close the stream instead of calling `close()` as that would + // have emitted 'close' again when calling `destroy()` + if (stream._handle && typeof stream._handle.close === 'function') { + stream._handle.close() + stream._handle = null + } + + stream.destroy() + } else if (typeof stream.close === 'function') { + stream.close() + } + } else { + // For other streams we assume calling destroy is enough + if (typeof stream.destroy === 'function') stream.destroy() + // Or if there's no destroy (which Node.js 6 will not have on regular + // streams), emit `close` as that should trigger almost the same effect + else if (typeof stream.emit === 'function') stream.emit('close') + } +} + +function oneOf (value, list) { + return list.indexOf(value) >= 0 +} + +function normalizeGlobalLabels (labels) { + if (!labels) return + const result = {} + + for (const key of Object.keys(labels)) { + const value = labels[key] + result[key] = oneOf(typeof value, ['string', 'number', 'boolean']) + ? value + : value.toString() + } + + return result +} + +// https://httpwg.org/specs/rfc9111.html#cache-response-directive.max-age +function getMaxAge (res) { + const header = res.headers['cache-control'] + if (!header) { + return undefined + } + const match = header.match(/max-age=(\d+)/i) + if (!match) { + return undefined + } + return parseInt(match[1], 10) +} + +// Wrap the given Error object, including the given message. +// +// Dev Note: Various techniques exist to wrap `Error`s in node.js and JavaScript +// to provide a cause chain, e.g. see +// https://www.joyent.com/node-js/production/design/errors +// However, I'm not aware of a de facto "winner". Eventually there may be +// https://github.com/tc39/proposal-error-cause +// For now we will simply prefix the existing error object's `message` property. +// This is simple and preserves the root error `stack`. +function wrapError (err, msg) { + err.message = msg + ': ' + err.message + return err +} + +function processIntakeErrorResponse (res, buf) { + const err = new Error('Unexpected APM Server response') + + err.code = res.statusCode + + if (buf.length > 0) { + // https://www.elastic.co/guide/en/apm/server/current/events-api.html#events-api-errors + const body = buf.toString('utf8') + const contentType = res.headers['content-type'] + if (contentType && contentType.startsWith('application/json')) { + try { + const data = JSON.parse(body) + err.accepted = data.accepted + err.errors = data.errors + if (!err.errors) err.response = body + } catch (e) { + err.response = body + } + } else { + err.response = body + } + } + + return err +} + +// Construct or decorate an Error instance from a failing response from the +// APM server central config endpoint. +// +// @param {IncomingMessage} res +// @param {Buffer|undefined} buf - Optional. A Buffer holding the response body. +// @param {Error|undefined} err - Optional. A cause Error instance. +function processConfigErrorResponse (res, buf, err) { + // This library doesn't have a pattern for wrapping errors yet, so if + // we already have an Error instance, we will just decorate it. That preserves + // the stack of the root cause error. + const errMsg = 'Unexpected APM Server response when polling config' + if (!err) { + err = new Error(errMsg) + } else { + err.message = errMsg + ': ' + err.message + } + + err.code = res.statusCode + + if (buf && buf.length > 0) { + const body = buf.toString('utf8') + const contentType = res.headers['content-type'] + if (contentType && contentType.startsWith('application/json')) { + try { + const response = JSON.parse(body) + if (typeof response === 'string') { + err.response = response + } else if (typeof response === 'object' && response !== null && typeof response.error === 'string') { + err.response = response.error + } else { + err.response = body + } + } catch (e) { + err.response = body + } + } else { + err.response = body + } + } + + return err +} + +// Return the time difference (in milliseconds) between the given time `t` +// (a 2-tuple as returned by `process.hrtime()`) and now. +function deltaMs (t) { + const d = process.hrtime(t) + return d[0] * 1e3 + d[1] / 1e6 +} + +/** + * Performs a deep merge of `source` into `target`. Mutates `target` only but + * not its objects. Objects are merged, Arrays are not. + * + * @author inspired by [eden](https://gist.github.com/ahtcx/0cd94e62691f539160b32ecda18af3d6#gistcomment-2930530) + */ +function metadataMergeDeep (target, source) { + const isObject = (obj) => obj && typeof obj === 'object' && !Array.isArray(obj) + + if (!isObject(target) || !isObject(source)) { + return source + } + + Object.keys(source).forEach(key => { + const targetValue = target[key] + const sourceValue = source[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + target[key] = metadataMergeDeep(Object.assign({}, targetValue), sourceValue) + } else { + target[key] = sourceValue + } + }) + + return target +} + +function deepClone (obj) { + return JSON.parse(JSON.stringify(obj)) +} diff --git a/lib/apm-client/http-apm-client/logging.js b/lib/apm-client/http-apm-client/logging.js new file mode 100644 index 0000000000..27bb552cbc --- /dev/null +++ b/lib/apm-client/http-apm-client/logging.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Logging utilities for the APM http client. + +// A logger that does nothing and supports enough of the pino API +// (https://getpino.io/#/docs/api?id=logger) for use as a fallback in +// this package. +class NoopLogger { + trace () {} + debug () {} + info () {} + warn () {} + error () {} + fatal () {} + child () { return this } + isLevelEnabled (_level) { return false } +} + +module.exports = { + NoopLogger +} diff --git a/lib/apm-client/http-apm-client/ndjson.js b/lib/apm-client/http-apm-client/ndjson.js new file mode 100644 index 0000000000..4b2267db4a --- /dev/null +++ b/lib/apm-client/http-apm-client/ndjson.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const stringify = require('fast-safe-stringify') + +exports.serialize = function serialize (obj) { + const str = tryJSONStringify(obj) || stringify(obj) + return str + '\n' +} + +function tryJSONStringify (obj) { + try { + return JSON.stringify(obj) + } catch (e) {} +} diff --git a/lib/apm-client/http-apm-client/truncate.js b/lib/apm-client/http-apm-client/truncate.js new file mode 100644 index 0000000000..a3c9573d55 --- /dev/null +++ b/lib/apm-client/http-apm-client/truncate.js @@ -0,0 +1,428 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +var breadthFilter = require('breadth-filter') + +exports.metadata = truncMetadata +exports.transaction = truncTransaction +exports.span = truncSpan +exports.error = truncError +exports.metricset = truncMetricSet + +// Truncate the string `s` to a `max` maximum number of JavaScript characters. +// +// Note that JavaScript uses UCS-2 internally, so characters outside of the +// BMP are represented as surrogate pairs. These count as *two* characters. +// The result is that a string with surrogate pairs will appear to be truncated +// shorter than expected: +// unitrunc('aaaa', 4) // => 'aaaa' +// unitrunc('😂😂😂😂', 4) // => '😂😂' +// +// This will avoid truncating in the middle of a surrogate pair by truncating +// one character earlier. For example: +// unitrunc('foo😂bar', 4) // => 'foo' +function unitrunc (s, max) { + if (s.length > max) { + if (max <= 0) { + return '' + } + // If the last character is a "high" surrogate (D800–DBFF) per + // https://en.wikipedia.org/wiki/Universal_Character_Set_characters#Surrogates + // then we would truncate in the middle of a surrogate pair. Move back one + // char to have a clean(er) truncation. + const endChar = s.charCodeAt(max - 1) + if (endChar >= 0xd800 && endChar <= 0xdbff) { + return s.slice(0, max - 1) + } else { + return s.slice(0, max) + } + } + return s +} + +function truncMetadata (metadata, opts) { + return breadthFilter(metadata, { + onArray, + onObject, + onValue (value, key, path) { + if (typeof value !== 'string') { + return value + } + + let max = opts.truncateStringsAt + switch (path[0]) { + case 'service': + switch (path[1]) { + case 'name': + case 'version': + case 'environment': + max = opts.truncateKeywordsAt + break + + case 'agent': + case 'framework': + case 'language': + case 'runtime': + switch (path[2]) { + case 'name': + case 'version': + max = opts.truncateKeywordsAt + break + } + break + } + break + + case 'process': + if (path[1] === 'title') { + max = opts.truncateKeywordsAt + } + break + + case 'system': + switch (path[1]) { + case 'architecture': + case 'hostname': + case 'platform': + max = opts.truncateKeywordsAt + break + } + break + case 'cloud': + switch (path[1]) { + case 'availability_zone': + case 'provider': + case 'region': + max = opts.truncateKeywordsAt + break + case 'account': + switch (path[2]) { + case 'id': + case 'name': + max = opts.truncateKeywordsAt + break + } + break + case 'instance': + switch (path[2]) { + case 'id': + case 'name': + max = opts.truncateKeywordsAt + break + } + break + case 'machine': + switch (path[2]) { + case 'type': + max = opts.truncateKeywordsAt + break + } + break + case 'project': + switch (path[2]) { + case 'id': + case 'name': + max = opts.truncateKeywordsAt + break + } + } + break + } + + return unitrunc(value, max) + } + }) +} + +function truncTransaction (trans, opts) { + const result = breadthFilter(trans, { + onArray, + onObject: onObjectWithHeaders, + onValue (value, key, path) { + if (typeof value !== 'string') { + if (isHeader(path)) return String(value) + + return value + } + + let max = opts.truncateStringsAt + switch (path[0]) { + case 'name': + case 'type': + case 'result': + case 'id': + case 'trace_id': + case 'parent_id': + max = opts.truncateKeywordsAt + break + + case 'context': + max = contextLength(path, opts) + break + } + + return unitrunc(value, max) + } + }) + + return Object.assign({ + name: 'undefined', + type: 'undefined', + result: 'undefined' + }, result) +} + +function truncSpan (span, opts) { + let result = breadthFilter(span, { + onArray, + onObject, + onValue (value, key, path) { + if (typeof value !== 'string') { + return value + } + + let max = opts.truncateStringsAt + switch (path[0]) { + case 'name': + case 'type': + case 'id': + case 'trace_id': + case 'parent_id': + case 'transaction_id': + case 'subtype': + case 'action': + max = opts.truncateKeywordsAt + break + + case 'context': + max = contextLength(path, opts) + break + } + + return unitrunc(value, max) + } + }) + + result = truncateCustomKeys( + result, + opts.truncateCustomKeysAt, + [ + 'name', + 'type', + 'id', + 'trace_id', + 'parent_id', + 'transaction_id', + 'subtype', + 'action', + 'context' + ] + ) + + return Object.assign({ + name: 'undefined', + type: 'undefined' + }, result) +} + +function truncError (error, opts) { + return breadthFilter(error, { + onArray, + onObject: onObjectWithHeaders, + onValue (value, key, path) { + if (typeof value !== 'string') { + if (isHeader(path)) return String(value) + + return value + } + + let max = opts.truncateStringsAt + switch (path[0]) { + case 'id': + case 'trace_id': + case 'parent_id': + case 'transaction_id': + max = opts.truncateKeywordsAt + break + + case 'context': + max = contextLength(path, opts) + break + + case 'log': + switch (path[1]) { + case 'level': + case 'logger_name': + case 'param_message': + max = opts.truncateKeywordsAt + break + + case 'message': + if (opts.truncateErrorMessagesAt === undefined) { + max = opts.truncateLongFieldsAt + } else if (opts.truncateErrorMessagesAt < 0) { + return value // skip truncation + } else { + max = opts.truncateErrorMessagesAt + } + break + } + break + + case 'exception': + switch (path[1]) { + case 'type': + case 'code': + case 'module': + max = opts.truncateKeywordsAt + break + case 'message': + if (opts.truncateErrorMessagesAt === undefined) { + max = opts.truncateLongFieldsAt + } else if (opts.truncateErrorMessagesAt < 0) { + return value // skip truncation + } else { + max = opts.truncateErrorMessagesAt + } + break + } + break + } + + return unitrunc(value, max) + } + }) +} + +function truncMetricSet (metricset, opts) { + return breadthFilter(metricset, { + onArray, + onObject, + onValue (value, key, path) { + if (typeof value !== 'string') { + return value + } + + const max = path[0] === 'tags' + ? opts.truncateKeywordsAt + : opts.truncateStringsAt + + return unitrunc(value, max) + } + }) +} + +function contextLength (path, opts) { + switch (path[1]) { + case 'db': + if (path[2] === 'statement') { + return opts.truncateLongFieldsAt + } + break + + case 'message': + if (path[2] === 'body') { + return opts.truncateLongFieldsAt + } + break + + case 'request': + switch (path[2]) { + case 'method': + case 'http_version': + return opts.truncateKeywordsAt + + case 'body': + return opts.truncateLongFieldsAt + + case 'url': + switch (path[3]) { + case 'protocol': + case 'hostname': + case 'port': + case 'pathname': + case 'search': + case 'hash': + case 'raw': + case 'full': + return opts.truncateKeywordsAt + } + break + } + break + + case 'user': + switch (path[2]) { + case 'id': + case 'email': + case 'username': + return opts.truncateKeywordsAt + } + break + + case 'tags': + return opts.truncateKeywordsAt + + case 'destination': + switch (path[2]) { + case 'address': + return opts.truncateKeywordsAt + + case 'service': + switch (path[3]) { + case 'name': + case 'resource': + case 'type': + return opts.truncateKeywordsAt + } + break + } + break + } + + return opts.truncateStringsAt +} + +function isHeader (path) { + return path[0] === 'context' && (path[1] === 'request' || path[1] === 'response') && path[2] === 'headers' && path[3] +} + +function onObjectWithHeaders (value, key, path, isNew) { + if (isHeader(path)) return String(value) + return onObject(value, key, path, isNew) +} + +function onObject (value, key, path, isNew) { + return isNew ? {} : '[Circular]' +} + +function onArray (value, key, path, isNew) { + return isNew ? [] : '[Circular]' +} + +function truncateCustomKeys (value, max, keywords) { + if (typeof value !== 'object' || value === null) { + return value + } + const result = value + const keys = Object.keys(result) + const truncatedKeys = keys.map(k => { + if (keywords.includes(k)) { + return k + } + return unitrunc(k, max) + }) + + for (const [index, k] of keys.entries()) { + const value = result[k] + delete result[k] + const newKey = truncatedKeys[index] + result[newKey] = truncateCustomKeys(value, max, keywords) + } + return result +} diff --git a/package-lock.json b/package-lock.json index 953586d12e..b0b011f806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,17 +14,19 @@ "@opentelemetry/core": "^1.11.0", "@opentelemetry/sdk-metrics": "^1.12.0", "after-all-results": "^2.0.0", + "agentkeepalive": "^4.2.1", "async-cache": "^1.1.0", "async-value-promise": "^1.1.1", "basic-auth": "^2.0.1", + "breadth-filter": "^2.0.0", "cookie": "^0.5.0", "core-util-is": "^1.0.2", - "elastic-apm-http-client": "12.0.0", "end-of-stream": "^1.4.4", "error-callsites": "^2.0.4", "error-stack-parser": "^2.0.6", "escape-string-regexp": "^4.0.0", "fast-safe-stringify": "^2.0.7", + "fast-stream-to-buffer": "^1.0.0", "http-headers": "^3.0.2", "import-in-the-middle": "1.3.5", "is-native": "^1.0.1", @@ -36,12 +38,14 @@ "object-identity-map": "^1.0.2", "original-url": "^1.2.3", "pino": "^6.11.2", + "readable-stream": "^3.4.0", "relative-microtime": "^2.0.0", "require-in-the-middle": "^7.1.1", "semver": "^6.3.1", "shallow-clone-shim": "^2.0.0", "source-map": "^0.8.0-beta.0", "sql-summary": "^1.0.1", + "stream-chopper": "^3.0.1", "unicode-byte-truncate": "^1.0.0" }, "devDependencies": { @@ -8348,25 +8352,6 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", "dev": true }, - "node_modules/elastic-apm-http-client": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/elastic-apm-http-client/-/elastic-apm-http-client-12.0.0.tgz", - "integrity": "sha512-dD067YAenZ7aYBkv+Pb5Z3tV3FmvvWVmV9S2+7BZdFkKwL+gkcT+ivbdmqKAILEfGV8p4V+/KzV+HeA3w+fQ9Q==", - "dependencies": { - "agentkeepalive": "^4.2.1", - "breadth-filter": "^2.0.0", - "end-of-stream": "^1.4.4", - "fast-safe-stringify": "^2.0.7", - "fast-stream-to-buffer": "^1.0.0", - "object-filter-sequence": "^1.0.0", - "readable-stream": "^3.4.0", - "semver": "^6.3.0", - "stream-chopper": "^3.0.1" - }, - "engines": { - "node": "^8.6.0 || 10 || >=12" - } - }, "node_modules/elasticsearch": { "version": "16.7.3", "resolved": "https://registry.npmjs.org/elasticsearch/-/elasticsearch-16.7.3.tgz", @@ -23960,22 +23945,6 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", "dev": true }, - "elastic-apm-http-client": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/elastic-apm-http-client/-/elastic-apm-http-client-12.0.0.tgz", - "integrity": "sha512-dD067YAenZ7aYBkv+Pb5Z3tV3FmvvWVmV9S2+7BZdFkKwL+gkcT+ivbdmqKAILEfGV8p4V+/KzV+HeA3w+fQ9Q==", - "requires": { - "agentkeepalive": "^4.2.1", - "breadth-filter": "^2.0.0", - "end-of-stream": "^1.4.4", - "fast-safe-stringify": "^2.0.7", - "fast-stream-to-buffer": "^1.0.0", - "object-filter-sequence": "^1.0.0", - "readable-stream": "^3.4.0", - "semver": "^6.3.0", - "stream-chopper": "^3.0.1" - } - }, "elasticsearch": { "version": "16.7.3", "resolved": "https://registry.npmjs.org/elasticsearch/-/elasticsearch-16.7.3.tgz", diff --git a/package.json b/package.json index adb49415a7..760bddf880 100644 --- a/package.json +++ b/package.json @@ -90,17 +90,19 @@ "@opentelemetry/core": "^1.11.0", "@opentelemetry/sdk-metrics": "^1.12.0", "after-all-results": "^2.0.0", + "agentkeepalive": "^4.2.1", "async-cache": "^1.1.0", "async-value-promise": "^1.1.1", "basic-auth": "^2.0.1", + "breadth-filter": "^2.0.0", "cookie": "^0.5.0", "core-util-is": "^1.0.2", - "elastic-apm-http-client": "12.0.0", "end-of-stream": "^1.4.4", "error-callsites": "^2.0.4", "error-stack-parser": "^2.0.6", "escape-string-regexp": "^4.0.0", "fast-safe-stringify": "^2.0.7", + "fast-stream-to-buffer": "^1.0.0", "http-headers": "^3.0.2", "import-in-the-middle": "1.3.5", "is-native": "^1.0.1", @@ -112,12 +114,14 @@ "object-identity-map": "^1.0.2", "original-url": "^1.2.3", "pino": "^6.11.2", + "readable-stream": "^3.4.0", "relative-microtime": "^2.0.0", "require-in-the-middle": "^7.1.1", "semver": "^6.3.1", "shallow-clone-shim": "^2.0.0", "source-map": "^0.8.0-beta.0", "sql-summary": "^1.0.1", + "stream-chopper": "^3.0.1", "unicode-byte-truncate": "^1.0.0" }, "devDependencies": { diff --git a/test/apm-client/apm-client.test.js b/test/apm-client/apm-client.test.js index 224769b177..6d9c316085 100644 --- a/test/apm-client/apm-client.test.js +++ b/test/apm-client/apm-client.test.js @@ -6,13 +6,14 @@ 'use strict' -const ElasticAPMHttpClient = require('elastic-apm-http-client') - const test = require('tape') +var apmVersion = require('../../package').version + const Agent = require('../../lib/agent') +const { HttpApmClient } = require('../../lib/apm-client/http-apm-client') const { NoopApmClient } = require('../../lib/apm-client/noop-apm-client') -const { createApmClient } = require('../../lib/apm-client/apm-client') +const { createApmClient, userAgentFromConf } = require('../../lib/apm-client/apm-client') test('#createApmClient - disableSend', (t) => { const agent = new Agent() @@ -50,7 +51,27 @@ test('#createApmClient - elastic APM Transport', (t) => { cloudProvider: 'none' }, agent) - t.ok(transport instanceof ElasticAPMHttpClient, 'transport should be an ElasticAPMHttpClient instance') + t.ok(transport instanceof HttpApmClient, 'transport should be an ElasticAPMHttpClient instance') agent.destroy() t.end() }) + +// Test User-Agent generation. It would be nice to also test against gherkin +// specs from apm.git. +// https://github.com/elastic/apm/blob/main/tests/agents/gherkin-specs/user_agent.feature +test('userAgentFromConf', t => { + t.equal(userAgentFromConf({}), + `apm-agent-nodejs/${apmVersion}`) + t.equal(userAgentFromConf({ serviceName: 'foo' }), + `apm-agent-nodejs/${apmVersion} (foo)`) + t.equal(userAgentFromConf({ serviceName: 'foo', serviceVersion: '1.0.0' }), + `apm-agent-nodejs/${apmVersion} (foo 1.0.0)`) + // ISO-8859-1 characters are generally allowed. + t.equal(userAgentFromConf({ serviceName: 'party', serviceVersion: '2021-été' }), + `apm-agent-nodejs/${apmVersion} (party 2021-été)`) + // Higher code points are replaced with `_`. + t.equal(userAgentFromConf({ serviceName: 'freeze', serviceVersion: 'do you want to build a ☃ in my 🏰' }), + `apm-agent-nodejs/${apmVersion} (freeze do you want to build a _ in my __)`) + + t.end() +}) diff --git a/test/apm-client/http-apm-client.test.js b/test/apm-client/http-apm-client.test.js deleted file mode 100644 index d34ad1fb8e..0000000000 --- a/test/apm-client/http-apm-client.test.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and other contributors where applicable. - * Licensed under the BSD 2-Clause License; you may not use this file except in - * compliance with the BSD 2-Clause License. - */ - -'use strict' - -const test = require('tape') - -var apmVersion = require('../../package').version - -const { userAgentFromConf } = require('../../lib/apm-client/http-apm-client') - -// Test User-Agent generation. It would be nice to also test against gherkin -// specs from apm.git. -// https://github.com/elastic/apm/blob/main/tests/agents/gherkin-specs/user_agent.feature -test('userAgentFromConf', t => { - t.equal(userAgentFromConf({}), - `apm-agent-nodejs/${apmVersion}`) - t.equal(userAgentFromConf({ serviceName: 'foo' }), - `apm-agent-nodejs/${apmVersion} (foo)`) - t.equal(userAgentFromConf({ serviceName: 'foo', serviceVersion: '1.0.0' }), - `apm-agent-nodejs/${apmVersion} (foo 1.0.0)`) - // ISO-8859-1 characters are generally allowed. - t.equal(userAgentFromConf({ serviceName: 'party', serviceVersion: '2021-été' }), - `apm-agent-nodejs/${apmVersion} (party 2021-été)`) - // Higher code points are replaced with `_`. - t.equal(userAgentFromConf({ serviceName: 'freeze', serviceVersion: 'do you want to build a ☃ in my 🏰' }), - `apm-agent-nodejs/${apmVersion} (freeze do you want to build a _ in my __)`) - - t.end() -}) diff --git a/test/apm-client/http-apm-client/abort.test.js b/test/apm-client/http-apm-client/abort.test.js new file mode 100644 index 0000000000..faffcca6a5 --- /dev/null +++ b/test/apm-client/http-apm-client/abort.test.js @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent + +test('abort request if server responds early', function (t) { + t.plan(assertIntakeReq.asserts * 2 + assertMetadata.asserts + assertEvent.asserts + 2) + + let reqs = 0 + let client + + const datas = [ + assertMetadata, + assertEvent({ span: { foo: 2 } }) + ] + + const timer = setTimeout(function () { + throw new Error('the test got stuck') + }, 5000) + + const server = APMServer(function (req, res) { + const reqNo = ++reqs + + assertIntakeReq(t, req) + + if (reqNo === 1) { + res.writeHead(500) + res.end('bad') + + // Wait a little to ensure the current stream have ended, so the next + // span will force a new stream to be created + setTimeout(function () { + client.sendSpan({ foo: 2 }) + client.flush() + }, 50) + } else if (reqNo === 2) { + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + clearTimeout(timer) + server.close() + client.destroy() // Destroy keep-alive agent. + t.end() + }) + } else { + t.fail('should not get more than two requests') + } + }).client({ apmServerVersion: '8.0.0' }, function (_client) { + client = _client + client.sendSpan({ foo: 1 }) + client.on('request-error', function (err) { + t.equal(err.code, 500, 'should generate request-error with 500 status code') + t.equal(err.response, 'bad', 'should generate request-error with expected body') + }) + }) +}) diff --git a/test/apm-client/http-apm-client/apm-server-version.test.js b/test/apm-client/http-apm-client/apm-server-version.test.js new file mode 100644 index 0000000000..bc59ec1de2 --- /dev/null +++ b/test/apm-client/http-apm-client/apm-server-version.test.js @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Test that fetching the APM Server version works as expected. +// +// Notes: +// - Testing that the APM Server version fetch request does not hold the +// process open is tested in "side-effects.test.js". + +const test = require('tape') +const { APMServer, validOpts } = require('./lib/utils') +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') + +test('no APM server version fetch if apmServerVersion is given', function (t) { + t.plan(1) + const server = APMServer(function (req, res) { + t.fail(`there should not be an APM server request: ${req.method} ${req.url}`) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + setTimeout(() => { + t.pass('made it to timeout with no APM server request') + server.close() + client.destroy() + t.end() + }, 100) + }) +}) + +test('APM server version fetch works for "6.6.0"', function (t) { + const server = APMServer(function (req, res) { + t.equal(req.method, 'GET') + t.equal(req.url, '/', 'got APM Server information API request') + + res.writeHead(200) + const verInfo = { + build_date: '2021-09-16T02:05:39Z', + build_sha: 'a183f675ecd03fca4a897cbe85fda3511bc3ca43', + version: '6.6.0' + } + // Pre-7.0.0 versions of APM Server responded with this body: + res.end(JSON.stringify({ ok: verInfo })) + }).client({}, function (client) { + t.strictEqual(client._apmServerVersion, undefined, + 'client._apmServerVersion is undefined immediately after client creation') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() defaults to true before fetch') + t.equal(client.supportsActivationMethodField(), true, + 'client.supportsActivationMethodField() defaults to true before fetch') + + // Currently there isn't a mechanism to wait for the fetch request, so for + // now just wait a bit. + setTimeout(() => { + t.ok(client._apmServerVersion, 'client._apmServerVersion is set') + t.equal(client._apmServerVersion.toString(), '6.6.0') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() is true after fetch') + t.equal(client.supportsActivationMethodField(), false, + 'client.supportsActivationMethodField() is false after fetch') + + server.close() + client.destroy() + t.end() + }, 200) + }) +}) + +test('APM server version fetch works for "7.16.0"', function (t) { + const server = APMServer(function (req, res) { + t.equal(req.method, 'GET') + t.equal(req.url, '/', 'got APM Server information API request') + + res.writeHead(200) + const verInfo = { + build_date: '2021-09-16T02:05:39Z', + build_sha: 'a183f675ecd03fca4a897cbe85fda3511bc3ca43', + version: '7.16.0' + } + res.end(JSON.stringify(verInfo, null, 2)) + }).client({}, function (client) { + t.strictEqual(client._apmServerVersion, undefined, + 'client._apmServerVersion is undefined immediately after client creation') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() defaults to true before fetch') + + // Currently there isn't a mechanism to wait for the fetch request, so for + // now just wait a bit. + setTimeout(() => { + t.ok(client._apmServerVersion, 'client._apmServerVersion is set') + t.equal(client._apmServerVersion.toString(), '7.16.0') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() is true after fetch') + + server.close() + client.destroy() + t.end() + }, 200) + }) +}) + +test('APM server version fetch works for "8.0.0"', function (t) { + const server = APMServer(function (req, res) { + t.equal(req.method, 'GET') + t.equal(req.url, '/', 'got APM Server information API request') + + res.writeHead(200) + const verInfo = { + build_date: '2021-09-16T02:05:39Z', + build_sha: 'a183f675ecd03fca4a897cbe85fda3511bc3ca43', + version: '8.0.0' + } + res.end(JSON.stringify(verInfo, null, 2)) + }).client({}, function (client) { + t.strictEqual(client._apmServerVersion, undefined, + 'client._apmServerVersion is undefined immediately after client creation') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() defaults to true before fetch') + + // Currently there isn't a mechanism to wait for the fetch request, so for + // now just wait a bit. + setTimeout(() => { + t.ok(client._apmServerVersion, 'client._apmServerVersion is set') + t.equal(client._apmServerVersion.toString(), '8.0.0') + t.equal(client.supportsKeepingUnsampledTransaction(), false, + 'client.supportsKeepingUnsampledTransaction() is false after fetch') + + server.close() + client.destroy() + t.end() + }, 200) + }) +}) + +/** + * APM server 8.7.0 included a bug where APM agents sending `activation_method` + * was harmful. This test ensures we don't send that field to v8.7.0. + * + * See https://github.com/elastic/apm/pull/780 + */ +test('APM server version fetch works for "8.7.0"', function (t) { + const server = APMServer(function (req, res) { + res.writeHead(200) + const verInfo = { + build_date: '2023-03-30T22:17:50Z', + build_sha: 'a183f675ecd03fca4a897cbe85fda3511bc3ca43', + version: '8.7.0' + } + res.end(JSON.stringify(verInfo, null, 2)) + }).client({ + agentActivationMethod: 'env-attach' + }, function (client) { + t.strictEqual(client._apmServerVersion, undefined, + 'client._apmServerVersion is undefined immediately after client creation') + t.equal(client._conf.agentActivationMethod, 'env-attach', '_conf.agentActivationMethod') + t.equal(client.supportsActivationMethodField(), true, + 'client.supportsActivationMethodField() defaults to true before fetch') + t.ok('activation_method' in JSON.parse(client._encodedMetadata).metadata.service.agent, + 'metadata includes "activation_method" before fetch') + + // Currently there isn't a mechanism to wait for the fetch request, so for + // now just wait a bit. + setTimeout(() => { + t.ok(client._apmServerVersion, 'client._apmServerVersion is set') + t.equal(client._apmServerVersion.toString(), '8.7.0') + t.equal(client.supportsActivationMethodField(), false, + 'client.supportsActivationMethodField() is false after fetch') + t.equal(JSON.parse(client._encodedMetadata).metadata.service.agent.activation_method, undefined, + 'metadata does not include "activation_method" after fetch') + + server.close() + client.destroy() + t.end() + }, 200) + }) +}) + +/** + * Starting with APM server 8.7.1, `activation_method` should be sent. + * See https://github.com/elastic/apm/pull/780 + */ +test('APM server version fetch works for "8.7.1"', function (t) { + const server = APMServer(function (req, res) { + res.writeHead(200) + const verInfo = { + build_date: '2023-03-30T22:17:50Z', + build_sha: 'a183f675ecd03fca4a897cbe85fda3511bc3ca43', + version: '8.7.1' + } + res.end(JSON.stringify(verInfo, null, 2)) + }).client({ + agentActivationMethod: 'env-attach' + }, function (client) { + t.strictEqual(client._apmServerVersion, undefined, + 'client._apmServerVersion is undefined immediately after client creation') + t.equal(client._conf.agentActivationMethod, 'env-attach', '_conf.agentActivationMethod') + t.equal(client.supportsActivationMethodField(), true, + 'client.supportsActivationMethodField() defaults to true before fetch') + t.ok('activation_method' in JSON.parse(client._encodedMetadata).metadata.service.agent, + 'metadata includes "activation_method" before fetch') + + // Currently there isn't a mechanism to wait for the fetch request, so for + // now just wait a bit. + setTimeout(() => { + t.ok(client._apmServerVersion, 'client._apmServerVersion is set') + t.equal(client._apmServerVersion.toString(), '8.7.1') + t.equal(client.supportsActivationMethodField(), true, + 'client.supportsActivationMethodField() is true after fetch') + t.equal(JSON.parse(client._encodedMetadata).metadata.service.agent.activation_method, 'env-attach', + 'metadata includes "activation_method" after fetch') + + server.close() + client.destroy() + t.end() + }, 200) + }) +}) + +test('APM server version is null on fetch error', function (t) { + const HOPEFULLY_UNUSED_PORT_HACK = 62345 + const client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + HOPEFULLY_UNUSED_PORT_HACK + })) + client.on('request-error', err => { + t.ok(err, 'got a "request-error" event') + t.ok(/error fetching APM Server version/.test(err.message), + 'error message is about APM Server version fetching') + t.strictEqual(client._apmServerVersion, null, 'client._apmServerVersion') + t.equal(client.supportsKeepingUnsampledTransaction(), true, + 'client.supportsKeepingUnsampledTransaction() defaults to true after failed fetch') + + client.destroy() + t.end() + }) +}) diff --git a/test/apm-client/http-apm-client/backoff-delay.test.js b/test/apm-client/http-apm-client/backoff-delay.test.js new file mode 100644 index 0000000000..970a0983ad --- /dev/null +++ b/test/apm-client/http-apm-client/backoff-delay.test.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Test Client.prototype._getBackoffDelay. + +const test = require('tape') + +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') +const { validOpts } = require('./lib/utils') + +function assertDelayWithinTenPercentOf (t, value, target, context) { + const jitter = target * 0.10 + t.ok(target - jitter <= value && value <= target + jitter, + `delay ~ ${target}ms ${context}, got ${value}`) +} + +test('_getBackoffDelay', function (t) { + const client = new HttpApmClient(validOpts()) + + // From https://github.com/elastic/apm/blob/main/specs/agents/transport.md#transport-errors + // "The grace period should be calculated in seconds using the algorithm + // min(reconnectCount++, 6) ** 2 ± 10%, where reconnectCount starts at zero. + // So the delay after the first error is 0 seconds, then circa 1, 4, 9, 16, 25 + // and finally 36 seconds. We add ±10% jitter to the calculated grace period + // in case multiple agents entered the grace period simultaneously." + t.equal(client._getBackoffDelay(false), 0, 'no backoff delay with no errors') + t.equal(client._getBackoffDelay(true), 0, 'delay=0 after one error') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 1000, 'after one error') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 4000, 'after two errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 9000, 'after three errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 16000, 'after four errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 25000, 'after five errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 36000, 'after six errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 36000, 'after seven or more errors') + assertDelayWithinTenPercentOf(t, client._getBackoffDelay(true), 36000, 'after seven or more errors') + t.equal(client._getBackoffDelay(false), 0, 'delay back to 0ms after a success') + + t.end() +}) diff --git a/test/apm-client/http-apm-client/basic.test.js b/test/apm-client/http-apm-client/basic.test.js new file mode 100644 index 0000000000..35cba24450 --- /dev/null +++ b/test/apm-client/http-apm-client/basic.test.js @@ -0,0 +1,524 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent + +const dataTypes = ['span', 'transaction', 'error', 'metricset'] + +const upper = { + span: 'Span', + transaction: 'Transaction', + error: 'Error', + metricset: 'MetricSet' +} + +dataTypes.forEach(function (dataType) { + const sendFn = 'send' + upper[dataType] + + test(`client.${sendFn}() + client.flush()`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { foo: 42 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client[sendFn]({ foo: 42 }) + client.flush(() => { client.destroy() }) + }) + }) + + test(`client.${sendFn}(callback) + client.flush()`, function (t) { + t.plan(1 + assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { foo: 42 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + let nexttick = false + client[sendFn]({ foo: 42 }, function () { + t.ok(nexttick, 'should call callback') + }) + client.flush(() => { + client.end() + server.close() + t.end() + }) + nexttick = true + }) + }) + + test(`client.${sendFn}() + client.end()`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + let client + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { foo: 42 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + client.destroy() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client[sendFn]({ foo: 42 }) + client.end() + }) + }) + + test(`single client.${sendFn}`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + let client + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { foo: 42 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + client.destroy() + t.end() + }) + }).client({ time: 100, apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client[sendFn]({ foo: 42 }) + }) + }) + + test(`multiple client.${sendFn} (same request)`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts * 3) + let client + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { req: 1 } }), + assertEvent({ [dataType]: { req: 2 } }), + assertEvent({ [dataType]: { req: 3 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + client.destroy() + t.end() + }) + }).client({ time: 100, apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client[sendFn]({ req: 1 }) + client[sendFn]({ req: 2 }) + client[sendFn]({ req: 3 }) + }) + }) + + test(`multiple client.${sendFn} (multiple requests)`, function (t) { + t.plan(assertIntakeReq.asserts * 2 + assertMetadata.asserts * 2 + assertEvent.asserts * 6) + + let clientReqNum = 0 + let clientSendNum = 0 + let serverReqNum = 0 + let client + + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { req: 1, send: 1 } }), + assertEvent({ [dataType]: { req: 1, send: 2 } }), + assertEvent({ [dataType]: { req: 1, send: 3 } }), + assertMetadata, + assertEvent({ [dataType]: { req: 2, send: 4 } }), + assertEvent({ [dataType]: { req: 2, send: 5 } }), + assertEvent({ [dataType]: { req: 2, send: 6 } }) + ] + + const server = APMServer(function (req, res) { + const reqNum = ++serverReqNum + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + if (reqNum === 1) { + send() + } else { + server.close() + client.destroy() + t.end() + } + }) + }).client({ time: 100, apmServerVersion: '8.0.0' }, function (_client) { + client = _client + send() + }) + + function send () { + clientReqNum++ + for (let n = 0; n < 3; n++) { + client[sendFn]({ req: clientReqNum, send: ++clientSendNum }) + } + } + }) +}) + +test('client.flush(callback) - with active request', function (t) { + t.plan(4 + assertIntakeReq.asserts + assertMetadata.asserts) + const datas = [ + assertMetadata, + { span: { foo: 42, name: 'undefined', type: 'undefined' } } + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + const expect = datas.shift() + if (typeof expect === 'function') expect(t, obj) + else t.deepEqual(obj, expect) + }) + req.on('end', function () { + res.end() + }) + }).client({ bufferWindowTime: -1, apmServerVersion: '8.0.0' }, function (client) { + t.equal(client._activeIntakeReq, false, 'no outgoing HTTP request to begin with') + client.sendSpan({ foo: 42 }) + t.equal(client._activeIntakeReq, true, 'an outgoing HTTP request should be active') + client.flush(function () { + t.equal(client._activeIntakeReq, false, 'the outgoing HTTP request should be done') + client.end() + server.close() + t.end() + }) + }) +}) + +test('client.flush(callback) - with queued request', function (t) { + t.plan(4 + assertIntakeReq.asserts * 2 + assertMetadata.asserts * 2) + const datas = [ + assertMetadata, + { span: { req: 1, name: 'undefined', type: 'undefined' } }, + assertMetadata, + { span: { req: 2, name: 'undefined', type: 'undefined' } } + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + const expect = datas.shift() + if (typeof expect === 'function') expect(t, obj) + else t.deepEqual(obj, expect) + }) + req.on('end', function () { + res.end() + }) + }).client({ bufferWindowTime: -1, apmServerVersion: '8.0.0' }, function (client) { + client.sendSpan({ req: 1 }) + client.flush() + client.sendSpan({ req: 2 }) + t.equal(client._activeIntakeReq, true, 'an outgoing HTTP request should be active') + client.flush(function () { + t.equal(client._activeIntakeReq, false, 'the outgoing HTTP request should be done') + client.end() + server.close() + t.end() + }) + }) +}) + +test('2nd flush before 1st flush have finished', function (t) { + t.plan(4 + assertIntakeReq.asserts * 2 + assertMetadata.asserts * 2) + let requestStarts = 0 + let requestEnds = 0 + const datas = [ + assertMetadata, + { span: { req: 1, name: 'undefined', type: 'undefined' } }, + assertMetadata, + { span: { req: 2, name: 'undefined', type: 'undefined' } } + ] + const server = APMServer(function (req, res) { + requestStarts++ + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + const expect = datas.shift() + if (typeof expect === 'function') expect(t, obj) + else t.deepEqual(obj, expect) + }) + req.on('end', function () { + requestEnds++ + res.end() + }) + }).client({ bufferWindowTime: -1, apmServerVersion: '8.0.0' }, function (client) { + client.sendSpan({ req: 1 }) + client.flush() + client.sendSpan({ req: 2 }) + client.flush(() => { client.destroy() }) + setTimeout(function () { + t.equal(requestStarts, 2, 'should have received 2 requests') + t.equal(requestEnds, 2, 'should have received 2 requests completely') + t.end() + server.close() + }, 200) + }) +}) + +test('client.end(callback)', function (t) { + t.plan(1 + assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + let client + const datas = [ + assertMetadata, + assertEvent({ span: { foo: 42 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + client.destroy() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end(function () { + t.pass('should call callback') + }) + }) +}) + +test('client.sent', function (t) { + t.plan(4) + const server = APMServer(function (req, res) { + t.comment('APM server got a request') + req.resume() + req.on('end', function () { + res.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.sendError({ foo: 42 }) + client.sendSpan({ foo: 42 }) + client.sendTransaction({ foo: 42 }) + t.equal(client.sent, 0, 'sent=0 after 1st round of sending') + client.flush(function () { + t.equal(client.sent, 3, 'sent=3 after 1st flush') + client.sendError({ foo: 42 }) + client.sendSpan({ foo: 42 }) + client.sendTransaction({ foo: 42 }) + t.equal(client.sent, 3, 'sent=3 after 2nd round of sending') + client.flush(function () { + t.equal(client.sent, 6, 'sent=6 after 2nd flush') + client.end() + server.close() + t.end() + }) + }) + }) +}) + +test('should not open new request until it\'s needed after flush', function (t) { + let client + let requests = 0 + let expectRequest = false + const server = APMServer(function (req, res) { + t.equal(expectRequest, true, 'should only send new request when expected') + expectRequest = false + + req.resume() + req.on('end', function () { + res.end() + + if (++requests === 2) { + server.close() + client.destroy() + t.end() + } else { + setTimeout(sendData, 250) + } + }) + }).client({ apmServerVersion: '8.0.0' }, function (_client) { + client = _client + sendData() + }) + + function sendData () { + expectRequest = true + client.sendError({ foo: 42 }) + client.flush() + } +}) + +test('should not open new request until it\'s needed after timeout', function (t) { + let client + let requests = 0 + let expectRequest = false + const server = APMServer(function (req, res) { + t.equal(expectRequest, true, 'should only send new request when expected') + expectRequest = false + + req.resume() + req.on('end', function () { + res.end() + + if (++requests === 2) { + server.close() + client.destroy() + t.end() + } else { + setTimeout(sendData, 250) + } + }) + }).client({ time: 1, apmServerVersion: '8.0.0' }, function (_client) { + client = _client + sendData() + }) + + function sendData () { + expectRequest = true + client.sendError({ foo: 42 }) + } +}) + +test('cloud metadata: _encodedMetadata maintains cloud info after re-config', function (t) { + const conf = { + agentName: 'a', + agentVersion: 'b', + serviceName: 'c', + userAgent: 'd' + } + const client = new HttpApmClient(conf) + + // test initial values + const metadataPreUpdate = JSON.parse(client._encodedMetadata).metadata + t.equals(metadataPreUpdate.service.name, conf.serviceName, 'initial service name set') + t.equals(metadataPreUpdate.service.agent.name, conf.agentName, 'initial agent name set') + t.equals(metadataPreUpdate.service.agent.version, conf.agentVersion, 'initial agent version set') + t.ok(!metadataPreUpdate.cloud, 'no cloud metadata set initially') + + // Simulate cloud metadata having been gathered. + client._cloudMetadata = { foo: 'bar' } + client._resetEncodedMetadata() + + // Ensure cloud metadata is on `_encodedMetadata`. + const metadataPostCloud = JSON.parse(client._encodedMetadata).metadata + t.equals(metadataPostCloud.service.name, conf.serviceName, 'service name still set') + t.equals(metadataPostCloud.service.agent.name, conf.agentName, 'agent name still set') + t.equals(metadataPostCloud.service.agent.version, conf.agentVersion, 'agent version still set') + t.ok(metadataPostCloud.cloud, 'cloud metadata set after fetch') + t.equals(metadataPostCloud.cloud.foo, 'bar', 'cloud metadata set after fetch') + + // Simulate an update of some metadata from re-config. + client.config({ + frameworkName: 'superFastify', + frameworkVersion: '1.0.0' + }) + + // Ensure _encodedMetadata keeps cloud info and updates appropriately. + const metadataPostUpdate = JSON.parse(client._encodedMetadata).metadata + t.equals(metadataPostUpdate.service.name, conf.serviceName, 'service name still set') + t.equals(metadataPostUpdate.service.agent.name, conf.agentName, 'agent name still set') + t.equals(metadataPostUpdate.service.agent.version, conf.agentVersion, 'agent version still set') + t.equals(metadataPostUpdate.service.framework.name, 'superFastify', 'service.framework.name properly set') + t.equals(metadataPostUpdate.service.framework.version, '1.0.0', 'service.framework.version properly set') + t.ok(metadataPostUpdate.cloud, 'cloud metadata still set after re-config') + t.equals(metadataPostUpdate.cloud.foo, 'bar', 'cloud metadata "passed through" after re-config') + t.end() +}) + +test('cloud metadata: _fetchAndEncodeMetadata with fetcher configured ', function (t) { + // test with a fetcher configured + const conf = { + agentName: 'a', + agentVersion: 'b', + serviceName: 'c', + userAgent: 'd' + } + conf.cloudMetadataFetcher = {} + conf.cloudMetadataFetcher.getCloudMetadata = function (cb) { + process.nextTick(cb, null, { foo: 'bar' }) + } + const client = new HttpApmClient(conf) + client._fetchAndEncodeMetadata(function () { + const metadata = JSON.parse(client._encodedMetadata).metadata + t.equals(metadata.service.name, conf.serviceName, 'service name set') + t.equals(metadata.service.agent.name, conf.agentName, 'agent name set') + t.equals(metadata.service.agent.version, conf.agentVersion, 'agent version set') + t.ok(metadata.cloud, 'cloud metadata set with a fetcher configured') + t.equals(metadata.cloud.foo, 'bar', 'cloud metadata value represented') + t.end() + }) +}) + +test('cloud metadata: _fetchAndEncodeMetadata with fetcher configured but an error', function (t) { + // fetcher configured but its callback returns an error + const conf = { + agentName: 'a', + agentVersion: 'b', + serviceName: 'c', + userAgent: 'd' + } + conf.cloudMetadataFetcher = {} + conf.cloudMetadataFetcher.getCloudMetadata = function (cb) { + const error = new Error('whoops') + process.nextTick(cb, error, { foo: 'bar' }) + } + const client = new HttpApmClient(conf) + client._fetchAndEncodeMetadata(function () { + const metadata = JSON.parse(client._encodedMetadata).metadata + t.equals(metadata.service.name, conf.serviceName, 'service name set') + t.equals(metadata.service.agent.name, conf.agentName, 'agent name set') + t.equals(metadata.service.agent.version, conf.agentVersion, 'agent version set') + t.ok(!metadata.cloud, 'cloud metadata not set when there is a fetcher error') + t.end() + }) +}) diff --git a/test/apm-client/http-apm-client/central-config.test.js b/test/apm-client/http-apm-client/central-config.test.js new file mode 100644 index 0000000000..4de2e33e0f --- /dev/null +++ b/test/apm-client/http-apm-client/central-config.test.js @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') + +const { APMServer, validOpts, assertConfigReq } = require('./lib/utils') +const { + getCentralConfigIntervalS, + INTERVAL_DEFAULT_S, + INTERVAL_MIN_S, + INTERVAL_MAX_S +} = require('../../../lib/apm-client/http-apm-client/central-config') +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') + +test('getCentralConfigIntervalS', function (t) { + const testCases = [ + // [ , ] + [-4, INTERVAL_DEFAULT_S], + [-1, INTERVAL_DEFAULT_S], + [0, 300], + [1, INTERVAL_MIN_S], + [2, INTERVAL_MIN_S], + [3, INTERVAL_MIN_S], + [4, INTERVAL_MIN_S], + [5, INTERVAL_MIN_S], + [6, 6], + [7, 7], + [8, 8], + [9, 9], + [10, 10], + [86398, 86398], + [86399, 86399], + [86400, 86400], + [86401, INTERVAL_MAX_S], + [86402, INTERVAL_MAX_S], + [86403, INTERVAL_MAX_S], + [86404, INTERVAL_MAX_S], + [NaN, INTERVAL_DEFAULT_S], + [null, INTERVAL_DEFAULT_S], + [undefined, INTERVAL_DEFAULT_S], + [false, INTERVAL_DEFAULT_S], + [true, INTERVAL_DEFAULT_S], + ['a string', INTERVAL_DEFAULT_S], + [{}, INTERVAL_DEFAULT_S], + [[], INTERVAL_DEFAULT_S] + ] + + testCases.forEach(testCase => { + t.equal(getCentralConfigIntervalS(testCase[0]), testCase[1], + `getCentralConfigIntervalS(${testCase[0]}) -> ${testCase[1]}`) + }) + t.end() +}) + +test('central config disabled', function (t) { + const origPollConfig = HttpApmClient.prototype._pollConfig + HttpApmClient.prototype._pollConfig = function () { + t.fail('should not call _pollConfig') + } + + t.on('end', function () { + HttpApmClient.prototype._pollConfig = origPollConfig + }) + + HttpApmClient(validOpts()) + t.end() +}) + +test('central config enabled', function (t) { + t.plan(1) + + const origPollConfig = HttpApmClient.prototype._pollConfig + HttpApmClient.prototype._pollConfig = function () { + t.pass('should call _pollConfig') + } + + t.on('end', function () { + HttpApmClient.prototype._pollConfig = origPollConfig + }) + + HttpApmClient(validOpts({ centralConfig: true })) + t.end() +}) + +// Test central-config handling of Etag and If-None-Match headers using a mock +// apm-server that uses the `Cache-Control: max-age=1 ...` header to speed up +// the polling interval of the client. (This is foiled by `INTERVAL_MIN_S = 5`.) +test('polling', function (t) { + const expectedConf = { foo: 'bar' } + const headers = { 'Cache-Control': 'max-age=1, must-revalidate' } + let reqs = 0 + let client + + const server = APMServer(function (req, res) { + assertConfigReq(t, req) + + switch (++reqs) { + case 1: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(500, Object.assign({ 'Content-Type': 'application/json' }, headers)) + res.end('{"invalid JSON"}') + break + case 2: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(503, Object.assign({ 'Content-Type': 'application/json' }, headers)) + res.end(JSON.stringify('valid JSON')) + break + case 3: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(503, Object.assign({ 'Content-Type': 'application/json' }, headers)) + res.end(JSON.stringify({ error: 'from error property' })) + break + case 4: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(403, headers) + res.end() + break + case 5: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(404, headers) + res.end() + break + case 6: + t.ok(!('if-none-match' in req.headers), 'should not have If-None-Match header') + res.writeHead(200, Object.assign({ Etag: '"42"' }, headers)) + res.end(JSON.stringify(expectedConf)) + break + case 7: + t.equal(req.headers['if-none-match'], '"42"') + res.writeHead(304, Object.assign({ Etag: '"42"' }, headers)) + res.end() + client.destroy() + server.close() + break + default: + t.fail('too many request') + } + }).client({ centralConfig: true, apmServerVersion: '8.0.0' }, function (_client) { + client = _client + client.on('config', function (conf) { + t.equal(reqs, 6, 'should emit config after 6th request') + t.deepEqual(conf, expectedConf) + }) + client.on('request-error', function (err) { + if (reqs === 1) { + t.equal(err.code, 500) + t.equal(err.message, 'Unexpected APM Server response when polling config') + t.equal(err.response, '{"invalid JSON"}') + } else if (reqs === 2) { + t.equal(err.code, 503) + t.equal(err.message, 'Unexpected APM Server response when polling config') + t.equal(err.response, 'valid JSON') + } else if (reqs === 3) { + t.equal(err.code, 503) + t.equal(err.message, 'Unexpected APM Server response when polling config') + t.equal(err.response, 'from error property') + } else if (reqs === 7) { + // The mock APMServer above hard-destroys the connection on req 7. If + // the client's keep-alive agent has an open socket, we expect a + // "socket hang up" (ECONNRESET) error here. + t.equal(err.message, 'socket hang up') + t.end() + } else { + t.error(err, 'got an err on req ' + reqs + ', err=' + err.message) + } + }) + }) +}) diff --git a/test/apm-client/http-apm-client/config.test.js b/test/apm-client/http-apm-client/config.test.js new file mode 100644 index 0000000000..796ccb21b3 --- /dev/null +++ b/test/apm-client/http-apm-client/config.test.js @@ -0,0 +1,576 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const fs = require('fs') +const http = require('http') +const ndjson = require('ndjson') +const os = require('os') +const path = require('path') +const semver = require('semver') +const test = require('tape') +const URL = require('url').URL + +const utils = require('./lib/utils') +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') +const { detectHostname } = require('../../../lib/apm-client/http-apm-client/detect-hostname') +const getContainerInfo = require('../../../lib/apm-client/http-apm-client/container-info') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const validOpts = utils.validOpts + +const detectedHostname = detectHostname() + +test('throw if missing required options', function (t) { + t.throws(() => new HttpApmClient(), 'throws if no options are provided') + t.throws(() => new HttpApmClient({ agentName: 'foo' }), 'throws if only agentName is provided') + t.throws(() => new HttpApmClient({ agentVersion: 'foo' }), 'throws if only agentVersion is provided') + t.throws(() => new HttpApmClient({ serviceName: 'foo' }), 'throws if only serviceName is provided') + t.throws(() => new HttpApmClient({ userAgent: 'foo' }), 'throws if only userAgent is provided') + t.throws(() => new HttpApmClient({ agentName: 'foo', agentVersion: 'foo', serviceName: 'foo' }), 'throws if userAgent is missing') + t.throws(() => new HttpApmClient({ agentName: 'foo', agentVersion: 'foo', userAgent: 'foo' }), 'throws if serviceName is missing') + t.throws(() => new HttpApmClient({ agentName: 'foo', serviceName: 'foo', userAgent: 'foo' }), 'throws if agentVersion is missing') + t.throws(() => new HttpApmClient({ agentVersion: 'foo', serviceName: 'foo', userAgent: 'foo' }), 'throws if agentName is missing') + t.doesNotThrow(() => new HttpApmClient({ agentName: 'foo', agentVersion: 'foo', serviceName: 'foo', userAgent: 'foo' }), 'doesn\'t throw if required options are provided') + t.end() +}) + +test('should work without new', function (t) { + const client = HttpApmClient(validOpts()) + t.ok(client instanceof HttpApmClient) + t.end() +}) + +test('null value config options shouldn\'t throw', function (t) { + t.doesNotThrow(function () { + new HttpApmClient(validOpts({ // eslint-disable-line no-new + size: null, + time: null, + serverTimeout: null, + type: null, + serverUrl: null, + keepAlive: null, + labels: null + })) + }) + t.end() +}) + +test('no secretToken or apiKey', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + t.notOk('authorization' in req.headers, 'no Authorization header') + res.end() + server.close() + client.destroy() + t.end() + }) + server.listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + apmServerVersion: '8.0.0' + })) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('has apiKey', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + t.equal(req.headers.authorization, 'ApiKey FooBar123', 'should use apiKey in authorization header') + res.end() + server.close() + client.destroy() + t.end() + }) + server.listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + apiKey: 'FooBar123', + apmServerVersion: '8.0.0' + })) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('custom headers', function (t) { + t.plan(1) + + let client + const server = APMServer(function (req, res) { + t.equal(req.headers['x-foo'], 'bar') + res.end() + server.close() + client.destroy() + t.end() + }).listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + headers: { + 'X-Foo': 'bar' + }, + apmServerVersion: '8.0.0' + })) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('serverUrl is invalid', function (t) { + t.throws(function () { + new HttpApmClient(validOpts({ // eslint-disable-line no-new + serverUrl: 'invalid', + apmServerVersion: '8.0.0' + })) + }) + t.end() +}) + +test('serverUrl contains path', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + t.equal(req.url, '/subpath/intake/v2/events') + res.end() + server.close() + client.destroy() + t.end() + }).listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port + '/subpath', + apmServerVersion: '8.0.0' + })) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('reject unauthorized TLS by default', function (t) { + t.plan(3) + const server = APMServer({ secure: true }, function (req, res) { + t.fail('should should not get request') + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + let expectedErrorMessage = 'self signed certificate' + if (semver.gte(process.version, 'v17.0.0')) { + expectedErrorMessage = 'self-signed certificate' + } + t.equal(err.message, expectedErrorMessage) + t.equal(err.code, 'DEPTH_ZERO_SELF_SIGNED_CERT') + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('allow unauthorized TLS if asked', function (t) { + t.plan(1) + let client + const server = APMServer({ secure: true }, function (req, res) { + t.pass('should let request through') + res.end() + client.destroy() + server.close() + t.end() + }).client({ rejectUnauthorized: false, apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('allow self-signed TLS certificate by specifying the CA', function (t) { + t.plan(1) + let client + const server = APMServer({ secure: true }, function (req, res) { + t.pass('should let request through') + res.end() + client.destroy() + server.close() + t.end() + }) + server.client({ serverCaCert: server.cert, apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('metadata', function (t) { + t.plan(11) + let client + const opts = { + agentName: 'custom-agentName', + agentVersion: 'custom-agentVersion', + agentActivationMethod: 'custom-agentActivationMethod', + serviceName: 'custom-serviceName', + serviceNodeName: 'custom-serviceNodeName', + serviceVersion: 'custom-serviceVersion', + frameworkName: 'custom-frameworkName', + frameworkVersion: 'custom-frameworkVersion', + configuredHostname: 'custom-hostname', + environment: 'production', + globalLabels: { + foo: 'bar', + doesNotNest: { + nope: 'this should be [object Object]' + } + }, + apmServerVersion: '8.7.1' // avoid the APM server version fetch request + } + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + const expects = { + metadata: { + service: { + name: 'custom-serviceName', + environment: 'production', + runtime: { + name: 'node', + version: process.versions.node + }, + language: { + name: 'javascript' + }, + agent: { + name: 'custom-agentName', + version: 'custom-agentVersion', + activation_method: 'custom-agentActivationMethod' + }, + framework: { + name: 'custom-frameworkName', + version: 'custom-frameworkVersion' + }, + version: 'custom-serviceVersion', + node: { + configured_name: 'custom-serviceNodeName' + } + }, + process: { + pid: process.pid, + title: process.title, + argv: process.argv + }, + system: { + architecture: process.arch, + platform: process.platform, + detected_hostname: detectedHostname, + configured_hostname: 'custom-hostname' + }, + labels: { + foo: 'bar', + doesNotNest: '[object Object]' + } + } + } + + if (semver.gte(process.version, '8.10.0')) { + expects.metadata.process.ppid = process.ppid + } + + t.deepEqual(obj, expects) + + t.ok(semver.valid(obj.metadata.service.runtime.version)) + t.ok(obj.metadata.process.pid > 0, `pid should be > 0, was ${obj.metadata.process.pid}`) + if (semver.gte(process.version, '8.10.0')) { + t.ok(obj.metadata.process.ppid > 0, `ppid should be > 0, was ${obj.metadata.process.ppid}`) + } else { + t.equal(obj.metadata.process.ppid, undefined) + } + t.ok(Array.isArray(obj.metadata.process.argv)) + t.ok(obj.metadata.process.argv.every(arg => typeof arg === 'string')) + t.ok(obj.metadata.process.argv.every(arg => arg.length > 0)) + t.equal(typeof obj.metadata.system.architecture, 'string') + t.ok(obj.metadata.system.architecture.length > 0) + t.equal(typeof obj.metadata.system.platform, 'string') + t.ok(obj.metadata.system.platform.length > 0) + }) + req.on('end', function () { + res.end() + client.destroy() + server.close() + t.end() + }) + }).client(opts, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('metadata - default values', function (t) { + t.plan(1) + let client + const opts = { + agentName: 'custom-agentName', + agentVersion: 'custom-agentVersion', + serviceName: 'custom-serviceName', + apmServerVersion: '8.0.0' // avoid the APM server version fetch request + } + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + const expects = { + metadata: { + service: { + name: 'custom-serviceName', + environment: 'development', + runtime: { + name: 'node', + version: process.versions.node + }, + language: { + name: 'javascript' + }, + agent: { + name: 'custom-agentName', + version: 'custom-agentVersion' + } + }, + process: { + pid: process.pid, + title: process.title, + argv: process.argv + }, + system: { + architecture: process.arch, + platform: process.platform, + detected_hostname: detectedHostname + } + } + } + + if (semver.gte(process.version, '8.10.0')) { + expects.metadata.process.ppid = process.ppid + } + + t.deepEqual(obj, expects) + }) + + req.on('end', function () { + res.end() + client.destroy() + server.close() + t.end() + }) + }).client(opts, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('metadata - container info', function (t) { + // Clear Client and APMServer from require cache + delete require.cache[require.resolve('../../../lib/apm-client/http-apm-client')] + delete require.cache[require.resolve('./lib/utils')] + + const sync = getContainerInfo.sync + getContainerInfo.sync = function sync () { + return { + containerId: 'container-id', + podId: 'pod-id' + } + } + t.on('end', () => { + getContainerInfo.sync = sync + }) + + const APMServer = require('./lib/utils').APMServer + + let client + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.ok(obj.metadata) + t.ok(obj.metadata.system) + t.deepEqual(obj.metadata.system.container, { + id: 'container-id' + }) + t.deepEqual(obj.metadata.system.kubernetes, { + pod: { + name: detectedHostname.split('.')[0], + uid: 'pod-id' + } + }) + }) + req.on('end', function () { + res.end() + client.destroy() + server.close() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('agentName', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.equal(obj.metadata.service.name, 'custom') + }) + req.on('end', function () { + res.end() + client.destroy() + server.close() + t.end() + }) + }).client({ serviceName: 'custom', apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('payloadLogFile', function (t) { + t.plan(6) + + const receivedObjects = [] + const filename = path.join(os.tmpdir(), Date.now() + '.ndjson') + let requests = 0 + + let client + const server = APMServer(function (req, res) { + const request = ++requests + + req = processIntakeReq(req) + + req.on('data', function (obj) { + receivedObjects.push(obj) + }) + + req.on('end', function () { + res.end() + + if (request === 2) { + client.destroy() + server.close() + t.equal(receivedObjects.length, 5, 'should have received 5 objects') + + const file = fs.createReadStream(filename).pipe(ndjson.parse()) + + file.on('data', function (obj) { + const expected = receivedObjects.shift() + const n = 5 - receivedObjects.length + t.deepEqual(obj, expected, `expected line ${n} in the log file to match item no ${n} received by the server`) + }) + + file.on('end', function () { + t.end() + }) + } + }) + }).client({ payloadLogFile: filename, apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.sendTransaction({ req: 1 }) + client.sendSpan({ req: 2 }) + client.flush() // force the client to make a 2nd request so that we test reusing the file across requests + client.sendError({ req: 3 }) + client.end() + }) +}) + +test('update conf', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.equal(obj.metadata.service.name, 'bar') + }) + req.on('end', function () { + res.end() + client.destroy() + server.close() + t.end() + }) + }).client({ serviceName: 'foo', apmServerVersion: '8.0.0' }, function (client_) { + client = client_ + client.config({ serviceName: 'bar' }) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +// There was a case (https://github.com/elastic/apm-agent-nodejs/issues/1749) +// where a non-200 response from apm-server would crash the agent. +test('503 response from apm-server for central config should not crash', function (t) { + let client + + // If this test goes wrong, it can hang. Clean up after a 30s timeout. + const abortTimeout = setTimeout(function () { + t.fail('test hung, aborting after a timeout') + cleanUpAndEnd() + }, 30000) + + function cleanUpAndEnd () { + if (abortTimeout) { + clearTimeout(abortTimeout) + } + client.destroy() + mockApmServer.close(function () { + t.end() + }) + } + + // 1. Start a mock apm-server that returns 503 for central config queries. + const mockApmServer = http.createServer(function (req, res) { + const parsedUrl = new URL(req.url, 'http://localhost:0') + let resBody = '{}' + if (parsedUrl.pathname === '/config/v1/agents') { + resBody = '{"ok":false,"message":"The requested resource is currently unavailable."}\n' + res.writeHead(503) + } + res.end(resBody) + }) + + mockApmServer.listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + mockApmServer.address().port, + // Turn centralConfig *off*. We'll manually trigger a poll for central + // config via internal methods, so that we don't need to muck with + // internal `setTimeout` intervals. + centralConfig: false, + apmServerVersion: '8.0.0' + })) + + // 2. Ensure the client conditions for the crash. + // One of the crash conditions at the time was a second `client.config` + // to ensure the request options were using the keep-alive agent. + client.config() + t.ok(client._conf.requestConfig.agent, + 'agent for central config requests is defined') + + client.on('config', function (config) { + t.fail('do not expect to get a successful central config response') + }) + client.on('request-error', function (err) { + t.ok(err, 'got request-error on _pollConfig') + t.ok(err.message.indexOf('Unexpected APM Server response when polling config') !== -1, + 'request-error from _pollConfig includes expected error message') + cleanUpAndEnd() + }) + + // 3. Make a poll for central config. + client._pollConfig() + }) +}) diff --git a/test/apm-client/http-apm-client/container-info.test.js b/test/apm-client/http-apm-client/container-info.test.js new file mode 100644 index 0000000000..c8772a6afa --- /dev/null +++ b/test/apm-client/http-apm-client/container-info.test.js @@ -0,0 +1,429 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const tape = require('tape') + +const data = fs.readFileSync(path.join(__dirname, 'fixtures', 'cgroup')) +const expected = require('./fixtures/cgroup_result') + +process.env.ECS_CONTAINER_METADATA_FILE = path.join(__dirname, 'fixtures', 'ecs-container-metadata.json') + +const containerInfo = require('../../../lib/apm-client/http-apm-client/container-info') +const { parse, sync } = containerInfo + +tape.test('basics', t => { + t.deepEqual(parse(` + 12:devices:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 11:hugetlb:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 10:memory:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 9:freezer:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 8:perf_event:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 7:blkio:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 6:pids:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 5:rdma:/ + 4:cpuset:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 3:net_cls,net_prio:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 2:cpu,cpuacct:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 1:name=systemd:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76 + 0::/system.slice/docker.service + `), { + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + entries: [ + { + id: '12', + groups: 'devices', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['devices'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '11', + groups: 'hugetlb', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['hugetlb'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '10', + groups: 'memory', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['memory'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '9', + groups: 'freezer', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['freezer'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '8', + groups: 'perf_event', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['perf_event'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '7', + groups: 'blkio', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['blkio'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '6', + groups: 'pids', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['pids'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '5', + groups: 'rdma', + path: '/', + controllers: ['rdma'] + }, + { + id: '4', + groups: 'cpuset', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['cpuset'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '3', + groups: 'net_cls,net_prio', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['net_cls', 'net_prio'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '2', + groups: 'cpu,cpuacct', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['cpu', 'cpuacct'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '1', + groups: 'name=systemd', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76', + controllers: ['name=systemd'], + containerId: '051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76' + }, + { + id: '0', + groups: '', + path: '/system.slice/docker.service', + controllers: [''] + } + ] + }) + + t.deepEqual(parse(` + 3:cpuacct:/ecs/eb9d3d0c-8936-42d7-80d8-f82b2f1a629e/7e9139716d9e5d762d22f9f877b87d1be8b1449ac912c025a984750c5dbff157 + `), { + containerId: '7e9139716d9e5d762d22f9f877b87d1be8b1449ac912c025a984750c5dbff157', + entries: [ + { + id: '3', + groups: 'cpuacct', + path: '/ecs/eb9d3d0c-8936-42d7-80d8-f82b2f1a629e/7e9139716d9e5d762d22f9f877b87d1be8b1449ac912c025a984750c5dbff157', + controllers: ['cpuacct'], + containerId: '7e9139716d9e5d762d22f9f877b87d1be8b1449ac912c025a984750c5dbff157' + } + ] + }) + + t.deepEqual(parse(` + 1:name=systemd:/system.slice/docker-cde7c2bab394630a42d73dc610b9c57415dced996106665d427f6d0566594411.scope + `), { + containerId: 'cde7c2bab394630a42d73dc610b9c57415dced996106665d427f6d0566594411', + entries: [ + { + id: '1', + groups: 'name=systemd', + path: '/system.slice/docker-cde7c2bab394630a42d73dc610b9c57415dced996106665d427f6d0566594411.scope', + controllers: ['name=systemd'], + containerId: 'cde7c2bab394630a42d73dc610b9c57415dced996106665d427f6d0566594411' + } + ] + }) + + t.deepEqual(parse(` + 1:name=systemd:/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76/not_hex + `), { + entries: [ + { + id: '1', + groups: 'name=systemd', + path: '/docker/051e2ee0bce99116029a13df4a9e943137f19f957f38ac02d6bad96f9b700f76/not_hex', + controllers: ['name=systemd'] + } + ] + }) + + t.deepEqual(parse(` + 1:name=systemd:/kubepods/besteffort/pode9b90526-f47d-11e8-b2a5-080027b9f4fb/15aa6e53-b09a-40c7-8558-c6c31e36c88a + `), { + containerId: '15aa6e53-b09a-40c7-8558-c6c31e36c88a', + podId: 'e9b90526-f47d-11e8-b2a5-080027b9f4fb', + entries: [ + { + id: '1', + groups: 'name=systemd', + path: '/kubepods/besteffort/pode9b90526-f47d-11e8-b2a5-080027b9f4fb/15aa6e53-b09a-40c7-8558-c6c31e36c88a', + controllers: ['name=systemd'], + containerId: '15aa6e53-b09a-40c7-8558-c6c31e36c88a', + podId: 'e9b90526-f47d-11e8-b2a5-080027b9f4fb' + } + ] + }) + + t.deepEqual(parse(` + 1:name=systemd:/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod90d81341_92de_11e7_8cf2_507b9d4141fa.slice/crio-2227daf62df6694645fee5df53c1f91271546a9560e8600a525690ae252b7f63.scope + `), { + containerId: '2227daf62df6694645fee5df53c1f91271546a9560e8600a525690ae252b7f63', + podId: '90d81341-92de-11e7-8cf2-507b9d4141fa', + entries: [ + { + id: '1', + groups: 'name=systemd', + path: '/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod90d81341_92de_11e7_8cf2_507b9d4141fa.slice/crio-2227daf62df6694645fee5df53c1f91271546a9560e8600a525690ae252b7f63.scope', + controllers: ['name=systemd'], + containerId: '2227daf62df6694645fee5df53c1f91271546a9560e8600a525690ae252b7f63', + podId: '90d81341-92de-11e7-8cf2-507b9d4141fa' + } + ] + }) + + t.deepEqual(parse(` + 1:name=systemd:/ecs/46686c7c701cdfdf2549f88f7b9575e9/46686c7c701cdfdf2549f88f7b9575e9-2574839563 + `), { + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + taskId: '46686c7c701cdfdf2549f88f7b9575e9', + entries: [ + { + id: '1', + groups: 'name=systemd', + path: '/ecs/46686c7c701cdfdf2549f88f7b9575e9/46686c7c701cdfdf2549f88f7b9575e9-2574839563', + controllers: ['name=systemd'], + taskId: '46686c7c701cdfdf2549f88f7b9575e9' + } + ] + }) + + t.deepEqual(parse(` + 12:devices:/user.slice + 11:hugetlb:/ + 10:memory:/user.slice + 9:freezer:/ + 8:perf_event:/ + 7:blkio:/user.slice + 6:pids:/user.slice/user-1000.slice/session-2.scope + 5:rdma:/ + 4:cpuset:/ + 3:net_cls,net_prio:/ + 2:cpu,cpuacct:/user.slice + 1:name=systemd:/user.slice/user-1000.slice/session-2.scope + 0::/user.slice/user-1000.slice/session-2.scope + `), { + entries: [ + { + id: '12', + groups: 'devices', + path: '/user.slice', + controllers: ['devices'] + }, + { + id: '11', + groups: 'hugetlb', + path: '/', + controllers: ['hugetlb'] + }, + { + id: '10', + groups: 'memory', + path: '/user.slice', + controllers: ['memory'] + }, + { + id: '9', + groups: 'freezer', + path: '/', + controllers: ['freezer'] + }, + { + id: '8', + groups: 'perf_event', + path: '/', + controllers: ['perf_event'] + }, + { + id: '7', + groups: 'blkio', + path: '/user.slice', + controllers: ['blkio'] + }, + { + id: '6', + groups: 'pids', + path: '/user.slice/user-1000.slice/session-2.scope', + controllers: ['pids'] + }, + { + id: '5', + groups: 'rdma', + path: '/', + controllers: ['rdma'] + }, + { + id: '4', + groups: 'cpuset', + path: '/', + controllers: ['cpuset'] + }, + { + id: '3', + groups: 'net_cls,net_prio', + path: '/', + controllers: ['net_cls', 'net_prio'] + }, + { + id: '2', + groups: 'cpu,cpuacct', + path: '/user.slice', + controllers: ['cpu', 'cpuacct'] + }, + { + id: '1', + groups: 'name=systemd', + path: '/user.slice/user-1000.slice/session-2.scope', + controllers: ['name=systemd'] + }, + { + id: '0', + groups: '', + path: '/user.slice/user-1000.slice/session-2.scope', + controllers: [''] + } + ] + }) + + t.end() +}) + +tape.test('containerInfo()', t => { + t.plan(2) + + const readFile = fs.readFile + fs.readFile = function (path, cb) { + fs.readFile = readFile + t.equal(path, '/proc/self/cgroup') + cb(null, data) + } + + containerInfo().then(result => { + t.deepEqual(result, expected) + t.end() + }) +}) + +tape.test('containerInfo(123)', t => { + t.plan(2) + + const readFile = fs.readFile + fs.readFile = function (path, cb) { + fs.readFile = readFile + t.equal(path, '/proc/123/cgroup') + cb(null, data) + } + + containerInfo(123).then(result => { + t.deepEqual(result, expected) + t.end() + }) +}) + +tape.test('containerInfo() - error', t => { + t.plan(1) + + const readFile = fs.readFile + fs.readFile = function (path, cb) { + fs.readFile = readFile + cb(new Error('boom')) + } + + containerInfo(123).then(result => { + t.deepEqual(result, undefined) + t.end() + }) +}) + +tape.test('containerInfoSync()', t => { + t.plan(2) + + const readFileSync = fs.readFileSync + fs.readFileSync = function (path) { + fs.readFileSync = readFileSync + t.equal(path, '/proc/self/cgroup') + return data + } + + t.deepEqual(sync(), expected) + t.end() +}) + +tape.test('containerInfoSync(123)', t => { + t.plan(2) + + const readFileSync = fs.readFileSync + fs.readFileSync = function (path) { + fs.readFileSync = readFileSync + t.equal(path, '/proc/123/cgroup') + return data + } + + t.deepEqual(sync(123), expected) + t.end() +}) + +tape.test('containerInfoSync() - error', t => { + t.plan(1) + + const readFileSync = fs.readFileSync + fs.readFileSync = function () { + fs.readFileSync = readFileSync + throw new Error('boom') + } + + t.deepEqual(sync(), undefined) + t.end() +}) + +tape.test('ecs without metadata file present', t => { + const originalEcsFile = process.env.ECS_CONTAINER_METADATA_FILE + containerInfo.resetEcsMetadata(null) + + t.equals( + containerInfo.parse('15:name=systemd:/ecs/03752a671e744971a862edcee6195646/03752a671e744971a862edcee6195646-4015103728').containerId, + '03752a671e744971a862edcee6195646-4015103728', + 'fargate id parsed' + ) + + containerInfo.resetEcsMetadata(originalEcsFile) + t.equals( + containerInfo.parse('15:name=systemd:/ecs/03752a671e744971a862edcee6195646/03752a671e744971a862edcee6195646-4015103728').containerId, + '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + 'container id from metadata file' + ) + t.end() +}) diff --git a/test/apm-client/http-apm-client/edge-cases.test.js b/test/apm-client/http-apm-client/edge-cases.test.js new file mode 100644 index 0000000000..be79672f1a --- /dev/null +++ b/test/apm-client/http-apm-client/edge-cases.test.js @@ -0,0 +1,625 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const { exec } = require('child_process') +const http = require('http') +const path = require('path') +const test = require('tape') +const utils = require('./lib/utils') + +const { HttpApmClient } = require('../../../lib/apm-client/http-apm-client') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent +const validOpts = utils.validOpts + +test('Event: close - if chopper ends', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + client._chopper.end() + setTimeout(function () { + // wait a little to allow close to be emitted + t.end() + server.close() + }, 10) + }).listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + apmServerVersion: '8.0.0' + })) + + client.on('finish', function () { + t.fail('should not emit finish event') + }) + client.on('close', function () { + t.pass('should emit close event') + }) + + client.sendSpan({ req: 1 }) + }) +}) + +test('Event: close - if chopper is destroyed', function (t) { + t.plan(1) + let client + const server = APMServer(function (req, res) { + client._chopper.destroy() + setTimeout(function () { + // wait a little to allow close to be emitted + t.end() + server.close() + }, 10) + }).listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + apmServerVersion: '8.0.0' + })) + + client.on('finish', function () { + t.fail('should not emit finish event') + }) + client.on('close', function () { + t.pass('should emit close event') + }) + + client.sendSpan({ req: 1 }) + }) +}) + +test('write after end', function (t) { + t.plan(2) + const server = APMServer(function (req, res) { + t.fail('should never get any request') + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'write after end') + server.close() + t.end() + }) + client.end() + client.sendSpan({ foo: 42 }) + }) +}) + +test('request with error - no body', function (t) { + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.end() + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, undefined) + t.equal(err.errors, undefined) + t.equal(err.response, undefined) + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('request with error - non json body', function (t) { + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.end('boom!') + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, undefined) + t.equal(err.errors, undefined) + t.equal(err.response, 'boom!') + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('request with error - invalid json body', function (t) { + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.setHeader('Content-Type', 'application/json') + res.end('boom!') + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, undefined) + t.equal(err.errors, undefined) + t.equal(err.response, 'boom!') + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('request with error - json body without accepted or errors properties', function (t) { + const body = JSON.stringify({ foo: 'bar' }) + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.setHeader('Content-Type', 'application/json') + res.end(body) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, undefined) + t.equal(err.errors, undefined) + t.equal(err.response, body) + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('request with error - json body with accepted and errors properties', function (t) { + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ accepted: 42, errors: [{ message: 'bar' }] })) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, 42) + t.deepEqual(err.errors, [{ message: 'bar' }]) + t.equal(err.response, undefined) + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('request with error - json body where Content-Type contains charset', function (t) { + const server = APMServer(function (req, res) { + res.statusCode = 418 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ accepted: 42, errors: [{ message: 'bar' }] })) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.on('request-error', function (err) { + t.ok(err instanceof Error) + t.equal(err.message, 'Unexpected APM Server response') + t.equal(err.code, 418) + t.equal(err.accepted, 42) + t.deepEqual(err.errors, [{ message: 'bar' }]) + t.equal(err.response, undefined) + client.destroy() + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.flush() + }) +}) + +test('socket hang up', function (t) { + const server = APMServer(function (req, res) { + req.socket.destroy() + }).client({ apmServerVersion: '8.0.0' }, function (client) { + let closed = false + client.on('request-error', function (err) { + t.equal(err.message, 'socket hang up') + t.equal(err.code, 'ECONNRESET') + // wait a little in case 'close' is emitted async + setTimeout(function () { + t.equal(closed, false, 'client should not emit close') + t.end() + server.close() + client.destroy() + }, 50) + }) + client.on('close', function () { + closed = true + }) + client.on('finish', function () { + t.fail('should not emit finish') + }) + client.sendSpan({ foo: 42 }) + }) +}) + +test('socket hang up - continue with new request', function (t) { + t.plan(4 + assertIntakeReq.asserts * 2 + assertMetadata.asserts + assertEvent.asserts) + let reqs = 0 + let client + const datas = [ + assertMetadata, + assertEvent({ span: { req: 2 } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + + if (++reqs === 1) return req.socket.destroy() + + // We have to attach the listener directly to the HTTP request stream as it + // will receive the gzip header once the write have been made on the + // client. If we were to attach it to the gunzip+ndjson, it would not fire + req.on('data', function () { + client.flush() + }) + + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + t.pass('should end request') + res.end() + client.end() // cleanup 1: end the client stream so it can 'finish' + }) + }).client({ apmServerVersion: '8.0.0' }, function (_client) { + client = _client + client.on('request-error', function (err) { + t.equal(err.message, 'socket hang up', 'got "socket hang up" request-error') + t.equal(err.code, 'ECONNRESET', 'request-error code is "ECONNRESET"') + client.sendSpan({ req: 2 }) + }) + client.on('finish', function () { + t.equal(reqs, 2, 'should emit finish after last request') + client.end() + server.close() + t.end() + }) + client.sendSpan({ req: 1 }) + }) +}) + +test('intakeResTimeoutOnEnd', function (t) { + const server = APMServer(function (req, res) { + req.resume() + }).client({ + intakeResTimeoutOnEnd: 500, + apmServerVersion: '8.0.0' + }, function (client) { + const start = Date.now() + client.on('request-error', function (err) { + t.ok(err, 'got a request-error from the client') + const end = Date.now() + const delta = end - start + t.ok(delta > 400 && delta < 600, `timeout should be about 500ms, got ${delta}ms`) + t.equal(err.message, 'intake response timeout: APM server did not respond within 0.5s of gzip stream finish') + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('intakeResTimeout', function (t) { + const server = APMServer(function (req, res) { + req.resume() + }).client({ + intakeResTimeout: 400, + apmServerVersion: '8.0.0' + }, function (client) { + const start = Date.now() + client.on('request-error', function (err) { + t.ok(err, 'got a request-error from the client') + const end = Date.now() + const delta = end - start + t.ok(delta > 300 && delta < 500, `timeout should be about 400ms, got ${delta}ms`) + t.equal(err.message, 'intake response timeout: APM server did not respond within 0.4s of gzip stream finish') + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + // Do *not* `client.end()` else we are testing intakeResTimeoutOnEnd. + client.flush() + }) +}) + +test('socket timeout - server response too slow', function (t) { + const server = APMServer(function (req, res) { + req.resume() + }).client({ + serverTimeout: 1000, + // Set the intake res timeout higher to be able to test serverTimeout. + intakeResTimeoutOnEnd: 5000, + apmServerVersion: '8.0.0' + }, function (client) { + const start = Date.now() + client.on('request-error', function (err) { + t.ok(err, 'got a request-error from the client') + const end = Date.now() + const delta = end - start + t.ok(delta > 1000 && delta < 2000, `timeout should occur between 1-2 seconds: delta=${delta}ms`) + t.equal(err.message, 'APM Server response timeout (1000ms)') + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('socket timeout - client request too slow', function (t) { + const server = APMServer(function (req, res) { + req.resume() + req.on('end', function () { + res.end() + }) + }).client({ serverTimeout: 1000, apmServerVersion: '8.0.0' }, function (client) { + const start = Date.now() + client.on('request-error', function (err) { + const end = Date.now() + const delta = end - start + t.ok(delta > 1000 && delta < 2000, 'timeout should occur between 1-2 seconds') + t.equal(err.message, 'APM Server response timeout (1000ms)') + server.close() + t.end() + }) + client.sendSpan({ foo: 42 }) + }) +}) + +test('client.destroy() - on fresh client', function (t) { + t.plan(1) + const client = new HttpApmClient(validOpts()) + client.on('finish', function () { + t.fail('should not emit finish') + }) + client.on('close', function () { + t.pass('should emit close') + }) + client.destroy() + process.nextTick(function () { + // wait a little to allow close to be emitted + t.end() + }) +}) + +test('client.destroy() - on ended client', function (t) { + t.plan(2) + let client + + // create a server that doesn't unref incoming sockets to see if + // `client.destroy()` will make the server close without hanging + const server = http.createServer(function (req, res) { + req.resume() + req.on('end', function () { + res.end() + client.destroy() + server.close() + process.nextTick(function () { + // wait a little to allow close to be emitted + t.end() + }) + }) + }) + + server.listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + apmServerVersion: '8.0.0' + })) + client.on('finish', function () { + t.pass('should emit finish only once') + }) + client.on('close', function () { + t.pass('should emit close event') + }) + client.sendSpan({ foo: 42 }) + client.end() + }) +}) + +test('client.destroy() - on client with request in progress', function (t) { + t.plan(1) + let client + + // create a server that doesn't unref incoming sockets to see if + // `client.destroy()` will make the server close without hanging + const server = http.createServer(function (req, res) { + server.close() + client.destroy() + process.nextTick(function () { + // wait a little to allow close to be emitted + t.end() + }) + }) + + server.listen(function () { + client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port + // TODO: the _fetchApmServerVersion() here *is* hanging. + })) + client.on('finish', function () { + t.fail('should not emit finish') + }) + client.on('close', function () { + t.pass('should emit close event') + }) + client.sendSpan({ foo: 42 }) + }) +}) + +// If the client is destroyed while waiting for cloud metadata to be fetched, +// there should not be an error: +// Error: Cannot call write after a stream was destroyed +// when cloud metadata *has* returned. +test('getCloudMetadata after client.destroy() should not result in error', function (t) { + const server = http.createServer(function (req, res) { + res.end('bye') + }) + + server.listen(function () { + // 1. Create a client with a slow cloudMetadataFetcher. + const client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + server.address().port, + cloudMetadataFetcher: { + getCloudMetadata: function (cb) { + setTimeout(function () { + t.comment('calling back with cloud metadata') + cb(null, { fake: 'cloud metadata' }) + }, 1000) + } + } + })) + client.on('close', function () { + t.pass('should emit close event') + }) + client.on('finish', function () { + t.fail('should not emit finish') + }) + client.on('error', function (err) { + t.ifError(err, 'should not get a client "error" event') + }) + client.on('cloud-metadata', function () { + t.end() + }) + + // 2. Start sending something to the (mock) APM server. + client.sendSpan({ foo: 42 }) + + // 3. Then destroy the client soon after, but before the `getCloudMetadata` + // above finishes. + setImmediate(function () { + t.comment('destroy client') + client.destroy() + server.close() + }) + }) +}) + +// FWIW, the current apm-agent-nodejs will happily call +// `client.sendTransaction()` after it has called `client.destroy()`. +test('client.send*() after client.destroy() should not result in error', function (t) { + const mockApmServer = http.createServer(function (req, res) { + res.end('bye') + }) + + mockApmServer.listen(function () { + const UNCORK_TIMER_MS = 100 + const client = new HttpApmClient(validOpts({ + serverUrl: 'http://localhost:' + mockApmServer.address().port, + bufferWindowTime: UNCORK_TIMER_MS + })) + + // 2. We should *not* receive: + // Error: Cannot call write after a stream was destroyed + client.on('error', function (err) { + t.ifErr(err, 'should *not* receive a "Cannot call write after a stream was destroyed" error') + }) + + // 1. Destroy the client, and then call one of its `.send*()` methods. + client.destroy() + client.sendSpan({ a: 'fake span' }) + + // 3. Give it until after `conf.bufferWindowTime` time (the setTimeout + // length used for `_corkTimer`) -- which is the error code path we + // are testing. + setTimeout(function () { + t.ok('waited 2 * UNCORK_TIMER_MS') + mockApmServer.close(function () { + t.end() + }) + }, 2 * UNCORK_TIMER_MS) + }) +}) + +const dataTypes = ['span', 'transaction', 'error'] +dataTypes.forEach(function (dataType) { + const sendFn = 'send' + dataType.charAt(0).toUpperCase() + dataType.substr(1) + + test(`client.${sendFn}(): handle circular references`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ [dataType]: { foo: 42, bar: '[Circular]' } }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + const obj = { foo: 42 } + obj.bar = obj + client[sendFn](obj) + client.flush(() => { client.destroy() }) + }) + }) +}) + +// Ensure that the client.flush(cb) callback is called even if there are no +// active handles -- i.e. the process is exiting. We test this out of process +// to ensure no conflict with other tests or the test framework. +test('client.flush callbacks must be called, even if no active handles', function (t) { + let theError + + const server = APMServer(function (req, res) { + const objStream = processIntakeReq(req) + let n = 0 + objStream.on('data', function (obj) { + if (++n === 2) { + theError = obj.error + } + }) + objStream.on('end', function () { + res.statusCode = 202 + res.end() + server.close() + }) + }) + + server.listen(function () { + const url = 'http://localhost:' + server.address().port + const script = path.resolve(__dirname, 'lib', 'call-me-back-maybe.js') + const start = Date.now() + exec(`"${process.execPath}" ${script} ${url}`, function (err, stdout, stderr) { + if (stderr.trim()) { + t.comment(`stderr from ${script}:\n${stderr}`) + } + if (err) { + throw err + } + t.equal(stdout, 'sendCb called\nflushCb called\n', + 'stdout shows both callbacks were called') + const duration = Date.now() - start + t.ok(duration < 1000, `should complete quickly, ie. not timeout (was: ${duration}ms)`) + + t.ok(theError, `APM server got an error object from ${script}`) + if (theError) { + t.equal(theError.exception.message, 'boom', 'error message is "boom"') + } + t.end() + }) + }) +}) diff --git a/test/apm-client/http-apm-client/expectExtraMetadata.test.js b/test/apm-client/http-apm-client/expectExtraMetadata.test.js new file mode 100644 index 0000000000..16114ac20b --- /dev/null +++ b/test/apm-client/http-apm-client/expectExtraMetadata.test.js @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Test usage of `expectExtraMetadata: true` and `setExtraMetadata()`. + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq + +test('expectExtraMetadata and setExtraMetadata used properly', function (t) { + const apmEvents = [] + + const server = APMServer(function (req, res) { + const objStream = processIntakeReq(req) + objStream.on('data', function (obj) { + apmEvents.push(obj) + }) + objStream.on('end', function () { + res.statusCode = 202 + res.end() + }) + }).client({ expectExtraMetadata: true, apmServerVersion: '8.0.0' }, function (client) { + client.setExtraMetadata({ + foo: 'bar', + service: { + runtime: { + name: 'MyLambda' + } + } + }) + client.sendTransaction({ req: 1 }) + + client.flush(() => { + t.equal(apmEvents.length, 2, 'APM Server got 2 events') + t.ok(apmEvents[0].metadata, 'event 0 is metadata') + t.equal(apmEvents[0].metadata.foo, 'bar', 'setExtraMetadata added "foo" field') + t.equal(apmEvents[0].metadata.service.runtime.name, 'MyLambda', + 'setExtraMetadata set nested service.runtime.name field properly') + t.ok(apmEvents[1].transaction, 'event 1 is a transaction') + + client.end() + server.close() + t.end() + }) + }) +}) + +test('empty setExtraMetadata is fine, and calling after send* is fine', function (t) { + const apmEvents = [] + + const server = APMServer(function (req, res) { + const objStream = processIntakeReq(req) + objStream.on('data', function (obj) { + apmEvents.push(obj) + }) + objStream.on('end', function () { + res.statusCode = 202 + res.end() + }) + }).client({ expectExtraMetadata: true, apmServerVersion: '8.0.0' }, function (client) { + client.sendTransaction({ req: 1 }) + client.setExtraMetadata() + + client.flush(() => { + t.equal(apmEvents.length, 2, 'APM Server got 2 events') + t.ok(apmEvents[0].metadata, 'event 0 is metadata') + t.ok(apmEvents[1].transaction, 'event 1 is a transaction') + + client.end() + server.close() + t.end() + }) + }) +}) + +test('expectExtraMetadata:true with *no* setExtraMetadata call results in a corked client', function (t) { + const server = APMServer(function (req, res) { + t.fail('do NOT expect to get intake request to APM server') + }).client({ expectExtraMetadata: true, apmServerVersion: '8.0.0' }, function (client) { + // Explicitly *not* calling setExtraMetadata(). + client.sendTransaction({ req: 1 }) + + client.flush(() => { + t.fail('should *not* callback from flush') + }) + setTimeout(() => { + t.pass('hit timeout without an intake request to APM server') + client.destroy() + server.close() + t.end() + }, 1000) + }) +}) diff --git a/test/apm-client/http-apm-client/extraMetadata.test.js b/test/apm-client/http-apm-client/extraMetadata.test.js new file mode 100644 index 0000000000..85baf3e7a1 --- /dev/null +++ b/test/apm-client/http-apm-client/extraMetadata.test.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Test usage of `extraMetadata: ...`. + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq + +test('extraMetadata', function (t) { + const apmEvents = [] + const extraMetadata = { + foo: 'bar', + service: { + language: { + name: 'spam' + } + } + } + + const server = APMServer(function (req, res) { + const objStream = processIntakeReq(req) + objStream.on('data', function (obj) { + apmEvents.push(obj) + }) + objStream.on('end', function () { + res.statusCode = 202 + res.end() + }) + }).client({ extraMetadata, apmServerVersion: '8.0.0' }, function (client) { + client.sendTransaction({ req: 1 }) + + client.flush(() => { + t.equal(apmEvents.length, 2, 'APM Server got 2 events') + t.ok(apmEvents[0].metadata, 'event 0 is metadata') + t.equal(apmEvents[0].metadata.foo, 'bar', 'extraMetadata added "foo" field') + t.equal(apmEvents[0].metadata.service.language.name, 'spam', + 'extraMetadata overrode nested service.language.name field properly') + t.ok(apmEvents[1].transaction, 'event 1 is a transaction') + + client.end() + server.close() + t.end() + }) + }) +}) diff --git a/test/apm-client/http-apm-client/fixtures/cgroup b/test/apm-client/http-apm-client/fixtures/cgroup new file mode 100644 index 0000000000..ddf8ee80eb --- /dev/null +++ b/test/apm-client/http-apm-client/fixtures/cgroup @@ -0,0 +1,14 @@ +14:pids:/kubepods/kubepods/besteffort/pod0e886e9a-3879-45f9-b44d-86ef9df03224/244a65edefdffe31685c42317c9054e71dc1193048cf9459e2a4dd35cbc1dba4 +13:cpuset:/kubepods/pod5eadac96-ab58-11ea-b82b-0242ac110009/7fe41c8a2d1da09420117894f11dd91f6c3a44dfeb7d125dc594bd53468861df +12:freezer:/kubepods.slice/kubepods-pod22949dce_fd8b_11ea_8ede_98f2b32c645c.slice/docker-b15a5bdedd2e7645c3be271364324321b908314e4c77857bbfd32a041148c07f.scope +11:devices:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +10:perf_event:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +9:memory:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +8:freezer:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +7:hugetlb:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +6:cpuset:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +5:blkio:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +4:cpu,cpuacct:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +3:net_cls,net_prio:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +2:pids:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 +1:name=systemd:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376 diff --git a/test/apm-client/http-apm-client/fixtures/cgroup_result.js b/test/apm-client/http-apm-client/fixtures/cgroup_result.js new file mode 100644 index 0000000000..6ad47b27b1 --- /dev/null +++ b/test/apm-client/http-apm-client/fixtures/cgroup_result.js @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +module.exports = { + entries: [ + { + id: '14', + groups: 'pids', + path: '/kubepods/kubepods/besteffort/pod0e886e9a-3879-45f9-b44d-86ef9df03224/244a65edefdffe31685c42317c9054e71dc1193048cf9459e2a4dd35cbc1dba4', + controllers: ['pids'], + containerId: '244a65edefdffe31685c42317c9054e71dc1193048cf9459e2a4dd35cbc1dba4', + podId: '0e886e9a-3879-45f9-b44d-86ef9df03224' + }, + { + id: '13', + groups: 'cpuset', + path: '/kubepods/pod5eadac96-ab58-11ea-b82b-0242ac110009/7fe41c8a2d1da09420117894f11dd91f6c3a44dfeb7d125dc594bd53468861df', + controllers: ['cpuset'], + containerId: '7fe41c8a2d1da09420117894f11dd91f6c3a44dfeb7d125dc594bd53468861df', + podId: '5eadac96-ab58-11ea-b82b-0242ac110009' + }, + { + id: '12', + groups: 'freezer', + path: '/kubepods.slice/kubepods-pod22949dce_fd8b_11ea_8ede_98f2b32c645c.slice/docker-b15a5bdedd2e7645c3be271364324321b908314e4c77857bbfd32a041148c07f.scope', + controllers: ['freezer'], + containerId: 'b15a5bdedd2e7645c3be271364324321b908314e4c77857bbfd32a041148c07f', + podId: '22949dce-fd8b-11ea-8ede-98f2b32c645c' + }, + { + id: '11', + groups: 'devices', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['devices'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '10', + groups: 'perf_event', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['perf_event'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '9', + groups: 'memory', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['memory'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '8', + groups: 'freezer', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['freezer'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '7', + groups: 'hugetlb', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['hugetlb'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '6', + groups: 'cpuset', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['cpuset'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '5', + groups: 'blkio', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['blkio'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '4', + groups: 'cpu,cpuacct', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['cpu', 'cpuacct'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '3', + groups: 'net_cls,net_prio', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['net_cls', 'net_prio'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '2', + groups: 'pids', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['pids'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + id: '1', + groups: 'name=systemd', + path: '/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + controllers: ['name=systemd'], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' + } + ], + containerId: '34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + podId: '74c13223-5a00-11e9-b385-42010a80018d' +} diff --git a/test/apm-client/http-apm-client/fixtures/ecs-container-metadata.json b/test/apm-client/http-apm-client/fixtures/ecs-container-metadata.json new file mode 100644 index 0000000000..66980afb81 --- /dev/null +++ b/test/apm-client/http-apm-client/fixtures/ecs-container-metadata.json @@ -0,0 +1,3 @@ +{ + "ContainerID": "34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376" +} diff --git a/test/apm-client/http-apm-client/k8s.test.js b/test/apm-client/http-apm-client/k8s.test.js new file mode 100644 index 0000000000..943656eff6 --- /dev/null +++ b/test/apm-client/http-apm-client/k8s.test.js @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const { APMServer, processIntakeReq } = require('./lib/utils') +const getContainerInfo = require('../../../lib/apm-client/http-apm-client/container-info') + +test('no environment variables', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.equal(obj.metadata.kubernetes, undefined) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('kubernetesNodeName only', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { node: { name: 'foo' } }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNodeName: 'foo', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('kubernetesNamespace only', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { namespace: 'foo' }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNamespace: 'foo', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('kubernetesPodName only', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { pod: { name: 'foo' } }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesPodName: 'foo', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('kubernetesPodUID only', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { pod: { uid: 'foo' } }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesPodUID: 'foo', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('all', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { + namespace: 'bar', + node: { name: 'foo' }, + pod: { name: 'baz', uid: 'qux' } + }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNodeName: 'foo', kubernetesNamespace: 'bar', kubernetesPodName: 'baz', kubernetesPodUID: 'qux', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('all except kubernetesNodeName', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { + namespace: 'bar', + pod: { name: 'baz', uid: 'qux' } + }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNamespace: 'bar', kubernetesPodName: 'baz', kubernetesPodUID: 'qux', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('all except kubernetesNamespace', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { + node: { name: 'foo' }, + pod: { name: 'baz', uid: 'qux' } + }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNodeName: 'foo', kubernetesPodName: 'baz', kubernetesPodUID: 'qux', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('all except kubernetesPodName', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { + namespace: 'bar', + node: { name: 'foo' }, + pod: { uid: 'qux' } + }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNodeName: 'foo', kubernetesNamespace: 'bar', kubernetesPodUID: 'qux', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('all except kubernetesPodUID', function (t) { + t.plan(1) + + const server = APMServer(function (req, res) { + req = processIntakeReq(req) + req.once('data', function (obj) { + t.deepEqual(obj.metadata.system.kubernetes, { + namespace: 'bar', + node: { name: 'foo' }, + pod: { name: 'baz' } + }) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ kubernetesNodeName: 'foo', kubernetesNamespace: 'bar', kubernetesPodName: 'baz', apmServerVersion: '8.0.0' }, function (client) { + client.sendError({}) + client.flush(() => { client.destroy() }) + }) +}) + +test('Tests for ../lib/container-info', function (t) { + const fixtures = [ + { + source: '12:freezer:/kubepods.slice/kubepods-pod22949dce_fd8b_11ea_8ede_98f2b32c645c.slice/docker-b15a5bdedd2e7645c3be271364324321b908314e4c77857bbfd32a041148c07f.scope', + expectedPodId: '22949dce-fd8b-11ea-8ede-98f2b32c645c' + }, + { + source: '11:devices:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '10:perf_event:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '9:memory:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '8:freezer:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '7:hugetlb:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '6:cpuset:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '5:blkio:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '4:cpu,cpuacct:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '3:net_cls,net_prio:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '2:pids:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + }, + { + source: '1:name=systemd:/kubepods/besteffort/pod74c13223-5a00-11e9-b385-42010a80018d/34dc0b5e626f2c5c4c5170e34b10e7654ce36f0fcd532739f4445baabea03376', + expectedPodId: '74c13223-5a00-11e9-b385-42010a80018d' + } + ] + for (const [, fixture] of fixtures.entries()) { + const info = getContainerInfo.parse(fixture.source) + t.equals(info.podId, fixture.expectedPodId, 'expected pod ID returned') + } + + t.end() +}) diff --git a/test/apm-client/http-apm-client/lambda-usage.test.js b/test/apm-client/http-apm-client/lambda-usage.test.js new file mode 100644 index 0000000000..e50286c395 --- /dev/null +++ b/test/apm-client/http-apm-client/lambda-usage.test.js @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Test the expected usage of this Client in an AWS Lambda environment. +// The "Notes on Lambda usage" section in the README.md describes the +// expected usage. +// +// Note: This test file needs to be run in its own process. + +// Must set this before the Client is imported so it thinks it is in a Lambda env. +process.env.AWS_LAMBDA_FUNCTION_NAME = 'myFn' + +const { URL } = require('url') +const zlib = require('zlib') +const test = require('tape') +const { APMServer } = require('./lib/utils') + +test('lambda usage', suite => { + let server + let client + let reqsToServer = [] + let lateSpanInSameTickCallbackCalled = false + let lateSpanInNextTickCallbackCalled = false + + test('setup mock APM server', t => { + server = APMServer(function (req, res) { + if (req.method === 'POST' && req.url === '/register/transaction') { + req.resume() + req.on('end', () => { + res.writeHead(200) + res.end() + }) + return + } else if (!(req.method === 'POST' && req.url.startsWith('/intake/v2/events'))) { + req.resume() + req.on('end', () => { + res.writeHead(404) + res.end() + }) + return + } + + // Capture intake req data to this mock APM server to `reqsToServer`. + const reqInfo = { + method: req.method, + path: req.url, + url: new URL(req.url, 'http://localhost'), + headers: req.headers, + events: [] + } + let instream = req + if (req.headers['content-encoding'] === 'gzip') { + instream = req.pipe(zlib.createGunzip()) + } else { + instream.setEncoding('utf8') + } + let body = '' + instream.on('data', chunk => { + body += chunk + }) + instream.on('end', () => { + body + .split(/\n/g) // parse each line + .filter(line => line.trim()) // ... if it is non-empty + .forEach(line => { + reqInfo.events.push(JSON.parse(line)) // ... append to reqInfo.events + }) + reqsToServer.push(reqInfo) + res.writeHead(202) // the expected response from intake API endpoint + res.end('{}') + }) + }) + + server.client({ + apmServerVersion: '8.0.0', + centralConfig: false + }, function (client_) { + client = client_ + t.end() + }) + }) + + test('clients stays corked before .lambdaStart()', t => { + t.plan(2) + // Add more events than `bufferWindowSize` and wait for more than + // `bufferWindowTime`, and the Client should *still* be corked. + const aTrans = { name: 'aTrans', type: 'custom', result: 'success' /* ... */ } + for (let i = 0; i < client._conf.bufferWindowSize + 1; i++) { + client.sendTransaction(aTrans) + } + setTimeout(() => { + t.equal(client._writableState.corked, 1, + 'corked after bufferWindowSize events and bufferWindowTime') + t.equal(reqsToServer.length, 0, 'no intake request was made to APM Server') + // t.end() + }, client._conf.bufferWindowTime + 10) + }) + + test('lambda invocation', async (t) => { + client.lambdaStart() // 1. start of invocation + + // 2. Registering transaction + t.equal(client.lambdaShouldRegisterTransactions(), true, '.lambdaShouldRegisterTransactions() is true') + await client.lambdaRegisterTransaction( + { name: 'GET /aStage/myFn', type: 'lambda', outcome: 'unknown' /* ... */ }, + '063de0d2-1705-4eeb-9dfd-045d76b8cdec') + t.equal(client.lambdaShouldRegisterTransactions(), true, '.lambdaShouldRegisterTransactions() is true after register') + + return new Promise(function (resolve) { + setTimeout(() => { + client.sendTransaction({ name: 'GET /aStage/myFn', type: 'lambda', result: 'success' /* ... */ }) + client.sendSpan({ name: 'mySpan', type: 'custom', result: 'success' /* ... */ }) + + // 3. Flush at end of invocation + client.flush({ lambdaEnd: true }, function () { + t.ok(reqsToServer.length > 1, 'at least 2 intake requests to APM Server') + t.equal(reqsToServer[reqsToServer.length - 1].url.searchParams.get('flushed'), 'true', + 'the last intake request had "?flushed=true" query param') + + let allEvents = [] + reqsToServer.forEach(r => { allEvents = allEvents.concat(r.events) }) + t.equal(allEvents[allEvents.length - 2].transaction.name, 'GET /aStage/myFn', + 'second last event is the lambda transaction') + t.equal(allEvents[allEvents.length - 1].span.name, 'mySpan', + 'last event is the lambda span') + + reqsToServer = [] // reset + t.end() + resolve() + }) + + // Explicitly send late events and flush *after* the + // `client.flush({lambdaEnd:true})` -- both in the same tick and next + // ticks -- to test that these get buffered until the next lambda + // invocation. + client.sendSpan({ name: 'lateSpanInSameTick', type: 'custom' /* ... */ }) + client.flush(function () { + lateSpanInSameTickCallbackCalled = true + }) + setImmediate(() => { + client.sendSpan({ name: 'lateSpanInNextTick', type: 'custom' /* ... */ }) + client.flush(function () { + lateSpanInNextTickCallbackCalled = true + }) + }) + }, 10) + }) + }) + + // Give some time to make sure there isn't some unexpected short async + // interaction. + test('pause between lambda invocations', t => { + setTimeout(() => { + t.end() + }, 1000) + }) + + test('second lambda invocation', t => { + t.equal(lateSpanInSameTickCallbackCalled, false, 'lateSpanInSameTick flush callback not yet called') + t.equal(lateSpanInNextTickCallbackCalled, false, 'lateSpanInNextTick flush callback not yet called') + t.equal(reqsToServer.length, 0, 'no intake request was made to APM Server since last lambdaEnd') + + client.lambdaStart() + setTimeout(() => { + client.flush({ lambdaEnd: true }, () => { + t.equal(reqsToServer.length, 3, '3 intake requests to APM Server') + t.equal(lateSpanInSameTickCallbackCalled, true, 'lateSpanInSameTick flush callback has now been called') + t.equal(lateSpanInNextTickCallbackCalled, true, 'lateSpanInNextTick flush callback has now been called') + + t.equal(reqsToServer[0].events.length, 2, + 'the first intake request has 2 events') + t.equal(reqsToServer[0].events[1].span.name, 'lateSpanInSameTick', + 'of which the second event is the lateSpanInSameTick') + t.equal(reqsToServer[1].events.length, 2, + 'the second intake request has 2 events') + t.equal(reqsToServer[1].events[1].span.name, 'lateSpanInNextTick', + 'of which the second event is the lateSpanInNextTick') + t.equal(reqsToServer[reqsToServer.length - 1].url.searchParams.get('flushed'), 'true', + 'the last intake request had "?flushed=true" query param') + t.end() + }) + }, 10) + }) + + test('teardown', t => { + server.close() + client.destroy() + t.end() + }) + + suite.end() +}) diff --git a/test/apm-client/http-apm-client/lib/call-me-back-maybe.js b/test/apm-client/http-apm-client/lib/call-me-back-maybe.js new file mode 100644 index 0000000000..2d56bcb23b --- /dev/null +++ b/test/apm-client/http-apm-client/lib/call-me-back-maybe.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +// A script, used by test/side-effects.js, to test that the client.flush +// callback is called. +// +// We expect both `console.log`s to write their output. +// +// Two important things are required to reproduce the issue: +// 1. There cannot be other activities going on that involve active libuv +// handles. For this Client that means: +// - ensure no `_pollConfig` requests via `centralConfig: false` +// - do not provide a `cloudMetadataFetcher` +// - set `apmServerVersion` to not have an APM Server version fetch request +// 2. There must be a listening APM server to which to send data. + +const { HttpApmClient } = require('../../../../lib/apm-client/http-apm-client') + +const serverUrl = process.argv[2] + +const client = new HttpApmClient({ + // logger: require('pino')({ level: 'trace', ...require('@elastic/ecs-pino-format')() }, process.stderr), // uncomment for debugging + serverUrl, + serviceName: 'call-me-back-maybe', + agentName: 'my-nodejs-agent', + agentVersion: '1.2.3', + userAgent: 'my-nodejs-agent/1.2.3', + centralConfig: false, // important for repro, see above + apmServerVersion: '8.0.0' // important for repro, see above +}) + +const e = { exception: { message: 'boom', type: 'Error' } } + +client.sendError(e, function sendCb () { + console.log('sendCb called') + client.flush(function flushCb () { + console.log('flushCb called') + }) +}) diff --git a/test/apm-client/http-apm-client/lib/unref-client.js b/test/apm-client/http-apm-client/lib/unref-client.js new file mode 100644 index 0000000000..0bb3d9764b --- /dev/null +++ b/test/apm-client/http-apm-client/lib/unref-client.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// This is used in test/side-effects.js to ensure that a Client with a +// (sometimes long-lived) request open to APM server does *not* keep a node +// process alive. + +const { HttpApmClient } = require('../../../../lib/apm-client/http-apm-client') + +const client = new HttpApmClient({ + // logger: require('pino')({ level: 'trace' }, process.stderr), // uncomment for debugging + serverUrl: process.argv[2], + secretToken: 'secret', + agentName: 'my-agent-name', + agentVersion: 'my-agent-version', + serviceName: 'my-service-name', + userAgent: 'my-user-agent' +}) + +process.stdout.write(String(Date.now()) + '\n') + +client.sendSpan({ hello: 'world' }) // Don't end the stream diff --git a/test/apm-client/http-apm-client/lib/utils.js b/test/apm-client/http-apm-client/lib/utils.js new file mode 100644 index 0000000000..815ebba914 --- /dev/null +++ b/test/apm-client/http-apm-client/lib/utils.js @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const http = require('http') +const https = require('https') +const os = require('os') +const { URL } = require('url') +const zlib = require('zlib') +const semver = require('semver') +const ndjson = require('ndjson') +const { HttpApmClient } = require('../../../../lib/apm-client/http-apm-client') + +exports.APMServer = APMServer +exports.processIntakeReq = processIntakeReq +exports.assertIntakeReq = assertIntakeReq +exports.assertConfigReq = assertConfigReq +exports.assertMetadata = assertMetadata +exports.assertEvent = assertEvent +exports.validOpts = validOpts + +function APMServer (opts, onreq) { + if (typeof opts === 'function') return APMServer(null, opts) + opts = opts || {} + + const secure = !!opts.secure + + const server = secure + ? https.createServer({ cert: tlsCert, key: tlsKey }, onreq) + : http.createServer(onreq) + + // Because we use a keep-alive agent in the client, we need to unref the + // sockets received by the server. If not, the server would hold open the app + // even after the tests have completed + server.on('connection', function (socket) { + socket.unref() + }) + + server.client = function (clientOpts, onclient) { + if (typeof clientOpts === 'function') { + onclient = clientOpts + clientOpts = {} + } + server.listen(function () { + onclient(new HttpApmClient(validOpts(Object.assign({ + // logger: require('pino')({ level: 'trace' }), // uncomment for debugging + serverUrl: `http${secure ? 's' : ''}://localhost:${server.address().port}`, + secretToken: 'secret' + }, clientOpts)))) + }) + return server + } + + return server +} + +function processIntakeReq (req) { + return req.pipe(zlib.createGunzip()).pipe(ndjson.parse()) +} + +function assertIntakeReq (t, req) { + t.equal(req.method, 'POST', 'should make a POST request') + t.equal(req.url, '/intake/v2/events', 'should send request to /intake/v2/events') + t.equal(req.headers.authorization, 'Bearer secret', 'should add secret token') + t.equal(req.headers['content-type'], 'application/x-ndjson', 'should send reqeust as ndjson') + t.equal(req.headers['content-encoding'], 'gzip', 'should compress request') + t.equal(req.headers.accept, 'application/json', 'should expect json in response') + t.equal(req.headers['user-agent'], 'my-user-agent', 'should add proper User-Agent') +} +assertIntakeReq.asserts = 7 + +function assertConfigReq (t, req) { + const url = new URL(req.url, 'relative:///') + + t.equal(req.method, 'GET', 'should make a GET request') + t.equal(url.pathname, '/config/v1/agents', 'should send request to /config/v1/agents') + t.equal(url.search, '?service.name=my-service-name&service.environment=development', 'should encode query in query params') + t.equal(req.headers.authorization, 'Bearer secret', 'should add secret token') + t.equal(req.headers['user-agent'], 'my-user-agent', 'should add proper User-Agent') +} +assertConfigReq.asserts = 5 + +function assertMetadata (t, obj) { + t.deepEqual(Object.keys(obj), ['metadata']) + const metadata = obj.metadata + const metadataKeys = new Set(Object.keys(metadata)) + t.ok(metadataKeys.has('service')) + t.ok(metadataKeys.has('process')) + t.ok(metadataKeys.has('system')) + const service = metadata.service + t.equal(service.name, 'my-service-name') + t.equal(service.runtime.name, 'node') + t.equal(service.runtime.version, process.versions.node) + t.ok(semver.valid(service.runtime.version)) + t.equal(service.language.name, 'javascript') + t.equal(service.agent.name, 'my-agent-name') + t.equal(service.agent.version, 'my-agent-version') + const _process = metadata.process + t.ok(_process.pid > 0, `pid should be > 0, was ${_process.pid}`) + if (semver.gte(process.version, '8.10.0')) { + t.ok(_process.ppid > 0, `ppid should be > 0, was ${_process.ppid}`) + } else { + t.equal(_process.ppid, undefined) + } + + if (os.platform() === 'win32') { + t.ok('skip process.title check on Windows') + } else if (_process.title.length === 1) { + // because of truncation test + t.equal(_process.title, process.title[0]) + } else { + const regex = /node/ + t.ok(regex.test(_process.title), `process.title should match ${regex} (was: ${_process.title})`) + } + + t.ok(Array.isArray(_process.argv), 'process.title should be an array') + t.ok(_process.argv.length >= 2, 'process.title should contain at least two elements') + var regex = /node(\.exe)?$/i + t.ok(regex.test(_process.argv[0]), `process.argv[0] should match ${regex} (was: ${_process.argv[0]})`) + regex = /(test.*\.js|tape)$/ + t.ok(regex.test(_process.argv[1]), `process.argv[1] should match ${regex} (was: ${_process.argv[1]})"`) + const system = metadata.system + if ('detected_hostname' in system) { + t.ok(typeof system.detected_hostname, 'string') + t.ok(system.detected_hostname.length > 0) + } else { + t.ok(typeof system.hostname, 'string') + t.ok(system.hostname.length > 0) + } + t.ok(typeof system.architecture, 'string') + t.ok(system.architecture.length > 0) + t.ok(typeof system.platform, 'string') + t.ok(system.platform.length > 0) +} +assertMetadata.asserts = 24 + +function assertEvent (expect) { + return function (t, obj) { + const key = Object.keys(expect)[0] + const val = expect[key] + switch (key) { + case 'transaction': + if (!('name' in val)) val.name = 'undefined' + if (!('type' in val)) val.type = 'undefined' + if (!('result' in val)) val.result = 'undefined' + break + case 'span': + if (!('name' in val)) val.name = 'undefined' + if (!('type' in val)) val.type = 'undefined' + break + case 'error': + break + case 'metricset': + break + default: + t.fail('unexpected event type: ' + key) + } + t.deepEqual(obj, expect) + } +} +assertEvent.asserts = 1 + +function validOpts (opts) { + return Object.assign({ + agentName: 'my-agent-name', + agentVersion: 'my-agent-version', + serviceName: 'my-service-name', + userAgent: 'my-user-agent' + }, opts) +} + +// tlsCert and tlsKey were generated via the same method as Go's builtin +// test certificate/key pair, using +// https://github.com/golang/go/blob/master/src/crypto/tls/generate_cert.go: +// +// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,localhost \ +// --ca --start-date "Jan 1 00:00:00 1970" \ +// --duration=1000000h +// +// The certificate is valid for 127.0.0.1, ::1, and localhost; and expires in the year 2084. + +const tlsCert = `-----BEGIN CERTIFICATE----- +MIICETCCAXqgAwIBAgIQQalo5z3llnTiwERMPZQxujANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDrW9Z8jSgTMeN9Dt36HBj/kbU/aeFp10GshKm8IKWBpyyWKrTSjiYJIpTK +l/6sdC77UCDokYAk66T+IXIvvRvqOtD1HUt+KLlqZ7acunTp1Qq4PnASHBm9fdKs +F1c8gWlEXOMzCsC5BmokcijW7z8JTKszAVi2vpq5MHbtYxZXKQIDAQABo2YwZDAO +BgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUw +AwEB/zAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAA +AAEwDQYJKoZIhvcNAQELBQADgYEA4yzI/6gjkACdvrnlFm/MJlDQztPYYEAtQ6Sp +0q0PMQcynLfhH94KMjxJb31HNPJYXr7UrE6gwL2sUnfioXUTQTk35okpphR8MGu2 +hZ704px4wdeK/9B5Vh96oMZLYhm9SXizRVAZz7bPFYNMrhyk9lrWZXOaX526w4wI +Y5LTiUQ= +-----END CERTIFICATE-----` + +const tlsKey = `-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAOtb1nyNKBMx430O +3focGP+RtT9p4WnXQayEqbwgpYGnLJYqtNKOJgkilMqX/qx0LvtQIOiRgCTrpP4h +ci+9G+o60PUdS34ouWpntpy6dOnVCrg+cBIcGb190qwXVzyBaURc4zMKwLkGaiRy +KNbvPwlMqzMBWLa+mrkwdu1jFlcpAgMBAAECgYEAtZc9LQooIm86izHeWOw26XD9 +u/iwf94igL42y70QlbFreE1pCI++jwvMa2fMijh2S1bunSIuEc5yldUuaeDp2FtJ +k7U9orbJspnWy6ixk1KgpjffdHP73r4S3a5G81G8sq9Uvwl0vxF90eTvg9C7kUfk +J1YMy4zcpLtwkCHEkNUCQQDx79t6Dqswi8vDoS0+MCIJNCO4J49ZchL8aXE8n9GT +mF+eOsKy6e5qYH0oYPpeXchwf1tWhX1gBCb3fXrtOoPTAkEA+QoX9S1XofY8YS1M +iNVVSkLjpKgVoTQVe4j+vj16NHouVQ+oOvEUca2LTrHRx+utdar1NSexl51NO0Lj +3sqnkwJAPNWCC3Pqyb8tEljRxoRV2piYrrKL0gLkEUH2LjdFfGZhDKlb0Z8OywLO +Fbwk2FuejeMINX5FY0JIBg0wPrxq7wJAMoot2n/dLO0/y6jZw1sn9+4jLKM/4Hsl +cPCYYhsv1b6F8JVA2tVaBMfnYY0MubnGdf6/zI3FqLMvnTsx62DNKQJBAMYUaw/D +plXTexeEU/c0BRxQdOkGmDqOQtnuRQUCQq6gu+occTeilgFoKHWT3QcZHIpHxawJ +N2K67EWPRgr3suE= +-----END PRIVATE KEY-----` diff --git a/test/apm-client/http-apm-client/metadata-filter.test.js b/test/apm-client/http-apm-client/metadata-filter.test.js new file mode 100644 index 0000000000..f2648dacab --- /dev/null +++ b/test/apm-client/http-apm-client/metadata-filter.test.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq + +test('addMetadataFilter', function (t) { + let theMetadata + + const server = APMServer(function (req, res) { + const objStream = processIntakeReq(req) + let n = 0 + objStream.on('data', function (obj) { + if (++n === 1) { + theMetadata = obj.metadata + } + }) + objStream.on('end', function () { + res.statusCode = 202 + res.end() + }) + }) + + server.client({ apmServerVersion: '8.0.0' }, function (client) { + client.addMetadataFilter(function (md) { + delete md.process.argv + md.labels = { foo: 'bar' } + return md + }) + + client.sendSpan({ foo: 42 }) + client.flush(function () { + t.ok(theMetadata, 'APM server got metadata') + t.equal(theMetadata.process.argv, undefined, 'metadata.process.argv was removed') + t.equal(theMetadata.labels.foo, 'bar', 'metadata.labels.foo was added') + client.end() + server.close() + t.end() + }) + }) +}) diff --git a/test/apm-client/http-apm-client/side-effects.test.js b/test/apm-client/http-apm-client/side-effects.test.js new file mode 100644 index 0000000000..5c0df77fab --- /dev/null +++ b/test/apm-client/http-apm-client/side-effects.test.js @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const path = require('path') +const exec = require('child_process').exec +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent + +// Exec a script that uses `client.sendSpan(...)` and then finishes. The +// script process should finish quickly. This is exercising the "beforeExit" +// (to end an ongoing intake request) and Client.prototype._unref (to hold the +// process to complete sending to APM server) handling in the client. +test('client should not hold the process open', function (t) { + t.plan(1 + assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + + const thingsToAssert = [ + assertMetadata, + assertEvent({ span: { hello: 'world' } }) + ] + + const server = APMServer(function (req, res) { + // Handle the server info endpoint. + if (req.method === 'GET' && req.url === '/') { + req.resume() + res.statusCode = 200 + res.end(JSON.stringify({ build_date: '...', build_sha: '...', version: '8.0.0' })) + return + } + + // Handle an intake request. + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + thingsToAssert.shift()(t, obj) + }) + req.on('end', function () { + res.statusCode = 202 + res.end() + server.close() + }) + }) + + server.listen(function () { + const url = 'http://localhost:' + server.address().port + const file = path.join(__dirname, 'lib', 'unref-client.js') + exec(`node ${file} ${url}`, function (err, stdout, stderr) { + if (stderr.trim()) { + t.comment('stderr from unref-client.js:\n' + stderr) + } + if (err) { + throw err + } + const end = Date.now() + const start = Number(stdout) + const duration = end - start + t.ok(duration < 300, `should not take more than 300ms to complete (was: ${duration}ms)`) + t.end() + }) + }) +}) + +// This is the same test as the previous, except this time the APM server is +// not responding. Normally the `intakeResTimeout` value is used to handle +// timing out intake requests. However, that timeout defaults to 10s, which is +// very long to hold a closing process open. `makeIntakeRequest` overrides +// `intakeResTimeout` to *1s* if the client is ending. We test that ~1s timeout +// here. +test('client should not hold the process open even if APM server not responding', function (t) { + t.plan(2 + assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + + const thingsToAssert = [ + assertMetadata, + assertEvent({ span: { hello: 'world' } }) + ] + + const server = APMServer(function (req, res) { + // Handle the server info endpoint. + if (req.method === 'GET' && req.url === '/') { + req.resume() + // Intentionally do not respond. + return + } + + // Handle an intake request. + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + thingsToAssert.shift()(t, obj) + }) + req.on('end', function () { + res.statusCode = 202 + // Here the server is intentionally not responding: + // res.end() + // server.close() + }) + }) + + server.listen(function () { + const url = 'http://localhost:' + server.address().port + const file = path.join(__dirname, 'lib', 'unref-client.js') + exec(`node ${file} ${url}`, function (err, stdout, stderr) { + if (stderr.trim()) { + t.comment('stderr from unref-client.js:\n' + stderr) + } + t.ifErr(err, `no error from executing ${file}`) + const end = Date.now() + const start = Number(stdout) + const duration = end - start + t.ok(duration > 700 && duration < 1300, + `should take approximately 1000ms to timeout (was: ${duration}ms)`) + + server.close() + t.end() + }) + }) +}) diff --git a/test/apm-client/http-apm-client/stringify.test.js b/test/apm-client/http-apm-client/stringify.test.js new file mode 100644 index 0000000000..441ca8bc0a --- /dev/null +++ b/test/apm-client/http-apm-client/stringify.test.js @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent + +const dataTypes = ['transaction', 'error'] +const properties = ['request', 'response'] + +const upper = { + transaction: 'Transaction', + error: 'Error' +} + +dataTypes.forEach(function (dataType) { + properties.forEach(function (prop) { + const sendFn = 'send' + upper[dataType] + + test(`stringify ${dataType} ${prop} headers`, function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + [dataType]: { + context: { + [prop]: { + headers: { + string: 'foo', + number: '42', + bool: 'true', + nan: 'NaN', + object: '[object Object]', + array: ['foo', '42', 'true', 'NaN', '[object Object]'] + } + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client({ apmServerVersion: '8.0.0' }, function (client) { + client[sendFn]({ + context: { + [prop]: { + headers: { + string: 'foo', + number: 42, + bool: true, + nan: NaN, + object: { foo: 'bar' }, + array: ['foo', 42, true, NaN, { foo: 'bar' }] + } + } + } + }) + client.flush(function () { + client.destroy() // Destroy keep-alive agent when done on client-side. + }) + }) + }) + }) +}) diff --git a/test/apm-client/http-apm-client/truncate.test.js b/test/apm-client/http-apm-client/truncate.test.js new file mode 100644 index 0000000000..e32f601cff --- /dev/null +++ b/test/apm-client/http-apm-client/truncate.test.js @@ -0,0 +1,480 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer +const processIntakeReq = utils.processIntakeReq +const assertIntakeReq = utils.assertIntakeReq +const assertMetadata = utils.assertMetadata +const assertEvent = utils.assertEvent +const truncate = require('../../../lib/apm-client/http-apm-client/truncate') + +const options = [ + {}, // default options + { truncateKeywordsAt: 100, truncateErrorMessagesAt: 200, truncateStringsAt: 300, truncateLongFieldsAt: 400 }, + { truncateErrorMessagesAt: -1 } +] + +options.forEach(function (opts) { + const clientOpts = Object.assign({ apmServerVersion: '8.0.0' }, opts) + const veryLong = 12000 + const lineLen = opts.truncateStringsAt || 1024 + const longFieldLen = opts.truncateLongFieldsAt || 10000 + const keywordLen = opts.truncateKeywordsAt || 1024 + const customKeyLen = opts.truncateCustomKeysAt || 1024 + const errMsgLen = opts.truncateErrorMessagesAt === -1 + ? veryLong + : (opts.truncateErrorMessagesAt || longFieldLen) + + test('truncate transaction', function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + transaction: { + id: 'abc123', + name: genStr('a', keywordLen), + type: genStr('b', keywordLen), + result: genStr('c', keywordLen), + sampled: true, + context: { + request: { + method: genStr('d', keywordLen), + url: { + protocol: genStr('e', keywordLen), + hostname: genStr('f', keywordLen), + port: genStr('g', keywordLen), + pathname: genStr('h', keywordLen), + search: genStr('i', keywordLen), + hash: genStr('j', keywordLen), + raw: genStr('k', keywordLen), + full: genStr('l', keywordLen) + } + }, + user: { + id: genStr('m', keywordLen), + email: genStr('n', keywordLen), + username: genStr('o', keywordLen) + }, + custom: { + foo: genStr('p', lineLen) + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client(clientOpts, function (client) { + client.sendTransaction({ + id: 'abc123', + name: genStr('a', veryLong), + type: genStr('b', veryLong), + result: genStr('c', veryLong), + sampled: true, + context: { + request: { + method: genStr('d', veryLong), + url: { + protocol: genStr('e', veryLong), + hostname: genStr('f', veryLong), + port: genStr('g', veryLong), + pathname: genStr('h', veryLong), + search: genStr('i', veryLong), + hash: genStr('j', veryLong), + raw: genStr('k', veryLong), + full: genStr('l', veryLong) + } + }, + user: { + id: genStr('m', veryLong), + email: genStr('n', veryLong), + username: genStr('o', veryLong) + }, + custom: { + foo: genStr('p', veryLong) + } + } + }) + client.flush(() => { client.destroy() }) + }) + }) + + test('truncate span', function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + span: { + id: 'abc123', + name: genStr('a', keywordLen), + type: genStr('b', keywordLen), + stacktrace: [ + { pre_context: [genStr('c', lineLen), genStr('d', lineLen)], context_line: genStr('e', lineLen), post_context: [genStr('f', lineLen), genStr('g', lineLen)] }, + { pre_context: [genStr('h', lineLen), genStr('i', lineLen)], context_line: genStr('j', lineLen), post_context: [genStr('k', lineLen), genStr('l', lineLen)] } + ], + context: { + custom: { + foo: genStr('m', lineLen) + }, + db: { + statement: genStr('n', longFieldLen) + }, + destination: { + address: genStr('o', keywordLen), + port: 80, + service: { + name: genStr('p', keywordLen), + resource: genStr('q', keywordLen), + type: genStr('r', keywordLen) + } + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client(clientOpts, function (client) { + client.sendSpan({ + id: 'abc123', + name: genStr('a', veryLong), + type: genStr('b', veryLong), + stacktrace: [ + { pre_context: [genStr('c', veryLong), genStr('d', veryLong)], context_line: genStr('e', veryLong), post_context: [genStr('f', veryLong), genStr('g', veryLong)] }, + { pre_context: [genStr('h', veryLong), genStr('i', veryLong)], context_line: genStr('j', veryLong), post_context: [genStr('k', veryLong), genStr('l', veryLong)] } + ], + context: { + custom: { + foo: genStr('m', veryLong) + }, + db: { + statement: genStr('n', veryLong) + }, + destination: { + address: genStr('o', veryLong), + port: 80, + service: { + name: genStr('p', veryLong), + resource: genStr('q', veryLong), + type: genStr('r', veryLong) + } + } + } + }) + client.flush(() => { client.destroy() }) + }) + }) + + test('truncate span custom keys', function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + span: { + id: 'abc123', + name: 'cool-name', + type: 'cool-type', + context: { + custom: { + [genStr('a', customKeyLen)]: 'truncate my key', + [genStr('b', customKeyLen)]: null + }, + db: { + statement: 'SELECT * FROM USERS' + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client(clientOpts, function (client) { + client.sendSpan({ + id: 'abc123', + name: 'cool-name', + type: 'cool-type', + context: { + custom: { + [genStr('a', veryLong)]: 'truncate my key', + [genStr('b', veryLong)]: null + }, + db: { + statement: 'SELECT * FROM USERS' + } + } + }) + client.flush(() => { client.destroy() }) + }) + }) + + test('truncate error', function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + error: { + id: 'abc123', + log: { + level: genStr('a', keywordLen), + logger_name: genStr('b', keywordLen), + message: genStr('c', errMsgLen), + param_message: genStr('d', keywordLen), + stacktrace: [ + { pre_context: [genStr('e', lineLen), genStr('f', lineLen)], context_line: genStr('g', lineLen), post_context: [genStr('h', lineLen), genStr('i', lineLen)] }, + { pre_context: [genStr('j', lineLen), genStr('k', lineLen)], context_line: genStr('l', lineLen), post_context: [genStr('m', lineLen), genStr('n', lineLen)] } + ] + }, + exception: { + message: genStr('o', errMsgLen), + type: genStr('p', keywordLen), + code: genStr('q', keywordLen), + module: genStr('r', keywordLen), + stacktrace: [ + { pre_context: [genStr('s', lineLen), genStr('t', lineLen)], context_line: genStr('u', lineLen), post_context: [genStr('v', lineLen), genStr('w', lineLen)] }, + { pre_context: [genStr('x', lineLen), genStr('y', lineLen)], context_line: genStr('z', lineLen), post_context: [genStr('A', lineLen), genStr('B', lineLen)] } + ] + }, + context: { + request: { + method: genStr('C', keywordLen), + url: { + protocol: genStr('D', keywordLen), + hostname: genStr('E', keywordLen), + port: genStr('F', keywordLen), + pathname: genStr('G', keywordLen), + search: genStr('H', keywordLen), + hash: genStr('I', keywordLen), + raw: genStr('J', keywordLen), + full: genStr('K', keywordLen) + } + }, + user: { + id: genStr('L', keywordLen), + email: genStr('M', keywordLen), + username: genStr('N', keywordLen) + }, + custom: { + foo: genStr('O', lineLen) + }, + tags: { + bar: genStr('P', keywordLen) + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client(clientOpts, function (client) { + client.sendError({ + id: 'abc123', + log: { + level: genStr('a', veryLong), + logger_name: genStr('b', veryLong), + message: genStr('c', veryLong), + param_message: genStr('d', veryLong), + stacktrace: [ + { pre_context: [genStr('e', veryLong), genStr('f', veryLong)], context_line: genStr('g', veryLong), post_context: [genStr('h', veryLong), genStr('i', veryLong)] }, + { pre_context: [genStr('j', veryLong), genStr('k', veryLong)], context_line: genStr('l', veryLong), post_context: [genStr('m', veryLong), genStr('n', veryLong)] } + ] + }, + exception: { + message: genStr('o', veryLong), + type: genStr('p', veryLong), + code: genStr('q', veryLong), + module: genStr('r', veryLong), + stacktrace: [ + { pre_context: [genStr('s', veryLong), genStr('t', veryLong)], context_line: genStr('u', veryLong), post_context: [genStr('v', veryLong), genStr('w', veryLong)] }, + { pre_context: [genStr('x', veryLong), genStr('y', veryLong)], context_line: genStr('z', veryLong), post_context: [genStr('A', veryLong), genStr('B', veryLong)] } + ] + }, + context: { + request: { + method: genStr('C', veryLong), + url: { + protocol: genStr('D', veryLong), + hostname: genStr('E', veryLong), + port: genStr('F', veryLong), + pathname: genStr('G', veryLong), + search: genStr('H', veryLong), + hash: genStr('I', veryLong), + raw: genStr('J', veryLong), + full: genStr('K', veryLong) + } + }, + user: { + id: genStr('L', veryLong), + email: genStr('M', veryLong), + username: genStr('N', veryLong) + }, + custom: { + foo: genStr('O', veryLong) + }, + tags: { + bar: genStr('P', veryLong) + } + } + }) + client.flush(() => { client.destroy() }) + }) + }) + + test('truncate metricset', function (t) { + t.plan(assertIntakeReq.asserts + assertMetadata.asserts + assertEvent.asserts) + const datas = [ + assertMetadata, + assertEvent({ + metricset: { + timestamp: 1496170422281000, + tags: { + foo: genStr('a', keywordLen) + }, + samples: { + metric_name: { + value: 4 + } + } + } + }) + ] + const server = APMServer(function (req, res) { + assertIntakeReq(t, req) + req = processIntakeReq(req) + req.on('data', function (obj) { + datas.shift()(t, obj) + }) + req.on('end', function () { + res.end() + server.close() + t.end() + }) + }).client(clientOpts, function (client) { + client.sendMetricSet({ + timestamp: 1496170422281000, + tags: { + foo: genStr('a', veryLong) + }, + samples: { + metric_name: { + value: 4 + } + } + }) + client.flush(() => { client.destroy() }) + }) + }) +}) + +function genStr (ch, length) { + return new Array(length + 1).join(ch) +} + +test('truncate cloud metadata', function (t) { + // tests that each cloud metadata field is truncated + // at `truncateKeywordsAt` values + const opts = { + truncateKeywordsAt: 100, + truncateStringsAt: 50 + } + + const longString = (new Array(500).fill('x').join('')) + const toTruncate = { + cloud: { + account: { + id: longString, + name: longString + }, + availability_zone: longString, + instance: { + id: longString, + name: longString + }, + machine: { + type: longString + }, + project: { + id: longString, + name: longString + }, + provider: longString, + region: longString + } + } + const { cloud } = truncate.metadata(toTruncate, opts) + + t.ok(cloud.account.id.length === 100, 'account.id.length was truncated') + t.ok(cloud.account.name.length === 100, 'account.name.length was truncated') + t.ok(cloud.availability_zone.length === 100, 'availability_zone was truncated') + t.ok(cloud.instance.id.length === 100, 'instance.id was truncated') + t.ok(cloud.instance.name.length === 100, 'instance.name was truncated') + t.ok(cloud.machine.type.length === 100, 'machine.type was truncated') + t.ok(cloud.project.id.length === 100, 'project.id was truncated') + t.ok(cloud.project.name.length === 100, 'project.name was truncated') + t.ok(cloud.provider.length === 100, 'provider was truncated') + t.ok(cloud.region.length === 100, 'region was truncated') + + t.end() +}) + +test('do not break surrogate pairs in truncation', function (t) { + const span = { + name: 'theSpan', + type: 'theType', + context: { + db: { + statement: 'foo🎉bar' + } + } + } + const truncateLongFieldsAt = 4 + const truncatedSpan = truncate.span(span, { truncateLongFieldsAt }) + t.ok(truncatedSpan.context.db.statement.length <= truncateLongFieldsAt, + 'context.db.statement was truncated') + t.equal(truncatedSpan.context.db.statement, 'foo', + 'context.db.statement was truncated without breaking a surrogate pair') + t.end() +}) diff --git a/test/apm-client/http-apm-client/writev.test.js b/test/apm-client/http-apm-client/writev.test.js new file mode 100644 index 0000000000..f639c58d8f --- /dev/null +++ b/test/apm-client/http-apm-client/writev.test.js @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const test = require('tape') +const utils = require('./lib/utils') + +const APMServer = utils.APMServer + +const dataTypes = ['span', 'transaction', 'error'] + +dataTypes.forEach(function (dataType) { + const sendFn = 'send' + dataType.charAt(0).toUpperCase() + dataType.substr(1) + + test(`bufferWindowSize - default value (${dataType})`, function (t) { + const server = APMServer().client(function (client) { + // Send one less span than bufferWindowSize + for (let n = 1; n <= 50; n++) { + client[sendFn]({ req: n }) + t.ok(client._writableState.corked, 'should be corked') + } + + // This span should trigger the uncork + client[sendFn]({ req: 51 }) + + // Wait a little to allow the above write to finish before destroying + process.nextTick(function () { + t.notOk(client._writableState.corked, 'should be uncorked') + + client.destroy() + server.close() + t.end() + }) + }) + }) + + test(`bufferWindowSize - custom value (${dataType})`, function (t) { + const server = APMServer().client({ bufferWindowSize: 5 }, function (client) { + // Send one less span than bufferWindowSize + for (let n = 1; n <= 5; n++) { + client[sendFn]({ req: n }) + t.ok(client._writableState.corked, 'should be corked') + } + + // This span should trigger the uncork + client[sendFn]({ req: 6 }) + + // Wait a little to allow the above write to finish before destroying + process.nextTick(function () { + t.notOk(client._writableState.corked, 'should be uncorked') + + client.destroy() + server.close() + t.end() + }) + }) + }) + + test(`bufferWindowTime - default value (${dataType})`, function (t) { + const server = APMServer().client(function (client) { + client[sendFn]({ req: 1 }) + t.ok(client._writableState.corked, 'should be corked') + + // Wait twice as long as bufferWindowTime + setTimeout(function () { + t.notOk(client._writableState.corked, 'should be uncorked') + client.destroy() + server.close() + t.end() + }, 40) + }) + }) + + test(`bufferWindowTime - custom value (${dataType})`, function (t) { + const server = APMServer().client({ bufferWindowTime: 150 }, function (client) { + client[sendFn]({ req: 1 }) + t.ok(client._writableState.corked, 'should be corked') + + // Wait twice as long as the default bufferWindowTime + setTimeout(function () { + t.ok(client._writableState.corked, 'should be corked') + }, 40) + + // Wait twice as long as the custom bufferWindowTime + setTimeout(function () { + t.notOk(client._writableState.corked, 'should be uncorked') + client.destroy() + server.close() + t.end() + }, 300) + }) + }) + + test(`write on destroyed (${dataType})`, function (t) { + const server = APMServer(function (req, res) { + t.fail('should not send anything to the APM Server') + }).client({ bufferWindowSize: 1, apmServerVersion: '8.0.0' }, function (client) { + client.on('error', function (err) { + t.error(err) + }) + + client[sendFn]({ req: 1 }) + client[sendFn]({ req: 2 }) + + // Destroy the client before the _writev function have a chance to be called + client.destroy() + + setTimeout(function () { + server.close() + t.end() + }, 10) + }) + }) +}) diff --git a/test/instrumentation/modules/http/outgoing.test.js b/test/instrumentation/modules/http/outgoing.test.js index 1c8273535c..476b4a8b69 100644 --- a/test/instrumentation/modules/http/outgoing.test.js +++ b/test/instrumentation/modules/http/outgoing.test.js @@ -184,7 +184,9 @@ test('http.request(..., bogusCb) errors on the bogusCb', { timeout: 5000 }, t => } tx.end() server.close() + return } + t.fail('should not get here, no err was thrown above') }) })