Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 [RUMF-876] Improve proxy behavior for xhr reuse #865

Merged
merged 22 commits into from
Jun 10, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
99881bd
♻️ 🐛 Clear event listeners on XMLHttpRequest
amortemousque May 25, 2021
fd3c9dc
✅ Add test for multi requests with XMLHttpRequest
amortemousque May 25, 2021
b4ebff7
Add a third state interface for open xhr
amortemousque May 26, 2021
6f83a4c
♻️ Move reportXhr out of XMLHttpRequest.send
amortemousque May 27, 2021
cb09c80
✅ Test for XHRs with same XMLHttpRequest instance
amortemousque May 27, 2021
61775fa
👌 use event listener for readystatechange
amortemousque Jun 1, 2021
d0f2a77
Update StubXhr to also stub listeners logic
amortemousque Jun 1, 2021
c567f7e
✅ Update xhr test to match the Angular use case
amortemousque Jun 1, 2021
e14e35a
👌 StubXhr support readystatechange attribute and evt
amortemousque Jun 3, 2021
3a49a3e
✅ Test helper withXhr trigger complete manually
amortemousque Jun 3, 2021
96ea908
👌 Cover onreadystage binding before open
amortemousque Jun 3, 2021
ccb7343
Fix multiple request xhr test
amortemousque Jun 4, 2021
dfb256f
Fix infinite loop in case of DD_RUM and DD_LOGS
amortemousque Jun 4, 2021
ef3b235
Create functionName helper for IE
amortemousque Jun 4, 2021
9903554
Rollback to previous implementation
amortemousque Jun 4, 2021
6097aa4
Merge branch 'main' into XHR-reuse-possible
amortemousque Jun 7, 2021
3bdd0a0
👌 Fix xhr tests
amortemousque Jun 8, 2021
f0d1d2d
👌 Ensure to call overridden onreadystatechange
amortemousque Jun 8, 2021
20bc634
👌 Remove manual xhr test
amortemousque Jun 8, 2021
c365895
👌 Keep same _datadog_xhr ref in sendXhr
amortemousque Jun 8, 2021
185cb70
👌 Simplify reportXhr
amortemousque Jun 9, 2021
00a48fe
Merge branch 'main' into XHR-reuse-possible
amortemousque Jun 10, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/core/src/browser/xhrProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ describe('xhr proxy', () => {
const onReadyStateChange = jasmine.createSpy()
withXhr({
setup(xhr) {
xhr.onreadystatechange = onReadyStateChange
xhr.addEventListener('load', () => xhr.abort())
xhr.open('GET', '/ok')
xhr.addEventListener('readystatechange', onReadyStateChange)
xhr.addEventListener('load', () => xhr.abort())
xhr.send()
amortemousque marked this conversation as resolved.
Show resolved Hide resolved
xhr.complete(200, 'ok')
},
Expand Down Expand Up @@ -209,4 +209,26 @@ describe('xhr proxy', () => {
},
})
})

it('should track only the second request (the first request is canceled)', (done) => {
amortemousque marked this conversation as resolved.
Show resolved Hide resolved
amortemousque marked this conversation as resolved.
Show resolved Hide resolved
bcaudan marked this conversation as resolved.
Show resolved Hide resolved
const onLoad = jasmine.createSpy()
withXhr({
setup(xhr) {
xhr.addEventListener('load', onLoad)
xhr.open('GET', '/first')
xhr.send()
xhr.open('GET', '/second')
xhr.send()
xhr.complete(200, 'ok')
},
onComplete() {
const request = getRequest(0)
expect(onLoad).toHaveBeenCalledTimes(1)
expect(request.method).toBe('GET')
expect(request.url).toContain('/second')
expect(request.status).toBe(200)
done()
},
})
})
})
171 changes: 86 additions & 85 deletions packages/core/src/browser/xhrProxy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { monitor, callMonitored } from '../domain/internalMonitoring'
import { callMonitored } from '../domain/internalMonitoring'
import { Duration, elapsed, relativeNow, RelativeTime, ClocksState, clocksNow, timeStampNow } from '../tools/timeUtils'
import { normalizeUrl } from '../tools/urlPolyfill'

interface BrowserXHR extends XMLHttpRequest {
_datadog_xhr: XhrStartContext
interface BrowserXHR<T extends XhrOpenContext = XhrStartContext> extends XMLHttpRequest {
bcaudan marked this conversation as resolved.
Show resolved Hide resolved
_datadog_xhr?: T
}

export interface XhrProxy<
Expand All @@ -14,12 +14,15 @@ export interface XhrProxy<
onRequestComplete: (callback: (context: CompleteContext) => void) => void
}

