Skip to content

Commit 695415b

Browse files
Joe-DeglerPatrick Housley
and
Patrick Housley
authored
fix: Fix adblock memory leak (#877)
Co-authored-by: Patrick Housley <[email protected]>
1 parent eb76ad2 commit 695415b

File tree

13 files changed

+205
-23
lines changed

13 files changed

+205
-23
lines changed

src/common/drain/drain.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ function curateRegistry (agentIdentifier) {
4141
* its own named group explicitly, when ready.
4242
* @param {string} agentIdentifier - A unique 16 character ID corresponding to an instantiated agent.
4343
* @param {string} featureName - A named group into which the feature's buffered events are bucketed.
44+
* @param {boolean} force - Whether to force the drain to occur immediately, bypassing the registry and staging logic.
4445
*/
45-
export function drain (agentIdentifier = '', featureName = 'feature') {
46+
export function drain (agentIdentifier = '', featureName = 'feature', force = false) {
4647
curateRegistry(agentIdentifier)
4748
// If the feature for the specified agent is not in the registry, that means the instrument file was bypassed.
4849
// This could happen in tests, or loaders that directly import the aggregator. In these cases it is safe to
4950
// drain the feature group immediately rather than waiting to drain all at once.
50-
if (!agentIdentifier || !registry[agentIdentifier].get(featureName)) return drainGroup(featureName)
51+
if (!agentIdentifier || !registry[agentIdentifier].get(featureName) || force) return drainGroup(featureName)
5152

5253
// When `drain` is called, this feature is ready to drain (staged).
5354
registry[agentIdentifier].get(featureName).staged = true

src/common/drain/drain.test.js

+9
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ describe('drain', () => {
4646
expect(emitSpy).toHaveBeenCalledWith('drain-pumbaa', expect.anything())
4747
})
4848

49+
test('immediately drains when force is true', () => {
50+
registerDrain('abcd', 'page_view_timing')
51+
registerDrain('abcd', 'page_view_event')
52+
53+
let emitSpy = jest.spyOn(ee.get('abcd'), 'emit')
54+
drain('abcd', 'page_view_event', true)
55+
expect(emitSpy).toHaveBeenNthCalledWith(1, 'drain-page_view_event', expect.anything())
56+
})
57+
4958
test('defaults to "feature" group when not provided one', () => {
5059
let emitSpy = jest.spyOn(ee.get('abcd'), 'emit')
5160
drain('abcd')

src/common/event-emitter/contextual-ee.component-test.js

+13
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ describe('event-emitter buffer', () => {
143143
expect(ee.isBuffering(eventType)).toEqual(false)
144144
})
145145

146+
test('it should empty the backlog on abort as opposed to replacing it', async () => {
147+
const { ee } = await import('./contextual-ee')
148+
149+
const backlog = {
150+
api: ['foo', 'bar', 'baz']
151+
}
152+
ee.backlog = backlog
153+
ee.abort()
154+
155+
expect(ee.backlog).toBe(backlog)
156+
expect(ee.backlog).toEqual({})
157+
})
158+
146159
test('it should not buffer after drain', async () => {
147160
const { ee } = await import('./contextual-ee')
148161
const { drain } = await import('../drain/drain')

src/common/event-emitter/contextual-ee.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,11 @@ function ee (old, debugId) {
144144

145145
function abort () {
146146
globalInstance.aborted = true
147-
globalInstance.backlog = {}
147+
// The global backlog can be referenced directly by other emitters,
148+
// so we need to delete its contents as opposed to replacing it.
149+
// Otherwise, these references to the old backlog would still exist
150+
// and the keys will not be garbage collected.
151+
Object.keys(globalInstance.backlog).forEach(key => {
152+
delete globalInstance.backlog[key]
153+
})
148154
}

src/common/harvest/harvest.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,10 @@ export class Harvest extends SharedContext {
142142

143143
if (!opts.unload && cbFinished && submitMethod === submitData.xhr) {
144144
const harvestScope = this
145-
result.addEventListener('load', function () {
145+
result.addEventListener('loadend', function () {
146146
// `this` refers to the XHR object in this scope, do not change this to a fat arrow
147-
const cbResult = { sent: true, status: this.status }
147+
// status 0 refers to a local error, such as CORS or network failure, or a blocked request by the browser (e.g. adblocker)
148+
const cbResult = { sent: this.status !== 0, status: this.status }
148149
if (this.status === 429) {
149150
cbResult.retry = true
150151
cbResult.delay = harvestScope.tooManyRequestsDelay

src/common/harvest/harvest.test.js

+37-15
Original file line numberDiff line numberDiff line change
@@ -358,14 +358,14 @@ describe('_send', () => {
358358

359359
const result = harvestInstance._send(spec)
360360
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
361-
const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
361+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
362362

363363
const xhrState = {
364364
status: faker.string.uuid()
365365
}
366-
xhrLoadHandler.call(xhrState)
366+
xhrLoadEndHandler.call(xhrState)
367367

368-
expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object))
368+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
369369
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
370370
expect(submitMethod).not.toHaveBeenCalled()
371371
expect(spec.cbFinished).toHaveBeenCalledWith({ ...xhrState, sent: true })
@@ -378,14 +378,14 @@ describe('_send', () => {
378378

379379
const result = harvestInstance._send(spec)
380380
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
381-
const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
381+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
382382

383383
const xhrState = {
384384
status: 429
385385
}
386-
xhrLoadHandler.call(xhrState)
386+
xhrLoadEndHandler.call(xhrState)
387387

388-
expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object))
388+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
389389
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
390390
expect(submitMethod).not.toHaveBeenCalled()
391391
expect(spec.cbFinished).toHaveBeenCalledWith({
@@ -404,14 +404,14 @@ describe('_send', () => {
404404

405405
const result = harvestInstance._send(spec)
406406
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
407-
const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
407+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
408408

409409
const xhrState = {
410410
status: statusCode
411411
}
412-
xhrLoadHandler.call(xhrState)
412+
xhrLoadEndHandler.call(xhrState)
413413

414-
expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object))
414+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
415415
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
416416
expect(submitMethod).not.toHaveBeenCalled()
417417
expect(spec.cbFinished).toHaveBeenCalledWith({
@@ -428,15 +428,15 @@ describe('_send', () => {
428428

429429
const result = harvestInstance._send(spec)
430430
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
431-
const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
431+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
432432

433433
const xhrState = {
434434
status: faker.string.uuid(),
435435
responseText: faker.lorem.sentence()
436436
}
437-
xhrLoadHandler.call(xhrState)
437+
xhrLoadEndHandler.call(xhrState)
438438

439-
expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object))
439+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
440440
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
441441
expect(submitMethod).not.toHaveBeenCalled()
442442
expect(spec.cbFinished).toHaveBeenCalledWith({
@@ -452,15 +452,15 @@ describe('_send', () => {
452452

453453
const result = harvestInstance._send(spec)
454454
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
455-
const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
455+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
456456

457457
const xhrState = {
458458
status: faker.string.uuid(),
459459
responseText: faker.lorem.sentence()
460460
}
461-
xhrLoadHandler.call(xhrState)
461+
xhrLoadEndHandler.call(xhrState)
462462

463-
expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object))
463+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
464464
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
465465
expect(submitMethod).not.toHaveBeenCalled()
466466
expect(spec.cbFinished).toHaveBeenCalledWith({
@@ -469,6 +469,28 @@ describe('_send', () => {
469469
sent: true
470470
})
471471
})
472+
473+
test('should call cbFinished with sent false and status 0 when xhr failed locally', () => {
474+
jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr)
475+
spec.cbFinished = jest.fn()
476+
477+
const result = harvestInstance._send(spec)
478+
const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener
479+
const xhrLoadEndHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1]
480+
481+
const xhrState = {
482+
status: 0
483+
}
484+
xhrLoadEndHandler.call(xhrState)
485+
486+
expect(xhrAddEventListener).toHaveBeenCalledWith('loadend', expect.any(Function), expect.any(Object))
487+
expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value)
488+
expect(submitMethod).not.toHaveBeenCalled()
489+
expect(spec.cbFinished).toHaveBeenCalledWith({
490+
...xhrState,
491+
sent: false
492+
})
493+
})
472494
})
473495

474496
describe('obfuscateAndSend', () => {

src/features/page_view_event/aggregate/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class Aggregate extends AggregateBase {
101101
payload: { qs: queryParameters, body },
102102
opts: { needResponse: true, sendEmptyBody: true },
103103
cbFinished: ({ status, responseText }) => {
104-
if (status >= 400) {
104+
if (status >= 400 || status === 0) {
105105
// Adding retry logic for the rum call will be a separate change
106106
this.ee.abort()
107107
return

src/features/utils/instrument-base.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export class InstrumentBase extends FeatureBase {
108108
warn(`Downloading and initializing ${this.featureName} failed...`, e)
109109
this.abortHandler?.() // undo any important alterations made to the page
110110
// not supported yet but nice to do: "abort" this agent's EE for this feature specifically
111-
drain(this.agentIdentifier, this.featureName)
111+
drain(this.agentIdentifier, this.featureName, true)
112112
loadedSuccessfully(false)
113113
}
114114
}

src/features/utils/instrument-base.test.js

+1
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ test('no uncaught async exception is thrown when an import fails', async () => {
211211

212212
expect(warn).toHaveBeenNthCalledWith(2, expect.stringContaining(`Downloading and initializing ${featureName} failed`), expect.any(Error))
213213
expect(instrument.abortHandler).toHaveBeenCalled()
214+
expect(drain).toHaveBeenCalledWith(agentIdentifier, featureName, true)
214215
await expect(instrument.onAggregateImported).resolves.toBe(false)
215216
expect(mockOnError).not.toHaveBeenCalled()
216217
})

src/loaders/api/api.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,10 @@ export function setAPI (agentIdentifier, forceDrain) {
207207
import(/* webpackChunkName: "async-api" */'./apiAsync').then(({ setAPI }) => {
208208
setAPI(agentIdentifier)
209209
drain(agentIdentifier, 'api')
210-
}).catch(() => warn('Downloading runtime APIs failed...'))
210+
}).catch(() => {
211+
warn('Downloading runtime APIs failed...')
212+
drain(agentIdentifier, 'api', true)
213+
})
211214
}
212215

213216
return apiInterface

tests/assets/adblocker-assets.html

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<!--
3+
Copyright 2020 New Relic Corporation.
4+
PDX-License-Identifier: Apache-2.0
5+
-->
6+
<html>
7+
<head>
8+
<title>RUM Unit Test</title>
9+
{init} {config}
10+
11+
<script type="text/javascript">
12+
// Use proxy to simulate adblock of assets
13+
window.NREUM||(NREUM={});NREUM.init=NREUM.init||{};NREUM.init.proxy=NREUM.init.proxy||{};NREUM.init.proxy.assets="http://foobar"
14+
</script>
15+
16+
{loader}
17+
</head>
18+
<body>
19+
This page simulates the agent assets being blocked by using a bad asset proxy setting.
20+
</body>
21+
</html>

tests/assets/adblocker-ingest.html

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!doctype html>
2+
<!--
3+
Copyright 2020 New Relic Corporation.
4+
PDX-License-Identifier: Apache-2.0
5+
-->
6+
<html>
7+
<head>
8+
<script type="text/javascript">
9+
var xhrOpen = XMLHttpRequest.prototype.open
10+
var xhrSend = XMLHttpRequest.prototype.send
11+
12+
var urlCache = {}
13+
XMLHttpRequest.prototype.open = function () {
14+
urlCache[this] = arguments[1]
15+
return xhrOpen.apply(this, arguments)
16+
}
17+
XMLHttpRequest.prototype.send = function () {
18+
var url = urlCache[this]
19+
var result = xhrSend.apply(this, arguments)
20+
if (/\/1\/[\w-_]*\?/.test(url)) {
21+
var xhr = this
22+
setTimeout(function () { xhr.abort() }, 1)
23+
}
24+
return result
25+
}
26+
</script>
27+
<title>RUM Unit Test</title>
28+
{init} {config} {loader}
29+
</head>
30+
<body>
31+
This page simulates the ingest XHR calls being blocked by aborting the rum call.
32+
</body>
33+
</html>

tests/specs/adblocker.e2e.js

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { config } from './session-replay/helpers'
2+
3+
describe('adblocker', () => {
4+
beforeEach(async () => {
5+
await browser.enableSessionReplay()
6+
})
7+
8+
afterEach(async () => {
9+
await browser.destroyAgentSession()
10+
})
11+
12+
it('should abort the global event emitter when rum call is blocked', async () => {
13+
await browser.url(await browser.testHandle.assetURL('adblocker-ingest.html', config({ session_replay: { sampling_rate: 100, error_sampling_rate: 100 } })))
14+
15+
await browser.waitUntil(() => browser.execute(function () {
16+
return newrelic.ee.aborted
17+
}), {
18+
timeoutMsg: 'expected global event emitter to abort'
19+
})
20+
21+
// Simulate adding an item to the event emitter backlog
22+
await browser.execute(function () {
23+
newrelic.addRelease('foobar', 'bizbaz')
24+
})
25+
26+
const eventEmitterBufferCleared = await browser.execute(function () {
27+
var eeCleared = {
28+
result: true,
29+
errors: []
30+
}
31+
32+
Object.entries(newrelic.ee.backlog).forEach(function (bl) {
33+
if (Array.isArray(bl[1]) && bl[1].length > 0) {
34+
eeCleared.result = false
35+
eeCleared.errors.push('newrelic.ee.backlog[' + bl[0] + '] not cleared: ' + bl[1].length + ' entries')
36+
}
37+
})
38+
39+
Object.values(newrelic.initializedAgents).forEach(function (agent) {
40+
Object.entries(agent.features).forEach(function (feat) {
41+
if (feat[1].ee.backlog !== newrelic.ee.backlog) {
42+
eeCleared.result = false
43+
eeCleared.errors.push('feature ' + feat[0] + ' backlog is not global backlog')
44+
}
45+
})
46+
})
47+
48+
return eeCleared
49+
})
50+
51+
expect(eventEmitterBufferCleared.result).toEqual(true)
52+
expect(eventEmitterBufferCleared.errors).toEqual([])
53+
})
54+
55+
it('should drain and null out all event emitter buffers when assets fail to load', async () => {
56+
await browser.url(await browser.testHandle.assetURL('adblocker-assets.html', config({ session_replay: { sampling_rate: 100, error_sampling_rate: 100 } })))
57+
58+
await browser.waitUntil(() => browser.execute(function () {
59+
var eeBacklogNulled = true
60+
61+
Object.values(newrelic.ee.backlog).forEach(function (bl) {
62+
if (bl !== null) {
63+
eeBacklogNulled = false
64+
}
65+
})
66+
67+
return eeBacklogNulled
68+
}), {
69+
timeoutMsg: 'expected global event emitter backlog to be nulled'
70+
})
71+
})
72+
})

0 commit comments

Comments
 (0)