Skip to content

Commit 3128cd0

Browse files
committed
feat: re-instrument WebSocket
- Added socketId to error objects for better linkage with WebSocket events. - Removed deprecated WebSocket event handling code and associated metrics. - Introduced new HTML test files for WebSocket error handling and multi-type message sending. - Updated existing tests to validate WebSocket metrics and error linkage. - Implemented a WebSocket echo server for testing various data types. - Enhanced third-party WebSocket wrappers to support new feature flags.
1 parent 09ff9b3 commit 3128cd0

File tree

17 files changed

+793
-254
lines changed

17 files changed

+793
-254
lines changed

src/common/wrap/wrap-websocket.js

Lines changed: 139 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,59 +3,167 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { globalScope } from '../constants/runtime'
6-
import { now } from '../timing/now'
7-
import { checkState } from '../window/load'
6+
import { handle } from '../event-emitter/handle'
87
import { generateRandomHexString } from '../ids/unique-id'
8+
import { now } from '../timing/now'
99
import { gosNREUMOriginals } from '../window/nreum'
1010

11-
export const WEBSOCKET_TAG = 'websocket-'
12-
export const ADD_EVENT_LISTENER_TAG = 'addEventListener'
13-
1411
const wrapped = {}
12+
const subscribedFeatures = []
1513