export interface XhrStartContext {
export interface XhrOpenContext {
method: string
url: string
}

export interface XhrStartContext extends XhrOpenContext {
startTime: RelativeTime // deprecated
startClocks: ClocksState

isAborted: boolean
/**
* allow clients to enhance the context
*/
Expand All @@ -30,7 +33,6 @@ export interface XhrCompleteContext extends XhrStartContext {
duration: Duration
status: number
response: string | undefined
isAborted: boolean
}

let xhrProxySingleton: XhrProxy | undefined
Expand All @@ -45,7 +47,16 @@ export function startXhrProxy<
CompleteContext extends XhrCompleteContext = XhrCompleteContext
>(): XhrProxy<StartContext, CompleteContext> {
if (!xhrProxySingleton) {
proxyXhr()
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrOpen = XMLHttpRequest.prototype.open
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrSend = XMLHttpRequest.prototype.send
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrAbort = XMLHttpRequest.prototype.abort
XMLHttpRequest.prototype.open = openXhr
XMLHttpRequest.prototype.send = sendXhr
XMLHttpRequest.prototype.abort = abortXhr

xhrProxySingleton = {
beforeSend(callback: (context: XhrStartContext, xhr: XMLHttpRequest) => void) {
beforeSendCallbacks.push(callback)
Expand All @@ -69,85 +80,75 @@ export function resetXhrProxy() {
}
}

function proxyXhr() {
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrOpen = XMLHttpRequest.prototype.open
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrSend = XMLHttpRequest.prototype.send
// eslint-disable-next-line @typescript-eslint/unbound-method
originalXhrAbort = XMLHttpRequest.prototype.abort

XMLHttpRequest.prototype.open = function (this: BrowserXHR, method: string, url: string) {
callMonitored(() => {
// WARN: since this data structure is tied to the instance, it is shared by both logs and rum
// and can be used by different code versions depending on customer setup
// so it should stay compatible with older versions
;(this._datadog_xhr as Pick<XhrStartContext, 'method' | 'url'>) = {
method,
url: normalizeUrl(url),
}
})
return originalXhrOpen.apply(this, arguments as any)
}
function openXhr(this: BrowserXHR<XhrOpenContext>, method: string, url: string) {
callMonitored(() => {
// WARN: since this data structure is tied to the instance, it is shared by both logs and rum
// and can be used by different code versions depending on customer setup
// so it should stay compatible with older versions
this._datadog_xhr = {
method,
url: normalizeUrl(url),
}

XMLHttpRequest.prototype.send = function (this: BrowserXHR) {
callMonitored(() => {
if (this._datadog_xhr) {
const xhrPendingContext = this._datadog_xhr as XhrStartContext &
Pick<XhrCompleteContext, 'startTime' | 'isAborted'>
xhrPendingContext.startTime = relativeNow()
xhrPendingContext.startClocks = clocksNow()
xhrPendingContext.isAborted = false

const originalOnreadystatechange = this.onreadystatechange

this.onreadystatechange = function () {
if (this.readyState === XMLHttpRequest.DONE) {
// Try to report the XHR as soon as possible, because the XHR may be mutated by the
// application during a future event. For example, Angular is calling .abort() on
// completed requests during a onreadystatechange event, so the status becomes '0'
// before the request is collected.
callMonitored(reportXhr)
}

if (originalOnreadystatechange) {
originalOnreadystatechange.apply(this, arguments as any)
}
}

let hasBeenReported = false

const reportXhr = () => {
if (hasBeenReported) {
return
}
hasBeenReported = true

const xhrCompleteContext: XhrCompleteContext = {
...xhrPendingContext,
duration: elapsed(xhrPendingContext.startClocks.timeStamp, timeStampNow()),
response: this.response as string | undefined,
status: this.status,
}

onRequestCompleteCallbacks.forEach((callback) => callback(xhrCompleteContext))
}

this.addEventListener('loadend', monitor(reportXhr))

beforeSendCallbacks.forEach((callback) => callback(xhrPendingContext, this))
}
})

return originalXhrSend.apply(this, arguments as any)
}
// To avoid adding listeners each time 'open()' is called,
// we take advantage of the automatic listener discarding if multiple identical EventListeners are registered
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#multiple_identical_event_listeners
this.addEventListener('readystatechange', onReadyStateChange)
this.addEventListener('loadend', reportXhr)
})
return originalXhrOpen.apply(this, arguments as any)
}

XMLHttpRequest.prototype.abort = function (this: BrowserXHR) {
callMonitored(() => {
if (this._datadog_xhr) {
this._datadog_xhr.isAborted = true
}
})
return originalXhrAbort.apply(this, arguments as any)
function sendXhr(this: BrowserXHR) {
callMonitored(() => {
if (!this._datadog_xhr) {
return
}
this._datadog_xhr = {
...this._datadog_xhr,
startTime: relativeNow(),
startClocks: clocksNow(),
isAborted: false,
}
bcaudan marked this conversation as resolved.
Show resolved Hide resolved

beforeSendCallbacks.forEach((callback) => callback(this._datadog_xhr!, this))
})

return originalXhrSend.apply(this, arguments as any)
}

function abortXhr(this: BrowserXHR) {
callMonitored(() => {
if (this._datadog_xhr) {
this._datadog_xhr.isAborted = true
}
})
return originalXhrAbort.apply(this, arguments as any)
}

function onReadyStateChange(this: BrowserXHR) {
if (this.readyState === XMLHttpRequest.DONE) {
// Try to report the XHR as soon as possible, because the XHR may be mutated by the
// application during a future event. For example, Angular is calling .abort() on
// completed requests during a onreadystatechange event, so the status becomes '0'
// before the request is collected.
// https://github.com/angular/angular/blob/master/packages/common/http/src/xhr.ts
reportXhr.call(this)
}
}

function reportXhr(this: BrowserXHR) {
callMonitored(() => {
const xhrCompleteContext: XhrCompleteContext = {
...this._datadog_xhr!,
duration: elapsed(this._datadog_xhr!.startClocks.timeStamp, timeStampNow()),
response: this.response as string | undefined,
status: this.status,
}

onRequestCompleteCallbacks.forEach((callback) => callback(xhrCompleteContext))
// Unsubscribe to avoid being reported twice
this.removeEventListener('readystatechange', onReadyStateChange)
this.removeEventListener('loadend', reportXhr)
})
}
30 changes: 21 additions & 9 deletions packages/core/test/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,7 @@ class StubXhr {
public onreadystatechange: () => void = noop

private hasEnded = false
private fakeEventTarget: HTMLDivElement

constructor() {
this.fakeEventTarget = document.createElement('div')
}
private listeners: { [k: string]: Array<() => void> } = {}

/* eslint-disable @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars */
open(method: string, url: string) {}
Expand All @@ -154,7 +150,7 @@ class StubXhr {
}
this.hasEnded = true
this.readyState = XMLHttpRequest.DONE
this.onreadystatechange()
this.dispatchEvent('readystatechange')
this.dispatchEvent('abort')
this.dispatchEvent('loadend')
}
Expand All @@ -167,7 +163,8 @@ class StubXhr {
this.response = response
this.status = status
this.readyState = XMLHttpRequest.DONE
this.onreadystatechange()
this.dispatchEvent('readystatechange')

if (status >= 200 && status < 500) {
this.dispatchEvent('load')
}
Expand All @@ -178,11 +175,26 @@ class StubXhr {
}

addEventListener(name: string, callback: () => void) {
this.fakeEventTarget.addEventListener(name, callback)
if (!this.listeners[name]) {
this.listeners[name] = []
}
bcaudan marked this conversation as resolved.
Show resolved Hide resolved

this.listeners[name].push(callback)
}

removeEventListener(name: string, callback: () => void) {
if (!this.listeners[name]) {
throw new Error(`Can't remove a listener. Event "${name}" doesn't exits.`)
}

this.listeners[name] = this.listeners[name].filter((listener) => listener !== callback)
}

private dispatchEvent(name: string) {
this.fakeEventTarget.dispatchEvent(createNewEvent(name))
if (!this.listeners[name]) {
return
}
this.listeners[name].forEach((listener) => listener.call(this))
}
}

Expand Down
50 changes: 50 additions & 0 deletions test/e2e/scenario/rum/resources.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,56 @@ describe('rum resources', () => {
expect(resourceEvent?.resource.status_code).toBe(0)
})
})

describe('support XHRs with same XMLHttpRequest instance', () => {
createTest('track only second XHR (first is canceled by the browser)')
.withRum()
.withSetup(bundleSetup)
.run(async ({ events }) => {
await browserExecuteAsync((done) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', '/ok?duration=200&call=1')
xhr.send()
xhr.open('GET', '/ok?duration=100&call=2')
xhr.send()
setTimeout(() => {
done(undefined)
}, 200)
})

await flushEvents()

const resourceEvents = events.rumResources.filter((event) => event.resource.type === 'xhr')
expect(resourceEvents.length).toEqual(1)
expect(events.rumErrors.length).toBe(0)
expect(resourceEvents[0]?.resource.status_code).toBe(200)
})

createTest('track both XHRs when the second wait for the respond of the first')
bcaudan marked this conversation as resolved.
Show resolved Hide resolved
.withRum()
.withSetup(bundleSetup)
.run(async ({ events }) => {
await browserExecuteAsync((done) => {
const xhr = new XMLHttpRequest()
const triggerSecondCall = () => {
xhr.removeEventListener('loadend', triggerSecondCall)
xhr.addEventListener('loadend', () => done(undefined))
xhr.open('GET', '/ok?duration=100&call=2')
xhr.send()
}
xhr.addEventListener('loadend', triggerSecondCall)
xhr.open('GET', '/ok?duration=100&call=1')
xhr.send()
})

await flushEvents()

const resourceEvents = events.rumResources.filter((event) => event.resource.type === 'xhr')
expect(resourceEvents.length).toEqual(2)
expect(events.rumErrors.length).toBe(0)
expect(resourceEvents.map((resourceEvent) => resourceEvent.resource.status_code)).toEqual([200, 200])
bcaudan marked this conversation as resolved.
Show resolved Hide resolved
})
})
})

function expectToHaveValidTimings(resourceEvent: RumResourceEvent) {
Expand Down