Skip to content

Commit 5e90183

Browse files
committed
fix(core): retire finished spans without losing context
Long-lived async resources can retain finished middleware spans and their parent chains after export, making retained heap grow with request count. The original span remains active until serialization and response listeners complete. Retained resources then keep a compact propagation facade, so late work preserves trace and baggage context without pinning the span graph. Fixes: #9227
1 parent 0b8ae4b commit 5e90183

26 files changed

Lines changed: 1251 additions & 76 deletions

File tree

‎.github/CODEOWNERS‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@
247247
/packages/dd-trace/test/opentracing/ @DataDog/apm-sdk-capabilities-js
248248
/packages/dd-trace/src/remote_config/ @DataDog/apm-sdk-capabilities-js
249249
/packages/dd-trace/test/remote_config/ @DataDog/apm-sdk-capabilities-js
250+
/packages/dd-trace/src/active-span.js @DataDog/apm-sdk-capabilities-js
251+
/packages/dd-trace/test/active-span.spec.js @DataDog/apm-sdk-capabilities-js
250252
/packages/dd-trace/src/baggage.js @DataDog/apm-sdk-capabilities-js
251253
/packages/dd-trace/test/baggage.spec.js @DataDog/apm-sdk-capabilities-js
252254
/packages/dd-trace/src/sampler.js @DataDog/apm-sdk-capabilities-js

‎packages/datadog-core/src/storage.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
const { AsyncLocalStorage } = require('async_hooks')
44

