Skip to content

Commit

Permalink
[RUMF-766] prevent request duration override by wrong matching timing (
Browse files Browse the repository at this point in the history
…#604)

Reduce risk of matching error by not considering invalid or incomplete timing due to cross origin restrictions.
  • Loading branch information
bcaudan committed Nov 5, 2020
1 parent b84be22 commit c4f7e92
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 93 deletions.
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
}

0 comments on commit c4f7e92

Please sign in to comment.