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-766] prevent request duration override by wrong matching timing #604

Merged
merged 2 commits into from
Nov 5, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
2 changes: 1 addition & 1 deletion packages/rum/src/domain/assemblyV2.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Context } from '@datadog/browser-core'
import { createRawRumEvent } from '../../test/createRawRumEvent'
import { createRawRumEvent } from '../../test/fixtures'
import { setup, TestSetupBuilder } from '../../test/specHelper'
import { RumEventType } from '../typesV2'
import { LifeCycle, LifeCycleEventType } from './lifeCycle'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DOM_EVENT } from '@datadog/browser-core'
import { createRawRumEvent } from '../../../../test/createRawRumEvent'
import { createRawRumEvent } from '../../../../test/fixtures'
import { setup, TestSetupBuilder } from '../../../../test/specHelper'
import { RumErrorEvent, RumEventCategory } from '../../../types'
import { RumEventType } from '../../../typesV2'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isIE } from '@datadog/browser-core'
import { createResourceEntry } from '../../../../test/fixtures'
import { RumPerformanceResourceTiming } from '../../../browser/performanceCollection'
import { RequestCompleteEvent } from '../../requestCollection'

Expand All @@ -17,52 +18,61 @@ describe('matchRequestTiming', () => {
})

it('should match single timing nested in the request ', () => {
const match: Partial<RumPerformanceResourceTiming> = { startTime: 200, duration: 300 }
entries.push(match as RumPerformanceResourceTiming)
const match = createResourceEntry({ startTime: 200, duration: 300 })
entries.push(match)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

expect(timing).toEqual(match as RumPerformanceResourceTiming)
expect(timing).toEqual(match)
})

it('should not match single timing outside the request ', () => {
const match: Partial<RumPerformanceResourceTiming> = { startTime: 0, duration: 300 }
entries.push(match as RumPerformanceResourceTiming)
const match = createResourceEntry({ startTime: 0, duration: 300 })
entries.push(match)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

expect(timing).toEqual(undefined)
})

it('should match two following timings nested in the request ', () => {
const optionsTiming: Partial<RumPerformanceResourceTiming> = { startTime: 200, duration: 50 }
const actualTiming: Partial<RumPerformanceResourceTiming> = { startTime: 300, duration: 100 }
entries.push(optionsTiming as RumPerformanceResourceTiming, actualTiming as RumPerformanceResourceTiming)
const optionsTiming = createResourceEntry({ startTime: 150, duration: 50 })
const actualTiming = createResourceEntry({ startTime: 200, duration: 100 })
entries.push(optionsTiming, actualTiming)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

expect(timing).toEqual(actualTiming as RumPerformanceResourceTiming)
expect(timing).toEqual(actualTiming)
})

it('should not match two not following timings nested in the request ', () => {
const match1: Partial<RumPerformanceResourceTiming> = { startTime: 200, duration: 100 }
const match2: Partial<RumPerformanceResourceTiming> = { startTime: 250, duration: 100 }
entries.push(match1 as RumPerformanceResourceTiming, match2 as RumPerformanceResourceTiming)
const match1 = createResourceEntry({ startTime: 150, duration: 100 })
const match2 = createResourceEntry({ startTime: 200, duration: 100 })
entries.push(match1, match2)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

expect(timing).toEqual(undefined)
})

it('should not match multiple timings nested in the request', () => {
const match1: Partial<RumPerformanceResourceTiming> = { startTime: 100, duration: 100 }
const match2: Partial<RumPerformanceResourceTiming> = { startTime: 200, duration: 100 }
const match3: Partial<RumPerformanceResourceTiming> = { startTime: 300, duration: 100 }
entries.push(
match1 as RumPerformanceResourceTiming,
match2 as RumPerformanceResourceTiming,
match3 as RumPerformanceResourceTiming
)
const match1 = createResourceEntry({ startTime: 100, duration: 50 })
const match2 = createResourceEntry({ startTime: 150, duration: 50 })
const match3 = createResourceEntry({ startTime: 200, duration: 50 })
entries.push(match1, match2, match3)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

expect(timing).toEqual(undefined)
})