5+
const kStoreRetirement = Symbol('dd-trace.store-retirement')
6+
57
/**
68
* `AsyncLocalStorage` with a `getHandle()` escape hatch: a span stashes the
79
* active handle at creation (see opentracing/span.js) so a later context can
@@ -19,6 +21,15 @@ const { AsyncLocalStorage } = require('async_hooks')
1921
* @typedef {Record<string, T>} Store
2022
*/
2123
class DatadogStorage extends AsyncLocalStorage {
24+
/**
25+
* @param {Store<unknown>} [store]
26+
* @override
27+
*/
28+
enterWith (store) {
29+
store?.[kStoreRetirement]?.add(store)
30+
return super.enterWith(store)
31+
}
32+
2233
/**
2334
* Passthrough to the real `getStore()`. A span stashes this handle and feeds
2435
* it back to `getStore(handle)` later. Identical in both modes: under ACF the
@@ -53,6 +64,7 @@ const isACFActive = (() => {
5364
return active
5465
})()
5566

67+
/* istanbul ignore if: legacy async_hooks backend is covered on supported Node releases without ACF */
5668
if (!isACFActive) {
5769
const superGetStore = AsyncLocalStorage.prototype.getStore
5870
const superEnterWith = AsyncLocalStorage.prototype.enterWith
@@ -66,6 +78,7 @@ if (!isACFActive) {
6678
* @param {Store<unknown>} [store]
6779
*/
6880
DatadogStorage.prototype.enterWith = function enterWith (store) {
81+
store?.[kStoreRetirement]?.add(store)
6982
const handle = { noop: store?.noop }
7083
stores.set(handle, store)
7184
superEnterWith.call(this, handle)
@@ -128,4 +141,4 @@ function storage (namespace) {
128141
return storages[namespace]
129142
}
130143

131-
module.exports = { storage, isACFActive }
144+
module.exports = { storage, isACFActive, kStoreRetirement }

‎packages/datadog-core/test/storage.spec.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { executionAsyncResource } = require('async_hooks')
66
const { describe, it, beforeEach, afterEach } = require('mocha')
77

88
require('../../dd-trace/test/setup/core')
9-
const { storage } = require('../src/storage')
9+
const { kStoreRetirement, storage } = require('../src/storage')
1010

1111
describe('storage', () => {
1212
let testStorage
@@ -51,6 +51,21 @@ describe('storage', () => {
5151
assert.strictEqual(storage('test'), testStorage)
5252
})
5353

54+
it('should register entered stores with their retirement group', () => {
55+
const entered = []
56+
const store = {
57+
[kStoreRetirement]: {
58+
add (value) {
59+
entered.push(value)
60+
},
61+
},
62+
}
63+
64+
testStorage.enterWith(store)
65+
66+
assert.deepStrictEqual(entered, [store])
67+
})
68+
5469
it('should not have its store referenced by the underlying async resource', () => {
5570
const resource = executionAsyncResource()
5671

‎packages/datadog-instrumentations/src/http/server.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const startServerCh = channel('apm:http:server:request:start')
1010
const exitServerCh = channel('apm:http:server:request:exit')
1111
const errorServerCh = channel('apm:http:server:request:error')
1212
const finishServerCh = channel('apm:http:server:request:finish')
13+
const postFinishServerCh = channel('apm:http:server:request:postfinish')
1314
const startWriteHeadCh = channel('apm:http:server:response:writeHead:start')
1415
const finishSetHeaderCh = channel('datadog:http:server:response:set-header:finish')
1516
const startSetHeaderCh = channel('datadog:http:server:response:set-header:start')
@@ -52,14 +53,20 @@ function wrapResponseEmit (originalEmit) {
5253
// skips its name rewrite; rest params keep the per-event forwarding
5354
// allocation-free.
5455
return function emit (...args) {
55-
if (!finishServerCh.hasSubscribers) {
56+
if (!finishServerCh.hasSubscribers && !postFinishServerCh.hasSubscribers) {
5657
return Reflect.apply(originalEmit, this, args)
5758
}
5859

5960
const eventName = args[0]
6061
if ((eventName === 'finish' || eventName === 'close') && !requestFinishedSet.has(this)) {
61-
finishServerCh.publish({ req: this.req })
62+
const ctx = { req: this.req }
63+
finishServerCh.publish(ctx)
6264
requestFinishedSet.add(this)
65+
try {
66+
return Reflect.apply(originalEmit, this, args)
67+
} finally {
68+
postFinishServerCh.publish(ctx)
69+
}
6370
}
6471

6572
return Reflect.apply(originalEmit, this, args)

‎packages/datadog-instrumentations/src/light-my-request.js‎

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const startServerCh = channel('apm:http:server:request:start')
2222
const exitServerCh = channel('apm:http:server:request:exit')
2323
const errorServerCh = channel('apm:http:server:request:error')
2424
const finishServerCh = channel('apm:http:server:request:finish')
25+
const postFinishServerCh = channel('apm:http:server:request:postfinish')
2526

2627
addHook({ name: 'light-my-request', versions: ['>=3'] }, (lightMyRequest) => {
2728
// Wrap the inject function
@@ -51,29 +52,22 @@ function wrapDispatchFunc (dispatchFunc) {
5152
// Publish start event (same as HTTP server)
5253
startServerCh.publish({ req, res, abortController })
5354

54-
// Track when response finishes via 'finish' event (like HTTP instrumentation)
5555
let finishCalled = false
56-
const onFinish = () => {
57-
if (!finishCalled) {
58-
finishCalled = true
59-
finishServerCh.publish({ req })
60-
}
61-
}
62-
63-
// light-my-request Response emits 'finish' when done
64-
if (res.on && typeof res.on === 'function') {
65-
res.once('finish', onFinish)
66-
res.once('close', onFinish)
67-
}
68-
69-
// Also wrap end() as fallback
70-
const originalEnd = res.end
71-
if (originalEnd) {
72-
res.end = function wrappedEnd (...args) {
73-
const result = originalEnd.apply(this, args)
74-
// Trigger finish if events don't fire
75-
setImmediate(onFinish)
76-
return result
56+
const originalEmit = res.emit
57+
if (typeof originalEmit === 'function') {
58+
res.emit = function emit (...args) {
59+
const eventName = args[0]
60+
if ((eventName === 'finish' || eventName === 'close') && !finishCalled) {
61+
finishCalled = true
62+
const ctx = { req }
63+
finishServerCh.publish(ctx)
64+
try {
65+
return Reflect.apply(originalEmit, this, args)
66+
} finally {
67+
postFinishServerCh.publish(ctx)
68+
}
69+
}
70+
return Reflect.apply(originalEmit, this, args)
7771
}
7872
}
7973

‎packages/datadog-instrumentations/test/http.spec.js‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ const sinon = require('sinon')
1010
const agent = require('../../dd-trace/test/plugins/agent')
1111

1212
describe('client', () => {
13-
let url, http, startChannelCb, endChannelCb, asyncStartChannelCb, errorChannelCb
13+
let tracer, url, http, startChannelCb, endChannelCb, asyncStartChannelCb, errorChannelCb
1414

1515
const startChannel = dc.channel('apm:http:client:request:start')
1616
const endChannel = dc.channel('apm:http:client:request:end')
1717
const asyncStartChannel = dc.channel('apm:http:client:request:asyncStart')
1818
const errorChannel = dc.channel('apm:http:client:request:error')
1919
const responseFinishChannel = dc.channel('apm:http:client:response:finish')
2020
const responseStartChannel = dc.channel('apm:http:client:response:start')
21+
const serverFinishChannel = dc.channel('apm:http:server:request:finish')
22+
const serverPostFinishChannel = dc.channel('apm:http:server:request:postfinish')
2123

2224
before(async () => {
23-
await agent.load('http')
25+
tracer = await agent.load('http')
2426
})
2527

2628
after(() => {
@@ -291,6 +293,60 @@ describe('client', () => {
291293
const describeIfHttp = httpSchema === 'http' ? describe : describe.skip
292294

293295
describeIfHttp('with local http server', () => {
296+
it('keeps response events unchanged without lifecycle subscribers', async () => {
297+
tracer.use('http', false)
298+
let finished = false
299+
const server = http.createServer((req, res) => {
300+
res.on('finish', () => {
301+
finished = true
302+
})
303+
res.end()
304+
})
305+
306+
try {
307+
await new Promise(resolve => server.listen(0, resolve))
308+
await new Promise((resolve, reject) => {
309+
http.get(`http://127.0.0.1:${server.address().port}/`, res => {
310+
res.resume()
311+
res.on('end', resolve)
312+
}).on('error', reject)
313+
})
314+
} finally {
315+
await new Promise(resolve => server.close(resolve))
316+
tracer.use('http', {})
317+
}
318+
319+
assert.strictEqual(finished, true)
320+
})
321+
322+
it('publishes server postfinish after response listeners', async () => {
323+
const calls = []
324+
const onFinish = () => calls.push('finish')
325+
const onPostFinish = () => calls.push('postfinish')
326+
serverFinishChannel.subscribe(onFinish)
327+
serverPostFinishChannel.subscribe(onPostFinish)
328+
const server = http.createServer((req, res) => {
329+
res.on('finish', () => calls.push('listener'))
330+
res.end()
331+
})
332+
333+
try {
334+
await new Promise(resolve => server.listen(0, resolve))
335+
await new Promise((resolve, reject) => {
336+
http.get(`http://127.0.0.1:${server.address().port}/`, res => {
337+
res.resume()
338+
res.on('end', resolve)
339+
}).on('error', reject)
340+
})
341+
} finally {
342+
serverFinishChannel.unsubscribe(onFinish)
343+
serverPostFinishChannel.unsubscribe(onPostFinish)
344+
await new Promise(resolve => server.close(resolve))
345+
}
346+
347+
assert.deepStrictEqual(calls, ['finish', 'listener', 'postfinish'])
348+
})
349+
294350
function requestWithLocalServer ({ responseHeaders, responseBody, onResponse }, done) {
295351
const server = http.createServer((req, res) => {
296352
res.writeHead(200, responseHeaders)

‎packages/datadog-instrumentations/test/light-my-request.spec.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ withVersions('light-my-request', 'light-my-request', version => describe('light-
1212
const startServerCh = dc.channel('apm:http:server:request:start')
1313
const exitServerCh = dc.channel('apm:http:server:request:exit')
1414
const finishServerCh = dc.channel('apm:http:server:request:finish')
15+
const postFinishServerCh = dc.channel('apm:http:server:request:postfinish')
1516
const errorServerCh = dc.channel('apm:http:server:request:error')
1617

17-
let startStub, exitStub, finishStub, errorStub
18+
let startStub, exitStub, finishStub, postFinishStub, errorStub
1819
let inject, Fastify
1920

2021
before(async () => {
@@ -31,18 +32,21 @@ withVersions('light-my-request', 'light-my-request', version => describe('light-
3132
startStub = sinon.stub()
3233
exitStub = sinon.stub()
3334
finishStub = sinon.stub()
35+
postFinishStub = sinon.stub()
3436
errorStub = sinon.stub()
3537

3638
startServerCh.subscribe(startStub)
3739
exitServerCh.subscribe(exitStub)
3840
finishServerCh.subscribe(finishStub)
41+
postFinishServerCh.subscribe(postFinishStub)
3942
errorServerCh.subscribe(errorStub)
4043
})
4144

4245
afterEach(() => {
4346
startServerCh.unsubscribe(startStub)
4447
exitServerCh.unsubscribe(exitStub)
4548
finishServerCh.unsubscribe(finishStub)
49+
postFinishServerCh.unsubscribe(postFinishStub)
4650
errorServerCh.unsubscribe(errorStub)
4751
})
4852

@@ -102,7 +106,7 @@ withVersions('light-my-request', 'light-my-request', version => describe('light-
102106
assert(testCall, 'exit channel should be called for /test request')
103107
})
104108

105-
it('should publish to finish channel when response completes', async () => {
109+
it('should publish finish and postfinish when the response completes', async () => {
106110
await app.inject({
107111
method: 'GET',
108112
url: '/test',
@@ -119,6 +123,13 @@ withVersions('light-my-request', 'light-my-request', version => describe('light-
119123
})
120124

121125
assert(testCall, 'finish channel should be called for /test request')
126+
const postFinishCall = postFinishStub.getCalls().find(call => {
127+
const { req } = call.args[0]
128+
return req.url === '/test'
129+
})
130+
131+
assert(postFinishCall, 'postfinish channel should be called for /test request')
132+
assert(testCall.calledBefore(postFinishCall))
122133
})
123134

124135
it('should link res.req for context tracking', async () => {

‎packages/datadog-plugin-google-cloud-pubsub/src/pubsub-push-subscription.js‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
4+
const { createStoreRetirement, enterSpanForRetirement } = require('../../dd-trace/src/active-span')
45
const SpanContext = require('../../dd-trace/src/opentracing/span_context')
56
const id = require('../../dd-trace/src/id')
67
const log = require('../../dd-trace/src/log')
@@ -32,12 +33,28 @@ class GoogleCloudPubsubPushSubscriptionPlugin extends TracingPlugin {
3233
this.addSub('apm:http:server:request:finish', ({ req }) => {
3334
this.#finishPushReceiveSpan(req)
3435
})
36+
this.addSub('apm:http:server:request:postfinish', ({ req }) => {
37+
this.#retirePushReceiveSpan(req)
38+
})
3539
}
3640

41+
/**
42+
* @param {import('node:http').IncomingMessage} req
43+
*/
3744
#finishPushReceiveSpan (req) {
38-
const pushReceiveSpan = pushReceiveSpans.get(req)
39-
if (pushReceiveSpan && !pushReceiveSpan._duration) {
40-
pushReceiveSpan.finish()
45+
const entry = pushReceiveSpans.get(req)
46+
if (entry && !entry.span._duration) {
47+
entry.span.finish()
48+
}
49+
}
50+
51+
/**
52+
* @param {import('node:http').IncomingMessage} req
53+
*/
54+
#retirePushReceiveSpan (req) {
55+
const entry = pushReceiveSpans.get(req)
56+
if (entry) {
57+
entry.retirement.retire()
4158
pushReceiveSpans.delete(req)
4259
}
4360
}
@@ -61,6 +78,9 @@ class GoogleCloudPubsubPushSubscriptionPlugin extends TracingPlugin {
6178
return false
6279
}
6380

81+
/**
82+
* @param {{ req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse }} ctx
83+
*/
6484
#createPushReceiveSpanAndActivate (ctx) {
6585
const { req, res } = ctx
6686
const messageData = this.#parseMessage(req)
@@ -90,8 +110,9 @@ class GoogleCloudPubsubPushSubscriptionPlugin extends TracingPlugin {
90110
return
91111
}
92112

93-
this.enter(pushReceiveSpan, { req, res })
94-
pushReceiveSpans.set(req, pushReceiveSpan)
113+
const retirement = createStoreRetirement(pushReceiveSpan.context())
114+
enterSpanForRetirement(pushReceiveSpan, { req, res }, retirement)
115+
pushReceiveSpans.set(req, { span: pushReceiveSpan, retirement })
95116
}
96117

97118
#parseMessage (req) {

0 commit comments

Comments
 (0)