diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 3511a9e200..942fe81d71 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -38,6 +38,8 @@ Notes: * Add instrumentation of all AWS S3 methods when using the https://www.npmjs.com/package/aws-sdk[JavaScript AWS SDK v2] (`aws-sdk`). +* Add <> configuration option. This supports some + use cases using the APM agent **without** an APM server. ({issues}2101[#2101]) [float] ===== Bug fixes diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index 86f7287dcc..054448d412 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -148,6 +148,22 @@ See {kibana-ref}/filters.html#environment-selector[environment selector] in the NOTE: This feature is fully supported in the APM app in Kibana versions >= 7.2. You must use the query bar to filter for a specific environment in versions prior to 7.2. +[[disable-send]] +==== `disableSend` + +* *Type:* Boolean +* *Default:* `false` +* *Env:* `ELASTIC_APM_DISABLE_SEND` + +If set to `true`, the agent will work as usual, except for any task requiring +communication with the APM server. Events will be dropped and the agent won't be +able to receive central configuration, which means that any other configuration +cannot be changed in this state without restarting the service. Example uses +for this setting are: maintaining the ability to create traces and log +trace/transaction/span IDs through the log correlation feature, and getting +automatic distributed tracing via the https://w3c.github.io/trace-context/[W3C +HTTP headers]. + [[instrument]] ==== `instrument` diff --git a/index.d.ts b/index.d.ts index 34eb27dd32..38747629e5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -207,6 +207,7 @@ interface AgentConfigOptions { configFile?: string; containerId?: string; disableInstrumentations?: string | string[]; + disableSend?: boolean; environment?: string; errorMessageMaxLength?: string; // Also support `number`, but as we're removing this functionality soon, there's no need to advertise it errorOnAbortedRequests?: boolean; diff --git a/lib/agent.js b/lib/agent.js index 0877320d5c..8339fe66a9 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -196,7 +196,7 @@ Agent.prototype.start = function (opts) { pkg = require(path.join(basedir, 'package.json')) } catch (e) {} - this.logger.trace('agent configured correctly %o', { + this.logger.trace({ pid: process.pid, ppid: process.ppid, arch: process.arch, @@ -207,7 +207,7 @@ Agent.prototype.start = function (opts) { main: pkg ? pkg.main : '', dependencies: pkg ? pkg.dependencies : '', conf: this._conf.toJSON() - }) + }, 'agent configured correctly') } this._transport = this._conf.transport(this._conf, this) @@ -359,6 +359,14 @@ Agent.prototype.captureError = function (err, opts, cb) { opts = EMPTY_OPTS } + // Quick out if disableSend=true, no point in the processing time. + if (this._conf.disableSend) { + if (cb) { + process.nextTick(cb, null, errors.generateErrorId()) + } + return + } + const agent = this let callSiteLoc = null const errIsError = isError(err) diff --git a/lib/config.js b/lib/config.js index d63e43bddd..df018dd6e8 100644 --- a/lib/config.js +++ b/lib/config.js @@ -13,6 +13,7 @@ var packageName = require('../package').name const { WildcardMatcher } = require('./wildcard-matcher') const { CloudMetadata } = require('./cloud-metadata') +const { NoopTransport } = require('./noop-transport') // Standardize user-agent header. Only use "elasticapm-node" if it matches "elastic-apm-node". if (packageName === 'elastic-apm-node') { @@ -53,6 +54,7 @@ var DEFAULTS = { cloudProvider: 'auto', containerId: undefined, disableInstrumentations: [], + disableSend: false, environment: process.env.NODE_ENV || 'development', errorMessageMaxLength: '2kb', errorOnAbortedRequests: false, @@ -110,6 +112,7 @@ var ENV_TABLE = { containerId: 'ELASTIC_APM_CONTAINER_ID', cloudProvider: 'ELASTIC_APM_CLOUD_PROVIDER', disableInstrumentations: 'ELASTIC_APM_DISABLE_INSTRUMENTATIONS', + disableSend: 'ELASTIC_APM_DISABLE_SEND', environment: 'ELASTIC_APM_ENVIRONMENT', ignoreMessageQueues: 'ELASTIC_IGNORE_MESSAGE_QUEUES', errorMessageMaxLength: 'ELASTIC_APM_ERROR_MESSAGE_MAX_LENGTH', @@ -171,6 +174,7 @@ var BOOL_OPTS = [ 'captureHeaders', 'captureSpanStackTraces', 'centralConfig', + 'disableSend', 'errorOnAbortedRequests', 'filterHttpHeaders', 'instrument', @@ -264,7 +268,11 @@ class Config { normalize(this, this.logger) - if (typeof this.transport !== 'function') { + if (this.disableSend) { + this.transport = function createNoopTransport (conf, agent) { + return new NoopTransport() + } + } else if (typeof this.transport !== 'function') { this.transport = function httpTransport (conf, agent) { let clientLogger = null if (!logging.isLoggerCustom(agent.logger)) { @@ -399,9 +407,21 @@ class Config { logger: true, transport: true } + const NICE_REGEXPS_FIELDS = { + ignoreUrlRegExp: true, + ignoreUserAgentRegExp: true, + transactionIgnoreUrlRegExp: true, + sanitizeFieldNamesRegExp: true, + ignoreMessageQueuesRegExp: true + } const loggable = {} for (const k in this) { - if (!EXCLUDE_FIELDS[k] && this[k] !== undefined) { + if (EXCLUDE_FIELDS[k] || this[k] === undefined) { + // pass + } else if (NICE_REGEXPS_FIELDS[k] && Array.isArray(this[k])) { + // JSON.stringify() on a RegExp is "{}", which isn't very helpful. + loggable[k] = this[k].map(r => r instanceof RegExp ? r.toString() : r) + } else { loggable[k] = this[k] } } diff --git a/lib/errors.js b/lib/errors.js index b88831a4e0..e295d0a805 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -98,6 +98,10 @@ function attributesFromErr (err) { // ---- exports +function generateErrorId () { + return crypto.randomBytes(16).toString('hex') +} + // Create an "error" APM event object to be sent to APM server. // // Required args: @@ -130,7 +134,7 @@ function createAPMError (args, cb) { let numAsyncStepsRemaining = 0 // finish() will call cb() only when this is 0. const error = { - id: crypto.randomBytes(16).toString('hex'), + id: generateErrorId(), timestamp: args.timestampUs } if (args.traceContext) { @@ -260,6 +264,7 @@ function createAPMError (args, cb) { } module.exports = { + generateErrorId, createAPMError, // Exported for testing. diff --git a/lib/instrumentation/index.js b/lib/instrumentation/index.js index fa33f55c7b..127b2689ff 100644 --- a/lib/instrumentation/index.js +++ b/lib/instrumentation/index.js @@ -210,7 +210,12 @@ Instrumentation.prototype._patchModule = function (exports, name, version, enabl Instrumentation.prototype.addEndedTransaction = function (transaction) { var agent = this._agent - if (this._started) { + if (agent._conf.disableSend) { + // Save effort if disableSend=true. This one log.trace related to + // disableSend is included as a possible log hint to future debugging for + // why events are not being sent to APM server. + agent.logger.trace('disableSend: skip sendTransaction') + } else if (this._started) { var payload = agent._transactionFilters.process(transaction._encode()) if (!payload) return agent.logger.debug('transaction ignored by filter %o', { trans: transaction.id, trace: transaction.traceId }) agent.logger.debug('sending transaction %o', { trans: transaction.id, trace: transaction.traceId }) @@ -223,7 +228,9 @@ Instrumentation.prototype.addEndedTransaction = function (transaction) { Instrumentation.prototype.addEndedSpan = function (span) { var agent = this._agent - if (this._started) { + if (agent._conf.disableSend) { + // Save effort and logging if disableSend=true. + } else if (this._started) { agent.logger.debug('encoding span %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type }) span._encode(function (err, payload) { if (err) { diff --git a/lib/instrumentation/transaction.js b/lib/instrumentation/transaction.js index a2d146195f..4a690b65d3 100644 --- a/lib/instrumentation/transaction.js +++ b/lib/instrumentation/transaction.js @@ -287,11 +287,19 @@ Transaction.prototype._setOutcomeFromHttpStatusCode = function (statusCode) { } Transaction.prototype._captureBreakdown = function (span) { - if (this.ended) return + if (this.ended) { + return + } + const agent = this._agent const metrics = agent._metrics const conf = agent._conf + // Quick out if disableSend=true, no point in the processing time. + if (conf.disableSend) { + return + } + // Record span data if (this.sampled && conf.breakdownMetrics) { captureBreakdown(this, { diff --git a/lib/metrics/index.js b/lib/metrics/index.js index bc759a2e4d..ea20b83b5e 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -23,10 +23,11 @@ class Metrics { start (refTimers) { const metricsInterval = this[agentSymbol]._conf.metricsInterval + const enabled = metricsInterval !== 0 && !this[agentSymbol]._conf.disableSend this[registrySymbol] = new MetricsRegistry(this[agentSymbol], { reporterOptions: { defaultReportingIntervalInSeconds: metricsInterval, - enabled: metricsInterval !== 0, + enabled: enabled, unrefTimers: !refTimers, logger: new NoopLogger() } diff --git a/lib/noop-transport.js b/lib/noop-transport.js new file mode 100644 index 0000000000..501a6f971d --- /dev/null +++ b/lib/noop-transport.js @@ -0,0 +1,47 @@ +'use strict' + +// A no-op (does nothing) Agent transport -- i.e. the APM server client API +// provided by elastic-apm-http-client. This is used when `disableSend=true`. + +class NoopTransport { + config (opts) {} + + addMetadataFilter (fn) {} + + sendSpan (span, cb) { + if (cb) { + process.nextTick(cb) + } + } + + sendTransaction (transaction, cb) { + if (cb) { + process.nextTick(cb) + } + } + + sendError (_error, cb) { + if (cb) { + process.nextTick(cb) + } + } + + sendMetricSet (metricset, cb) { + if (cb) { + process.nextTick(cb) + } + } + + flush (cb) { + if (cb) { + process.nextTick(cb) + } + } + + // Inherited from Writable, called in agent.js. + destroy () {} +} + +module.exports = { + NoopTransport +} diff --git a/package.json b/package.json index e9eaa6efa9..d7ca901174 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/elastic/apm-agent-nodejs", "dependencies": { - "@elastic/ecs-pino-format": "^1.1.2", + "@elastic/ecs-pino-format": "^1.2.0", "after-all-results": "^2.0.0", "async-cache": "^1.1.0", "async-value-promise": "^1.1.1", diff --git a/test/config.js b/test/config.js index 1ffc197bd2..41a71fc55e 100644 --- a/test/config.js +++ b/test/config.js @@ -39,6 +39,7 @@ var optionFixtures = [ ['captureSpanStackTraces', 'CAPTURE_SPAN_STACK_TRACES', true], ['centralConfig', 'CENTRAL_CONFIG', true], ['containerId', 'CONTAINER_ID'], + ['disableSend', 'DISABLE_SEND', false], ['disableInstrumentations', 'DISABLE_INSTRUMENTATIONS', []], ['environment', 'ENVIRONMENT', 'development'], ['errorMessageMaxLength', 'ERROR_MESSAGE_MAX_LENGTH', 2048], diff --git a/test/disable-send.test.js b/test/disable-send.test.js new file mode 100644 index 0000000000..a06f414f11 --- /dev/null +++ b/test/disable-send.test.js @@ -0,0 +1,211 @@ +'use strict' + +// Test the behaviour of the agent configured with 'disableSend=true'. + +const { Writable } = require('stream') + +const ecsFormat = require('@elastic/ecs-pino-format') +let http // required below, *after* apm.start() +const pino = require('pino') +const tape = require('tape') + +const apm = require('../') +const { NoopTransport } = require('../lib/noop-transport') +const { MockAPMServer } = require('./_mock_apm_server') + +tape.test('disableSend', function (suite) { + let server + let serverUrl + const METRICS_INTERVAL_S = 1 + + suite.test('setup', function (t) { + server = new MockAPMServer() + server.start(function (serverUrl_) { + serverUrl = serverUrl_ + t.comment('mock APM serverUrl: ' + serverUrl) + apm.start({ + serverUrl, + serviceName: 'test-disable-send', + logLevel: 'off', + captureExceptions: false, + metricsInterval: `${METRICS_INTERVAL_S}s`, // Try to force getting metrics soon. + transport: function customTransport (conf, agent) { + throw new Error('custom transport should not get called with disableSend') + }, + disableSend: true + }) + t.comment('apm started') + + // Intentionally delay import of 'http' until after we've started the + // APM agent so HTTP requests are instrumented. + http = require('http') + + t.end() + }) + }) + + suite.test('transport should be NoopTransport if disableSend', function (t) { + t.ok(apm._transport instanceof NoopTransport, 'agent transport is NoopTransport') + t.end() + }) + + // Send a request: + // client -> serviceB -> serviceA + // and assert that: + // 1. the traceparent header automatically propagates the same trace_id + // between services B and A, and + // 2. log enrichment (adding trace.id et al) still works + suite.test('ensure distributed tracing and log enrichment still works', function (t) { + let headersA + let traceparentA + let traceparentB + + class LogCapturer extends Writable { + constructor (options) { + super(options) + this.chunks = [] + } + + _write (chunk, _encoding, cb) { + this.chunks.push(chunk) + cb() + } + + getLogRecords () { + return this.chunks + .join('') + .split('\n') + .filter(line => line.trim()) + .map(line => JSON.parse(line)) + } + } + const logStream = new LogCapturer() + + // `_elasticApm` is an internal/test-only option added in + // @elastic/ecs-pino-format@1.2.0 to allow passing in the import agent + // package that isn't `require`able at "elastic-apm-node". + const log = pino(ecsFormat({ _elasticApm: apm }), logStream) + const logA = log.child({ 'event.module': 'serviceA' }) + const logB = log.child({ 'event.module': 'serviceB' }) + + const serviceA = http.createServer(function (req, res) { + logA.info('handle request') + headersA = req.headers + traceparentA = apm.currentTraceparent + t.comment(`service A traceparent: ${traceparentA}`) + res.setHeader('Service', 'A') + res.end('the answer is 42') + }) + serviceA.listen(function () { + const urlA = 'http://localhost:' + serviceA.address().port + + const serviceB = http.createServer(function (req, res) { + logB.info('handle request') + traceparentB = apm.currentTraceparent + t.comment(`service B traceparent: ${traceparentB}`) + res.setHeader('Service', 'B') + http.get(urlA, function (resA) { + let bodyA = '' + resA.on('data', function (chunk) { + bodyA += chunk + }) + resA.on('end', function () { + res.setHeader('Service', 'B') + res.end('from service A: ' + bodyA) + }) + }) + }) + serviceB.listen(function () { + const urlB = 'http://localhost:' + serviceB.address().port + + log.info('send client request') + t.equal(apm.currentTraceparent, null, + 'there is no current traceparent before our client request') + http.get(urlB, function (resB) { + log.info('client got response') + let bodyB = '' + resB.on('data', function (chunk) { + bodyB += chunk + }) + resB.on('end', function () { + t.equal(bodyB, 'from service A: the answer is 42', + 'got expected body from client request') + const traceId = traceparentA.split('-')[1] + t.equal(traceId, traceparentB.split('-')[1], + 'the trace_id from apm.currentTraceparent in service A and B requests match') + t.equal(headersA.tracestate, 'es=s:1', + 'service A request got expected "tracestate" header') + + const recs = logStream.getLogRecords() + t.equal(recs[0].trace, undefined, + `log record 0 "${recs[0].message}" has no trace.id because trace has not yet started`) + t.equal(recs[1]['event.module'], 'serviceB', + `log record 1 "${recs[1].message}" is from serviceB`) + t.equal(recs[1].trace.id, traceId, + `log record 1 "${recs[1].message}" has trace.id set ${traceId}`) + t.equal(recs[2]['event.module'], 'serviceA', + `log record 2 "${recs[1].message}" is from serviceA`) + t.equal(recs[2].trace.id, traceId, + `log record 2 "${recs[2].message}" has trace.id set ${traceId}`) + + t.equal(server.events.length, 0, 'no events sent to APM server intake') + + serviceB.close() + serviceA.close() + t.end() + }) + }) + }) + }) + }) + + suite.test('transactions and spans are not sent to APM server', function (t) { + const tx = apm.startTransaction('mytx') + const s = tx.startSpan('myspan') + setImmediate(function () { + s.end() + tx.end() + apm.flush(function onFlushed () { + t.ok(tx.id, 'transaction has an id: ' + tx.id) + t.ok(s.id, 'span has an id: ' + s.id) + t.equal(server.events.length, 0, 'no events sent to APM server intake') + t.end() + }) + }) + }) + + suite.test('errors are not sent to APM server', function (t) { + const start = process.hrtime() + apm.captureError(new Error('myboom'), function (_, errId) { + const duration = process.hrtime(start) + + t.ok(errId, 'apm.captureError still calls back with an error id: ' + errId) + + // Test captureError speed as a proxy for testing that it avoids + // stacktrace collection when disableSend=true. It isn't a perfect way + // to test that. + const durationMs = duration[0] / 1e3 + duration[1] / 1e6 + const THRESHOLD_MS = 3 // Is this long enough for slow CI? + t.ok(durationMs < THRESHOLD_MS, `captureError is fast (<${THRESHOLD_MS}ms): ${durationMs}ms`) + + t.equal(server.events.length, 0, 'no events sent to APM server intake') + t.end() + }) + }) + + suite.test('metrics are not sent to APM server', function (t) { + setTimeout(function afterMetricsInterval () { + t.equal(server.events.length, 0, + 'after metricsInterval, no events sent to APM server intake') + t.end() + }, METRICS_INTERVAL_S * 1000) + }) + + suite.test('teardown', function (t) { + server.close() + t.end() + apm.destroy() + }) + + suite.end() +})