it('should match invalid timing nested in the request ', () => {
const match = createResourceEntry({
duration: 100,
fetchStart: 0,
startTime: 200,
})
entries.push(match)

const timing = matchRequestTiming(FAKE_REQUEST as RequestCompleteEvent)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RumPerformanceResourceTiming } from '../../../browser/performanceCollection'
import { RequestCompleteEvent } from '../../requestCollection'
import { toValidEntry } from './resourceUtils'

interface Timing {
startTime: number
Expand All @@ -14,6 +15,7 @@ interface Timing {
* - Browsers generate a timing entry for OPTIONS request
*
* Strategy:
* - from valid nested entries
* - if a single timing match, return the timing
* - if two following timings match (OPTIONS request), return the timing for the actual request
* - otherwise we can't decide, return undefined
Expand All @@ -22,11 +24,11 @@ export function matchRequestTiming(request: RequestCompleteEvent) {
if (!performance || !('getEntriesByName' in performance)) {
return
}
const candidates = (performance
const candidates = performance
.getEntriesByName(request.url, 'resource')
.filter((entry) =>
isBetween(entry, request.startTime, endTime(request))
) as unknown) as RumPerformanceResourceTiming[]
.map((entry) => entry.toJSON() as RumPerformanceResourceTiming)
.filter(toValidEntry)
.filter((entry) => isBetween(entry, request.startTime, endTime(request)))

if (candidates.length === 1) {
return candidates[0]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RequestType, ResourceType } from '@datadog/browser-core'
import { createResourceEntry } from '../../../../test/fixtures'
import { setup, TestSetupBuilder } from '../../../../test/specHelper'
import { RumPerformanceResourceTiming } from '../../../browser/performanceCollection'
import { RumEventCategory, RumResourceEvent } from '../../../types'
import { RumEventType, RumResourceEventV2 } from '../../../typesV2'
import { LifeCycleEventType } from '../../lifeCycle'
Expand Down Expand Up @@ -424,29 +424,6 @@ describe('resourceCollection V2', () => {
})
})

function createResourceEntry(details?: Partial<RumPerformanceResourceTiming>): RumPerformanceResourceTiming {
const entry: Partial<RumPerformanceResourceTiming> = {
connectEnd: 0,
connectStart: 0,
decodedBodySize: 0,
domainLookupEnd: 0,
domainLookupStart: 0,
duration: 100,
entryType: 'resource',
fetchStart: 0,
name: 'https://resource.com/valid',
redirectEnd: 0,
redirectStart: 0,
requestStart: 0,
responseEnd: 0,
responseStart: 0,
secureConnectionStart: 0,
startTime: 1234,
...details,
}
return entry as RumPerformanceResourceTiming
}

function createCompletedRequest(details?: Partial<RequestCompleteEvent>): RequestCompleteEvent {
const request: Partial<RequestCompleteEvent> = {
duration: 100,
Expand Down
103 changes: 61 additions & 42 deletions packages/rum/src/domain/rumEventsCollection/resource/resourceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,16 @@ export function computePerformanceResourceDuration(entry: RumPerformanceResource
export function computePerformanceResourceDetails(
entry: RumPerformanceResourceTiming
): PerformanceResourceDetails | undefined {
const validEntry = toValidEntry(entry)

if (!validEntry) {
return undefined
}
const {
startTime,
fetchStart,
redirectStart,
redirectEnd,
domainLookupStart,
domainLookupEnd,
connectStart,
Expand All @@ -100,47 +107,7 @@ export function computePerformanceResourceDetails(
requestStart,
responseStart,
responseEnd,
} = entry
let { redirectStart, redirectEnd } = entry

// Ensure timings are in the right order. On top of filtering out potential invalid
// RumPerformanceResourceTiming, it will ignore entries from requests where timings cannot be
// collected, for example cross origin requests without a "Timing-Allow-Origin" header allowing
// it.
if (
!areInOrder(
startTime,
fetchStart,
domainLookupStart,
domainLookupEnd,
connectStart,
connectEnd,
requestStart,
responseStart,
responseEnd
)
) {
return undefined
}

// The only time fetchStart is different than startTime is if a redirection occurred.
const hasRedirectionOccurred = fetchStart !== startTime

if (hasRedirectionOccurred) {
// Firefox doesn't provide redirect timings on cross origin requests. Provide a default for
// those.
if (redirectStart < startTime) {
redirectStart = startTime
}
if (redirectEnd < startTime) {
redirectEnd = fetchStart
}

// Make sure redirect timings are in order
if (!areInOrder(startTime, redirectStart, redirectEnd, fetchStart)) {
return undefined
}
}
} = validEntry

const details: PerformanceResourceDetails = {
download: formatTiming(startTime, responseStart, responseEnd),
Expand All @@ -162,13 +129,65 @@ export function computePerformanceResourceDetails(
details.dns = formatTiming(startTime, domainLookupStart, domainLookupEnd)
}

if (hasRedirectionOccurred) {
if (hasRedirection(entry)) {
details.redirect = formatTiming(startTime, redirectStart, redirectEnd)
}

return details
}

export function toValidEntry(entry: RumPerformanceResourceTiming) {
// Ensure timings are in the right order. On top of filtering out potential invalid
// RumPerformanceResourceTiming, it will ignore entries from requests where timings cannot be
// collected, for example cross origin requests without a "Timing-Allow-Origin" header allowing
// it.
if (
!areInOrder(
entry.startTime,
entry.fetchStart,
entry.domainLookupStart,
entry.domainLookupEnd,
entry.connectStart,
entry.connectEnd,
entry.requestStart,
entry.responseStart,
entry.responseEnd
)
) {
return undefined
}

if (!hasRedirection(entry)) {
return entry
}

let { redirectStart, redirectEnd } = entry
// Firefox doesn't provide redirect timings on cross origin requests.
// Provide a default for those.
if (redirectStart < entry.startTime) {
redirectStart = entry.startTime
}
if (redirectEnd < entry.startTime) {
redirectEnd = entry.fetchStart
}

// Make sure redirect timings are in order
if (!areInOrder(entry.startTime, redirectStart, redirectEnd, entry.fetchStart)) {
return undefined
}

return {
...entry,
redirectEnd,
redirectStart,
}
}

function hasRedirection(entry: RumPerformanceResourceTiming) {
// The only time fetchStart is different than startTime is if a redirection occurred.
return entry.fetchStart !== entry.startTime
}

function formatTiming(origin: number, start: number, end: number) {
return {
duration: msToNs(end - start),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createRawRumEvent } from '../../../../test/createRawRumEvent'
import { createRawRumEvent } from '../../../../test/fixtures'
import { setup, TestSetupBuilder } from '../../../../test/specHelper'
import { RumPerformanceNavigationTiming, RumPerformancePaintTiming } from '../../../browser/performanceCollection'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { combine, Context, ErrorSource, ResourceType } from '@datadog/browser-core'
import { RumPerformanceResourceTiming } from '../src/browser/performanceCollection'
import { ActionType } from '../src/domain/rumEventsCollection/action/trackActions'
import { ViewLoadingType } from '../src/domain/rumEventsCollection/view/trackViews'
import { RawRumEventV2, RumEventType } from '../src/typesV2'
Expand Down Expand Up @@ -76,3 +77,29 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR
)
}
}

export function createResourceEntry(
overrides?: Partial<RumPerformanceResourceTiming>
): RumPerformanceResourceTiming & PerformanceResourceTiming {
const entry: Partial<RumPerformanceResourceTiming & PerformanceResourceTiming> = {
connectEnd: 200,
connectStart: 200,
decodedBodySize: 200,
domainLookupEnd: 200,
domainLookupStart: 200,
duration: 100,
entryType: 'resource',
fetchStart: 200,
name: 'https://resource.com/valid',
redirectEnd: 200,
redirectStart: 200,
requestStart: 200,
responseEnd: 300,
responseStart: 200,
secureConnectionStart: 200,
startTime: 200,
...overrides,
}
entry.toJSON = () => entry
return entry as RumPerformanceResourceTiming & PerformanceResourceTiming
}