Skip to content

Commit 52bb088

Browse files
fix: fix duplicative log payloads on unload (#1166)
1 parent 16355c4 commit 52bb088

File tree

7 files changed

+380
-42
lines changed

7 files changed

+380
-42
lines changed
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const IDEAL_PAYLOAD_SIZE = 64000
2+
export const MAX_PAYLOAD_SIZE = 1000000

src/features/logging/aggregate/index.js

+29-29
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import { warn } from '../../../common/util/console'
88
import { stringify } from '../../../common/util/stringify'
99
import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
1010
import { AggregateBase } from '../../utils/aggregate-base'
11-
import { FEATURE_NAME, LOGGING_EVENT_EMITTER_CHANNEL, LOG_LEVELS, MAX_PAYLOAD_SIZE } from '../constants'
11+
import { FEATURE_NAME, LOGGING_EVENT_EMITTER_CHANNEL, LOG_LEVELS } from '../constants'
1212
import { Log } from '../shared/log'
1313
import { isValidLogLevel } from '../shared/utils'
1414
import { applyFnToProps } from '../../../common/util/traverse'
15+
import { MAX_PAYLOAD_SIZE } from '../../../common/constants/agent-constants'
16+
import { EventBuffer } from '../../utils/event-buffer'
1517

1618
export class Aggregate extends AggregateBase {
1719
static featureName = FEATURE_NAME
@@ -21,11 +23,7 @@ export class Aggregate extends AggregateBase {
2123
super(agentIdentifier, aggregator, FEATURE_NAME)
2224

2325
/** held logs before sending */
24-
this.bufferedLogs = []
25-
/** held logs during sending, for retries */
26-
this.outgoingLogs = []
27-
/** the estimated bytes of log data waiting to be sent -- triggers a harvest if adding a new log will exceed limit */
28-
this.estimatedBytes = 0
26+
this.bufferedLogs = new EventBuffer()
2927

3028
this.#agentRuntime = getRuntime(this.agentIdentifier)
3129
this.#agentInfo = getInfo(this.agentIdentifier)
@@ -39,11 +37,11 @@ export class Aggregate extends AggregateBase {
3937
getPayload: this.prepareHarvest.bind(this),
4038
raw: true
4139
}, this)
42-
/** harvest immediately once started to purge pre-load logs collected */
43-
this.scheduler.startTimer(this.harvestTimeSeconds, 0)
4440
/** emitted by instrument class (wrapped loggers) or the api methods directly */
4541
registerHandler(LOGGING_EVENT_EMITTER_CHANNEL, this.handleLog.bind(this), this.featureName, this.ee)
4642
this.drain()
43+
/** harvest immediately once started to purge pre-load logs collected */
44+
this.scheduler.startTimer(this.harvestTimeSeconds, 0)
4745
})
4846
}
4947

@@ -70,10 +68,6 @@ export class Aggregate extends AggregateBase {
7068
return
7169
}
7270
if (typeof message !== 'string' || !message) return warn(32)
73-
if (message.length > MAX_PAYLOAD_SIZE) {
74-
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Logging/Harvest/Failed/Seen', message.length])
75-
return warn(31, message.slice(0, 25) + '...')
76-
}
7771

