Skip to content

Commit 9dbded4

Browse files
committed
test: refactor to move knowledge for in-process Agent re-use into Agent methods
Move away from the test/_apm_server.js and test/_agent.js pattern that encapsulates internal details of Agent and Instrumentation to allow re-use of an Agent instance in-process in the same test file. The pattern from these test support files also makes leftover state from a test case an issue for *subsequent* test cases to deal with. Instead we add agent._testReset (and ins.testReset) to encapsulate these internal details. This also fixes a bug in agent.test.js where a new async-hook was being created and enabled for each Agent re-use. Cleanup was never disabling those async-hooks.
1 parent 0f28a16 commit 9dbded4

File tree

6 files changed

+1029
-784
lines changed

6 files changed

+1029
-784
lines changed

lib/agent.js

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,31 @@ function Agent () {
4646
this.lambda = elasticApmAwsLambda(this)
4747
}
4848

49+
// Reset the running state of this agent to a (relatively) clean state, to
50+
// allow re-`.start()` and re-use of this Agent within the same process for
51+
// testing. This is not supported outside of the test suite.
52+
//
53+
// Limitations: There are untracked async tasks (a) between span.end() and
54+
// and transport.sendSpan(); and (b) between agent.captureError() and
55+
// transport.sendError(). Currently there is no way to wait for completion of
56+
// those tasks in this method. There is code in each of ins.addEndedSpan and
57+
// agent.captureError to mitigate this.
58+
Agent.prototype._testReset = function () {
59+
this._errorFilters = new Filters()
60+
this._transactionFilters = new Filters()
61+
this._spanFilters = new Filters()
62+
if (this._transport && this._transport.destroy) {
63+
this._transport.destroy()
64+
}
65+
this._transport = null
66+
global[symbols.agentInitialized] = null // Mark as not yet started.
67+
if (this._uncaughtExceptionListener) {
68+
process.removeListener('uncaughtException', this._uncaughtExceptionListener)
69+
}
70+
this._metrics.stop()
71+
this._instrumentation.testReset()
72+
}
73+
4974
Object.defineProperty(Agent.prototype, 'currentTransaction', {
5075
get () {
5176
return this._instrumentation.currentTransaction
@@ -394,6 +419,12 @@ Agent.prototype.captureError = function (err, opts, cb) {
394419
span._setOutcomeFromErrorCapture(constants.OUTCOME_FAILURE)
395420
}
396421

422+
// Ensure an inflight `agent.captureError()` across a call to
423+
// `agent._testReset()` does not impact testing of the agent after that call
424+
// -- by using *current* values of _errorFilters and _transport.
425+
const errorFilters = agent._errorFilters
426+
const transport = agent._transport
427+
397428
// Move the remaining captureError processing to a later tick because:
398429
// 1. This allows the calling code to continue processing. For example, for
399430
// Express instrumentation this can significantly improve latency in
@@ -454,7 +485,7 @@ Agent.prototype.captureError = function (err, opts, cb) {
454485
// _err is always null from createAPMError.
455486
const id = apmError.id
456487

457-
apmError = agent._errorFilters.process(apmError)
488+
apmError = errorFilters.process(apmError)
458489
if (!apmError) {
459490
agent.logger.debug('error ignored by filter %o', { id })
460491
if (cb) {
@@ -463,9 +494,9 @@ Agent.prototype.captureError = function (err, opts, cb) {
463494
return
464495
}
465496

466-
if (agent._transport) {
497+
if (transport) {
467498
agent.logger.info('Sending error to Elastic APM: %o', { id })
468-
agent._transport.sendError(apmError, function () {
499+
transport.sendError(apmError, function () {
469500
agent.flush(function (flushErr) {
470501
if (cb) {
471502
cb(flushErr, id)

lib/instrumentation/async-hooks.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,12 @@
33
const asyncHooks = require('async_hooks')
44
const shimmer = require('./shimmer')
55

6-
// FOR INTERNAL TESTING PURPOSES ONLY!
7-
const resettable = process.env._ELASTIC_APM_ASYNC_HOOKS_RESETTABLE === 'true'
8-
let _asyncHook
9-
106
module.exports = function (ins) {
117
const asyncHook = asyncHooks.createHook({ init, before, destroy })
12-
const contexts = new WeakMap()
13-
14-
if (resettable) {
15-
if (_asyncHook) _asyncHook.disable()
16-
_asyncHook = asyncHook
17-
}
8+
let activeSpans = new Map()
9+
let activeTransactions = new Map()
10+
let contexts = new WeakMap()
1811

19-
const activeTransactions = new Map()
2012
Object.defineProperty(ins, 'currentTransaction', {
2113
get () {
2214
const asyncId = asyncHooks.executionAsyncId()
@@ -32,7 +24,6 @@ module.exports = function (ins) {
3224
}
3325
})
3426

35-
const activeSpans = new Map()
3627
Object.defineProperty(ins, 'activeSpan', {
3728
get () {
3829
const asyncId = asyncHooks.executionAsyncId()
@@ -63,6 +54,18 @@ module.exports = function (ins) {
6354
}
6455
})
6556

57+
shimmer.wrap(ins, 'testReset', function (origTestReset) {
58+
return function wrappedTestReset () {
59+
asyncHook.disable()
60+
activeTransactions = new Map()
61+
activeSpans = new Map()
62+
contexts = new WeakMap()
63+
shimmer.unwrap(ins, 'addEndedTransaction')
64+
shimmer.unwrap(ins, 'testReset')
65+
return origTestReset.call(this)
66+
}
67+
})
68+
6669
asyncHook.enable()
6770

6871
function init (asyncId, type, triggerAsyncId, resource) {

lib/instrumentation/index.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,27 @@ function Instrumentation (agent) {
8686
}
8787
}
8888

89+
// Reset internal context tracking state for (relatively) clean re-use of this
90+
// Instrumentation in the same process. Used for testing.
91+
//
92+
// Limitations: Removing and re-applying 'require-in-the-middle'-based patches
93+
// has no way to update user or test code that already has references to
94+
// patched or unpatched exports from those modules. That may mean some
95+
// automatic instrumentation may not work.
96+
Instrumentation.prototype.testReset = function () {
97+
// Reset context tracking.
98+
this.currentTransaction = null
99+
this.bindingSpan = null
100+
this.activeSpan = null
101+
102+
// Reset patching.
103+
this._started = false
104+
if (this._hook) {
105+
this._hook.unhook()
106+
this._hook = null
107+
}
108+
}
109+
89110
Object.defineProperty(Instrumentation.prototype, 'ids', {
90111
get () {
91112
const current = this.currentSpan || this.currentTransaction
@@ -232,21 +253,30 @@ Instrumentation.prototype.addEndedSpan = function (span) {
232253
// Save effort and logging if disableSend=true.
233254
} else if (this._started) {
234255
agent.logger.debug('encoding span %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type })
256+
257+
// Ensure an inflight `span._encode()` across a call to `agent._testReset()`
258+
// does not impact testing of the agent after that call -- by using
259+
// *current* values of _spanFilters and _transport.
260+
const spanFilters = agent._spanFilters
261+
const transport = agent._transport
262+
235263
span._encode(function (err, payload) {
236264
if (err) {
237265
agent.logger.error('error encoding span %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type, error: err.message })
238266
return
239267
}
240268

241-
payload = agent._spanFilters.process(payload)
269+
payload = spanFilters.process(payload)
242270

243271
if (!payload) {
244272
agent.logger.debug('span ignored by filter %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type })
245273
return
246274
}
247275

248276
agent.logger.debug('sending span %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type })
249-
if (agent._transport) agent._transport.sendSpan(payload)
277+
if (transport) {
278+
transport.sendSpan(payload)
279+
}
250280
})
251281
} else {
252282
agent.logger.debug('ignoring span %o', { span: span.id, parent: span.parentId, trace: span.traceId, name: span.name, type: span.type })

test/_mock_apm_server.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
// // Test code using `serverUrl`...
77
// // - Events received on the intake API will be on `server.events`.
88
// // - Raw request data is on `server.requests`.
9+
// // - Use `server.clear()` to clear `server.events` and `server.requests`
10+
// // for re-use of the mock server in multiple test cases.
911
// // - Call `server.close()` when done.
1012
// })
1113

@@ -15,12 +17,16 @@ const zlib = require('zlib')
1517

1618
class MockAPMServer {
1719
constructor () {
18-
this.events = []
19-
this.requests = []
20+
this.clear()
2021
this.serverUrl = null // set in .start()
2122
this._http = http.createServer(this._onRequest.bind(this))
2223
}
2324

25+
clear () {
26+
this.events = []
27+
this.requests = []
28+
}
29+
2430
_onRequest (req, res) {
2531
var parsedUrl = new URL(req.url, this.serverUrl)
2632
var instream = req

0 commit comments

Comments
 (0)