16-
export function wrapWebSocket (sharedEE) {
17-
if (wrapped[sharedEE.debugId]++) return sharedEE
14+
export function wrapWebSocket (sharedEE, callerFeature) {
1815
const originals = gosNREUMOriginals().o
1916
if (!originals.WS) return sharedEE
2017

21-
function reporter (socketId) {
22-
const createdAt = now()
23-
return function (message, ...data) {
24-
const timestamp = data[0]?.timeStamp || now()
25-
const isLoaded = checkState()
26-
sharedEE.emit(WEBSOCKET_TAG + message, [timestamp, timestamp - createdAt, isLoaded, socketId, ...data])
27-
}
28-
}
18+
if (callerFeature) subscribedFeatures.push(callerFeature) // regardless if WS is already wrapped or not, set feat up for future event from this wrapping
19+
const wsEE = sharedEE.get('websockets')
20+
if (wrapped[wsEE.debugId]++) return wsEE
21+
wrapped[wsEE.debugId] = 1 // otherwise, first feature to wrap events
2922

3023
class WrappedWebSocket extends WebSocket {
3124
static name = 'WebSocket'
25+
static toString () { // fake native WebSocket when static class is stringified
26+
return 'function WebSocket() { [native code] }'
27+
}
28+
29+
toString () { // fake [object WebSocket] when instance is stringified
30+
return '[object WebSocket]'
31+
}
32+
33+
get [Symbol.toStringTag] () { // fake [object WebSocket] when Object.prototype.toString.call is used on instance
34+
return WrappedWebSocket.name
35+
}
36+
37+
// Private method to tag send, close, and event listener errors with WebSocket ID for JSErrors feature
38+
#tagError (error) {
39+
;(error.__newrelic ??= {}).socketId = this.nrData.socketId
40+
this.nrData.hasErrors ??= true
41+
}
3242

3343
constructor (...args) {
3444
super(...args)
35-
const socketId = generateRandomHexString(6)
36-
this.report = reporter(socketId)
37-
this.report('new')
38-
39-
const events = ['message', 'error', 'open', 'close']
40-
/** add event listeners */
41-
events.forEach(evt => {
42-
this.addEventListener(evt, function (e) {
43-
this.report(ADD_EVENT_LISTENER_TAG, { eventType: evt, event: e })
45+
this.nrData = {
46+
timestamp: now(), // this will be time corrected later when timeKeeper is avail
47+
currentUrl: window.location.href,
48+
socketId: generateRandomHexString(8),
49+
requestedUrl: args[0],
50+
requestedProtocols: Array.isArray(args[1]) ? args[1].join(',') : (args[1] || '')
51+
// pageUrl will be set by addEvent later; unlike timestamp and currentUrl, it's not sensitive to *when* it is set
52+
}
53+
54+
this.addEventListener('open', () => {
55+
this.nrData.openedAt = now()
56+
;['protocol', 'extensions', 'binaryType'].forEach(prop => {
57+
this.nrData[prop] = this[prop]
4458
})
4559
})
60+
61+
this.addEventListener('message', (event) => {
62+
const { type, size } = getDataInfo(event.data)
63+
this.nrData.messageOrigin ??= event.origin // the origin of messages thru WS lifetime cannot be changed, so set once is sufficient
64+
this.nrData.messageCount = (this.nrData.messageCount ?? 0) + 1
65+
this.nrData.messageBytes = (this.nrData.messageBytes ?? 0) + size
66+
this.nrData.messageBytesMin = Math.min(this.nrData.messageBytesMin ?? Infinity, size)
67+
this.nrData.messageBytesMax = Math.max(this.nrData.messageBytesMax ?? 0, size)
68+
if (!(this.nrData.messageTypes ?? '').includes(type)) {
69+
this.nrData.messageTypes = this.nrData.messageTypes ? `${this.nrData.messageTypes},${type}` : type
70+
}
71+
})
72+
73+
this.addEventListener('close', (event) => {
74+
this.nrData.closedAt = now()
75+
this.nrData.closeCode = event.code
76+
this.nrData.closeReason = event.reason
77+
this.nrData.closeWasClean = event.wasClean
78+
this.nrData.connectedDuration = this.nrData.closedAt - this.nrData.openedAt
79+
80+
subscribedFeatures.forEach(featureName => handle('ws-complete', [this.nrData], this, featureName, wsEE))
81+
})
82+
}
83+
84+
addEventListener (type, listener, ...rest) {
85+
const wsInstance = this
86+
const wrappedListener = typeof listener === 'function'
87+
? function (...args) {
88+
try {
89+
return listener.apply(this, args)
90+
} catch (error) {
91+
wsInstance.#tagError(error)
92+
throw error
93+
}
94+
}
95+
: listener?.handleEvent
96+
? { // case for listener === object with handleEvent
97+
handleEvent: function (...args) {
98+
try {
99+
return listener.handleEvent.apply(listener, args)
100+
} catch (error) {
101+
wsInstance.#tagError(error)
102+
throw error
103+
}
104+
}
105+
}
106+
: listener // case for listener === null
107+
return super.addEventListener(type, wrappedListener, ...rest)
46108
}
47109

48-
send (...args) {
49-
this.report('send', ...args)
110+
send (data) {
111+
// Only track metrics if the connection is OPEN; data sent in CONNECTING state throws, and data sent in CLOSING/CLOSED states is silently discarded
112+
if (this.readyState === WebSocket.OPEN) {
113+
const { type, size } = getDataInfo(data)
114+
this.nrData.sendCount = (this.nrData.sendCount ?? 0) + 1
115+
this.nrData.sendBytes = (this.nrData.sendBytes ?? 0) + size
116+
this.nrData.sendBytesMin = Math.min(this.nrData.sendBytesMin ?? Infinity, size)
117+
this.nrData.sendBytesMax = Math.max(this.nrData.sendBytesMax ?? 0, size)
118+
if (!(this.nrData.sendTypes ?? '').includes(type)) {
119+
this.nrData.sendTypes = this.nrData.sendTypes ? `${this.nrData.sendTypes},${type}` : type
120+
}
121+
}
50122
try {
51-
return super.send(...args)
52-
} catch (err) {
53-
this.report('send-err', ...args)
54-
throw err
123+
return super.send(data)
124+
} catch (error) {
125+
this.#tagError(error)
126+
throw error
127+
}
128+
}
129+
130+
close (...args) {
131+
try {
132+
super.close(...args)
133+
} catch (error) {
134+
this.#tagError(error)
135+
throw error
55136
}
56137
}
57138
}
58139

59140
globalScope.WebSocket = WrappedWebSocket
60-
return sharedEE
141+
return wsEE
142+
}
143+
144+
/**
145+
* Returns the data type and size of the WebSocket send data
146+
* @param {*} data - The data being sent
147+
* @returns {{ type: string, size: number }} - The type name and size in bytes
148+
*/
149+
function getDataInfo (data) {
150+
if (typeof data === 'string') {
151+
return {
152+
type: 'string',
153+
size: new TextEncoder().encode(data).length // efficient way to calculate the # of UTF-8 bytes that WS sends (cannot use string length)
154+
}
155+
}
156+
if (data instanceof ArrayBuffer) {
157+
return { type: 'ArrayBuffer', size: data.byteLength }
158+
}
159+
if (data instanceof Blob) {
160+
return { type: 'Blob', size: data.size }
161+
}
162+
if (data instanceof DataView) {
163+
return { type: 'DataView', size: data.byteLength }
164+
}
165+
if (ArrayBuffer.isView(data)) {
166+
return { type: 'TypedArray', size: data.byteLength }
167+
}
168+
return { type: 'unknown', size: 0 }
61169
}

src/features/generic_events/aggregate/index.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class Aggregate extends AggregateBase {
3737
if (RESERVED_EVENT_TYPES.includes(eventType)) return warn(46)
3838
this.addEvent({
3939
eventType,
40-
timestamp: this.toEpoch(timestamp),
40+
timestamp: this.#toEpoch(timestamp),
4141
...attributes
4242
})
4343
}, this.featureName, this.ee)
@@ -47,7 +47,7 @@ export class Aggregate extends AggregateBase {
4747
this.addEvent({
4848
...attributes,
4949
eventType: 'PageAction',
50-
timestamp: this.toEpoch(timestamp),
50+
timestamp: this.#toEpoch(timestamp),
5151
timeSinceLoad: timestamp / 1000,
5252
actionName: name,
5353
referrerUrl: this.referrerUrl,
@@ -72,7 +72,7 @@ export class Aggregate extends AggregateBase {
7272
const { target, timeStamp, type } = aggregatedUserAction.event
7373
const userActionEvent = {
7474
eventType: 'UserAction',
75-
timestamp: this.toEpoch(timeStamp),
75+
timestamp: this.#toEpoch(timeStamp),
7676
action: type,
7777
actionCount: aggregatedUserAction.count,
7878
actionDuration: aggregatedUserAction.relativeMs[aggregatedUserAction.relativeMs.length - 1],
@@ -149,7 +149,7 @@ export class Aggregate extends AggregateBase {
149149
this.addEvent({
150150
...detailObj,
151151
eventType: 'BrowserPerformance',
152-
timestamp: this.toEpoch(entry.startTime),
152+
timestamp: this.#toEpoch(entry.startTime),
153153
entryName: entry.name,
154154
entryDuration: entry.duration,
155155
entryType: type
@@ -214,7 +214,7 @@ export class Aggregate extends AggregateBase {
214214
const event = {
215215
...entryObject,
216216
eventType: 'BrowserPerformance',
217-
timestamp: Math.floor(agentRef.runtime.timeKeeper.correctRelativeTimestamp(entryObject.startTime)),
217+
timestamp: this.#toEpoch(entryObject.startTime),
218218
entryName: cleanURL(name),
219219
entryDuration: duration,
220220
firstParty
@@ -233,7 +233,7 @@ export class Aggregate extends AggregateBase {
233233
const event = {
234234
...customAttributes,
235235
eventType: 'BrowserPerformance',
236-
timestamp: Math.floor(agentRef.runtime.timeKeeper.correctRelativeTimestamp(start)),
236+
timestamp: this.#toEpoch(start),
237237
entryName: n,
238238
entryDuration: duration,
239239
entryType: 'measure'
@@ -242,6 +242,19 @@ export class Aggregate extends AggregateBase {
242242
this.addEvent(event)
243243
}, this.featureName, this.ee)
244244

245+
if (agentRef.init.feature_flags.includes('websockets')) {
246+
const wsEE = this.ee.get('websockets')
247+
registerHandler('ws-complete', (nrData) => {
248+
this.addEvent({
249+
...nrData,
250+
eventType: 'WebSocket',
251+
timestamp: this.#toEpoch(nrData.timestamp),
252+
openedAt: this.#toEpoch(nrData.openedAt),
253+
closedAt: this.#toEpoch(nrData.closedAt)
254+
})
255+
}, this.featureName, wsEE)
256+
}
257+
245258
this.drain()
246259
})
247260
}
@@ -274,7 +287,7 @@ export class Aggregate extends AggregateBase {
274287

275288
const defaultEventAttributes = {
276289
/** should be overridden by the event-specific attributes, but just in case -- set it to now() */
277-
timestamp: Math.floor(this.agentRef.runtime.timeKeeper.correctRelativeTimestamp(now())),
290+
timestamp: this.#toEpoch(now()),
278291
/** all generic events require pageUrl(s) */
279292
pageUrl: cleanURL('' + initialLocation),
280293
currentUrl: cleanURL('' + location),
@@ -302,7 +315,7 @@ export class Aggregate extends AggregateBase {
302315
return { ua: this.agentRef.info.userAttributes, at: this.agentRef.info.atts }
303316
}
304317

305-
toEpoch (timestamp) {
318+
#toEpoch (timestamp) {
306319
return Math.floor(this.agentRef.runtime.timeKeeper.correctRelativeTimestamp(timestamp))
307320
}
308321

src/features/generic_events/instrument/index.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,23 @@ import { wrapFetch } from '../../../common/wrap/wrap-fetch'
2020
import { wrapXhr } from '../../../common/wrap/wrap-xhr'
2121
import { parseUrl } from '../../../common/url/parse-url'
2222
import { extractUrl } from '../../../common/url/extract-url'
23+
import { wrapWebSocket } from '../../../common/wrap/wrap-websocket'
2324

2425
export class Instrument extends InstrumentBase {
2526
static featureName = FEATURE_NAME
2627
constructor (agentRef) {
2728
super(agentRef, FEATURE_NAME)
29+
const websocketsEnabled = agentRef.init.feature_flags.includes('websockets')
30+
const ufEnabled = agentRef.init.feature_flags.includes('user_frustrations')
31+
2832
/** config values that gate whether the generic events aggregator should be imported at all */
2933
const genericEventSourceConfigs = [
3034
agentRef.init.page_action.enabled,
3135
agentRef.init.performance.capture_marks,
3236
agentRef.init.performance.capture_measures,
37+
agentRef.init.performance.resources.enabled,
3338
agentRef.init.user_actions.enabled,
34-
agentRef.init.performance.resources.enabled
39+
websocketsEnabled
3540
]
3641

3742
/** feature specific APIs */
@@ -41,13 +46,13 @@ export class Instrument extends InstrumentBase {
4146
setupRegisterAPI(agentRef)
4247
setupMeasureAPI(agentRef)
4348

44-
const ufEnabled = agentRef.init.feature_flags.includes('user_frustrations')
4549
let historyEE
4650
if (isBrowserScope && ufEnabled) {
4751
wrapFetch(this.ee)
4852
wrapXhr(this.ee)
4953
historyEE = wrapHistory(this.ee)
5054
}
55+
if (websocketsEnabled) wrapWebSocket(this.ee, this.featureName)
5156

5257
if (isBrowserScope) {
5358
if (agentRef.init.user_actions.enabled) {

src/features/jserrors/aggregate/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,13 @@ export class Aggregate extends AggregateBase {
192192
// still send EE events for other features such as above, but stop this one from aggregating internal data
193193
if (this.blocked) return
194194

195-
if (err?.__newrelic?.[this.agentIdentifier]) {
195+
if (err.__newrelic?.[this.agentIdentifier]) {
196196
params._interactionId = err.__newrelic[this.agentIdentifier].interactionId
197197
params._interactionNodeId = err.__newrelic[this.agentIdentifier].interactionNodeId
198198
}
199+
if (err.__newrelic?.socketId) {
200+
customAttributes.socketId = err.__newrelic.socketId
201+
}
199202

200203
if (this.shouldAllowMainAgentToCapture(target)) {
201204
const softNavInUse = Boolean(this.agentRef.features?.[FEATURE_NAMES.softNav])

src/features/metrics/aggregate/index.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,6 @@ export class Aggregate extends AggregateBase {
131131
// webdriver detection
132132
if (navigator.webdriver) this.storeSupportabilityMetrics('Generic/WebDriver/Detected')
133133

134-
// WATCHABLE_WEB_SOCKET_EVENTS.forEach(tag => {
135-
// registerHandler('buffered-' + WEBSOCKET_TAG + tag, (...args) => {
136-
// handleWebsocketEvents(this.storeSupportabilityMetrics.bind(this), tag, ...args)
137-
// }, this.featureName, this.ee)
138-
// })
139-
140134
/** all the harvest metadata metrics need to be evaluated simulataneously at unload time so just temporarily buffer them and dont make SMs immediately from the data */
141135
registerHandler('harvest-metadata', (harvestMetadataObject = {}) => {
142136
try {

src/features/metrics/aggregate/websocket-detection.js

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)