7872
const log = new Log(
7973
Math.floor(this.#agentRuntime.timeKeeper.correctAbsoluteTimestamp(
@@ -84,26 +78,26 @@ export class Aggregate extends AggregateBase {
8478
level
8579
)
8680
const logBytes = log.message.length + stringify(log.attributes).length + log.level.length + 10 // timestamp == 10 chars
87-
if (logBytes > MAX_PAYLOAD_SIZE) {
88-
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Logging/Harvest/Failed/Seen', logBytes])
89-
return warn(31, log.message.slice(0, 25) + '...')
90-
}
9181

92-
if (this.estimatedBytes + logBytes >= MAX_PAYLOAD_SIZE) {
93-
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Logging/Harvest/Early/Seen', this.estimatedBytes + logBytes])
94-
this.scheduler.runHarvest({})
82+
if (!this.bufferedLogs.canMerge(logBytes)) {
83+
if (this.bufferedLogs.hasData) {
84+
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Logging/Harvest/Early/Seen', this.bufferedLogs.bytes + logBytes])
85+
this.scheduler.runHarvest({})
86+
if (logBytes < MAX_PAYLOAD_SIZE) this.bufferedLogs.add(log)
87+
} else {
88+
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Logging/Harvest/Failed/Seen', logBytes])
89+
warn(31, log.message.slice(0, 25) + '...')
90+
}
91+
return
9592
}
96-
this.estimatedBytes += logBytes
97-
this.bufferedLogs.push(log)
93+
94+
this.bufferedLogs.add(log)
9895
}
9996

100-
prepareHarvest () {
101-
if (this.blocked || !(this.bufferedLogs.length || this.outgoingLogs.length)) return
102-
/** populate outgoing array while also clearing main buffer */
103-
this.outgoingLogs.push(...this.bufferedLogs.splice(0))
104-
this.estimatedBytes = 0
97+
prepareHarvest (options = {}) {
98+
if (this.blocked || !this.bufferedLogs.hasData) return
10599
/** see https://source.datanerd.us/agents/rum-specs/blob/main/browser/Log for logging spec */
106-
return {
100+
const payload = {
107101
qs: {
108102
browser_monitoring_key: this.#agentInfo.licenseKey
109103
},
@@ -123,14 +117,20 @@ export class Aggregate extends AggregateBase {
123117
},
124118
/** logs section contains individual unique log entries */
125119
logs: applyFnToProps(
126-
this.outgoingLogs,
120+
this.bufferedLogs.buffer,
127121
this.obfuscator.obfuscateString.bind(this.obfuscator), 'string'
128122
)
129123
}]
130124
}
125+
126+
if (options.retry) this.bufferedLogs.hold()
127+
else this.bufferedLogs.clear()
128+
129+
return payload
131130
}
132131

133132
onHarvestFinished (result) {
134-
if (!result.retry) this.outgoingLogs = []
133+
if (result.retry) this.bufferedLogs.unhold()
134+
else this.bufferedLogs.held.clear()
135135
}
136136
}

src/features/utils/event-buffer.js

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { stringify } from '../../common/util/stringify'
2+
import { MAX_PAYLOAD_SIZE } from '../../common/constants/agent-constants'
3+
4+
/**
5+
* A container that keeps an event buffer and size with helper methods
6+
* @typedef {Object} EventBuffer
7+
* @property {number} size
8+
* @property {*[]} buffer
9+
*/
10+
11+
/**
12+
* A container that holds, evaluates, and merges event objects for harvesting
13+
*/
14+
export class EventBuffer {
15+
/** @type {Object[]} */
16+
#buffer = []
17+
/** @type {number} */
18+
#bytes = 0
19+
/** @type {EventBuffer} */
20+
#held
21+
22+
/**
23+
*
24+
* @param {number=} maxPayloadSize
25+
*/
26+
constructor (maxPayloadSize = MAX_PAYLOAD_SIZE) {
27+
this.maxPayloadSize = maxPayloadSize
28+
}
29+
30+
/**
31+
* buffer is read only, use the helper methods to add or clear buffer data
32+
*/
33+
get buffer () {
34+
return this.#buffer
35+
}
36+
37+
/**
38+
* bytes is read only, use the helper methods to add or clear buffer data
39+
*/
40+
get bytes () {
41+
return this.#bytes
42+
}
43+
44+
/**
45+
* held is another event buffer
46+
*/
47+
get held () {
48+
this.#held ??= new EventBuffer(this.maxPayloadSize)
49+
return this.#held
50+
}
51+
52+
/**
53+
* Returns a boolean indicating whether the current size and buffer contain valid data
54+
* @returns {boolean}
55+
*/
56+
get hasData () {
57+
return this.buffer.length > 0 && this.bytes > 0
58+
}
59+
60+
/**
61+
* Adds an event object to the buffer while tallying size
62+
* @param {Object} event the event object to add to the buffer
63+
* @returns {EventBuffer} returns the event buffer for chaining
64+
*/
65+
add (event) {
66+
const size = stringify(event).length
67+
if (!this.canMerge(size)) return this
68+
this.#buffer.push(event)
69+
this.#bytes += size
70+
return this
71+
}
72+
73+
/**
74+
* clear the buffer data
75+
* @returns {EventBuffer}
76+
*/
77+
clear () {
78+
this.#bytes = 0
79+
this.#buffer = []
80+
return this
81+
}
82+
83+
/**
84+
* Hold the buffer data in a new (child) EventBuffer (.held) to unblock the main buffer.
85+
* This action clears the main buffer
86+
* @returns {EventBuffer}
87+
*/
88+
hold () {
89+
this.held.merge(this)
90+
this.clear()
91+
return this
92+
}
93+
94+
/**
95+
* Prepend the held EventBuffer (.held) back into the main buffer
96+
* This action clears the held buffer
97+
* @returns {EventBuffer}
98+
*/
99+
unhold () {
100+
this.merge(this.held, true)
101+
this.held.clear()
102+
return this
103+
}
104+
105+
/**
106+
* Merges an EventBuffer into this EventBuffer
107+
* @param {EventBuffer} events an EventBuffer intended to merge with this EventBuffer
108+
* @param {boolean} prepend if true, the supplied events will be prepended before the events of this class
109+
* @returns {EventBuffer} returns the event buffer for chaining
110+
*/
111+
merge (eventBuffer, prepend = false) {
112+
if (!this.canMerge(eventBuffer.bytes)) return this
113+
this.#buffer = prepend ? [...eventBuffer.buffer, ...this.#buffer] : [...this.#buffer, ...eventBuffer.buffer]
114+
this.#bytes += eventBuffer.#bytes
115+
return this
116+
}
117+
118+
/**
119+
* Returns a boolean indicating the resulting size of a merge would be valid. Compares against the maxPayloadSize provided at initialization time.
120+
* @param {number} size
121+
* @returns {boolean}
122+
*/
123+
canMerge (size) {
124+
return this.bytes + (size || Infinity) < this.maxPayloadSize
125+
}
126+
}

tests/assets/logs-redirect.html

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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} {loader}
10+
</head>
11+
<body>
12+
Logs captured immediately followed by a redirect
13+
14+
<script>
15+
window.addEventListener('load', () => setTimeout(() => redirectTo('https://gmail.com'), 500))
16+
function redirectTo(url) {
17+
window.newrelic.log(`redirect to ${url}`);
18+
window.open(url, "_blank");
19+
}
20+
</script>
21+
</body>
22+
</html>

tests/components/logging/aggregate.test.js

+14-13
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ describe('class setup', () => {
4040
'featureName',
4141
'blocked',
4242
'bufferedLogs',
43-
'outgoingLogs',
44-
'harvestTimeSeconds',
45-
'estimatedBytes'
43+
'harvestTimeSeconds'
4644
]))
4745
})
4846

@@ -72,7 +70,7 @@ describe('payloads', () => {
7270
{ myAttributes: 1 },
7371
'error'
7472
)
75-
expect(loggingAggregate.bufferedLogs[0]).toEqual(expectedLog)
73+
expect(loggingAggregate.bufferedLogs.buffer[0]).toEqual(expectedLog)
7674

7775
expect(loggingAggregate.prepareHarvest()).toEqual({
7876
qs: { browser_monitoring_key: info.licenseKey },
@@ -97,7 +95,7 @@ describe('payloads', () => {
9795
test('prepares payload as expected', async () => {
9896
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', { myAttributes: 1 }, 'error'])
9997

100-
expect(loggingAggregate.bufferedLogs[0]).toEqual(new Log(
98+
expect(loggingAggregate.bufferedLogs.buffer[0]).toEqual(new Log(
10199
Math.floor(runtime.timeKeeper.correctAbsoluteTimestamp(
102100
runtime.timeKeeper.convertRelativeTimestamp(1234)
103101
)),
@@ -142,17 +140,19 @@ describe('payloads', () => {
142140
'error'
143141
)
144142

143+
const logs = loggingAggregate.bufferedLogs.buffer
144+
145145
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', [], 'ERROR'])
146-
expect(loggingAggregate.bufferedLogs.pop()).toEqual(expected)
146+
expect(logs.pop()).toEqual(expected)
147147

148148
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', true, 'ERROR'])
149-
expect(loggingAggregate.bufferedLogs.pop()).toEqual(expected)
149+
expect(logs.pop()).toEqual(expected)
150150

151151
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', 1, 'ERROR'])
152-
expect(loggingAggregate.bufferedLogs.pop()).toEqual(expected)
152+
expect(logs.pop()).toEqual(expected)
153153

154154
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', 'string', 'ERROR'])
155-
expect(loggingAggregate.bufferedLogs.pop()).toEqual(expected)
155+
expect(logs.pop()).toEqual(expected)
156156
})
157157

158158
test('should work if log level is valid but wrong case', async () => {
@@ -166,18 +166,19 @@ describe('payloads', () => {
166166
)
167167

168168
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, 'test message', {}, 'ErRoR'])
169-
expect(loggingAggregate.bufferedLogs.pop()).toEqual(expected)
169+
expect(loggingAggregate.bufferedLogs.buffer[0]).toEqual(expected)
170170
})
171171

172172
test('should buffer logs with non-stringify-able message', async () => {
173+
const logs = loggingAggregate.bufferedLogs.buffer
173174
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, new Error('test'), {}, 'error'])
174-
expect(loggingAggregate.bufferedLogs.pop().message).toEqual('Error: test')
175+
expect(logs.pop().message).toEqual('Error: test')
175176

176177
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, new SyntaxError('test'), {}, 'error'])
177-
expect(loggingAggregate.bufferedLogs.pop().message).toEqual('SyntaxError: test')
178+
expect(logs.pop().message).toEqual('SyntaxError: test')
178179

179180
loggingAggregate.ee.emit(LOGGING_EVENT_EMITTER_CHANNEL, [1234, Symbol('test'), {}, 'error'])
180-
expect(loggingAggregate.bufferedLogs.pop().message).toEqual('Symbol(test)')
181+
expect(logs.pop().message).toEqual('Symbol(test)')
181182
})
182183
})
183184

tests/specs/logging/harvesting.e2e.js

+14
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ describe('logging harvesting', () => {
7777
expect(JSON.parse(body)[0].logs[0].message).toEqual('Error: test')
7878
})
7979

80+
it('should not double harvest on navigation logs', async () => {
81+
const [logsRequests] = await Promise.all([
82+
logsCapture.waitForResult({ timeout: 15000 }),
83+
browser.url(await browser.testHandle.assetURL('logs-redirect.html'))
84+
])
85+
86+
// 1 harvest
87+
expect(logsRequests.length).toEqual(1)
88+
const parsedBody = JSON.parse(logsRequests[0].request.body)
89+
// 1 log in the 1 harvest
90+
expect(parsedBody[0].logs.length).toEqual(1)
91+
expect(parsedBody[0].logs[0].message).toEqual('redirect to https://gmail.com')
92+
})
93+
8094
it('should allow for re-wrapping and 3rd party wrapping', async () => {
8195
const [[{ request: { body } }]] = await Promise.all([
8296
logsCapture.waitForResult({ totalCount: 1 }),

0 commit comments

Comments
 (0)