From bcd14bfc69377bc82880afc45813efa7acde0e70 Mon Sep 17 00:00:00 2001 From: Alberto Delgado Roda Date: Mon, 31 Jul 2023 09:44:51 +0200 Subject: [PATCH] feat(rum): add redirect span for same-origin redirect (#1400) Signed-off-by: Adrien Mannocci --- packages/rum-core/src/common/compress.js | 2 +- packages/rum-core/src/common/utils.js | 8 +- .../capture-navigation.js | 382 ------------------ .../src/performance-monitoring/metrics.js | 16 +- .../navigation/capture-navigation.js | 125 ++++++ .../navigation/marks.js | 127 ++++++ .../navigation/navigation-timing.js | 83 ++++ .../navigation/resource-timing.js | 101 +++++ .../navigation/user-timing.js | 52 +++ .../navigation/utils.js | 51 +++ .../transaction-service.js | 2 +- .../benchmarks/capture-navigation.bench.js | 2 +- packages/rum-core/test/common/context.spec.js | 4 +- packages/rum-core/test/index.js | 17 + .../capture-navigation.spec.js | 123 +++++- .../performance-monitoring/metrics.spec.js | 74 +++- packages/rum-core/test/utils/globals-mock.js | 18 + 17 files changed, 776 insertions(+), 411 deletions(-) delete mode 100644 packages/rum-core/src/performance-monitoring/capture-navigation.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/capture-navigation.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/marks.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/navigation-timing.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/resource-timing.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/user-timing.js create mode 100644 packages/rum-core/src/performance-monitoring/navigation/utils.js diff --git a/packages/rum-core/src/common/compress.js b/packages/rum-core/src/common/compress.js index 26b1e9d55..926faac32 100644 --- a/packages/rum-core/src/common/compress.js +++ b/packages/rum-core/src/common/compress.js @@ -27,7 +27,7 @@ import { Promise } from './polyfills' import { NAVIGATION_TIMING_MARKS, COMPRESSED_NAV_TIMING_MARKS -} from '../performance-monitoring/capture-navigation' +} from '../performance-monitoring/navigation/marks' import { isBeaconInspectionEnabled } from './utils' /** diff --git a/packages/rum-core/src/common/utils.js b/packages/rum-core/src/common/utils.js index bf2a7bc39..8f39faabc 100644 --- a/packages/rum-core/src/common/utils.js +++ b/packages/rum-core/src/common/utils.js @@ -431,6 +431,11 @@ function isBeaconInspectionEnabled() { return false } +// redirect info is only available for same-origin redirects +function isRedirectInfoAvailable(timing) { + return timing.redirectStart > 0 +} + export { extend, merge, @@ -470,5 +475,6 @@ export { isPerfTimelineSupported, isBrowser, isPerfTypeSupported, - isBeaconInspectionEnabled + isBeaconInspectionEnabled, + isRedirectInfoAvailable } diff --git a/packages/rum-core/src/performance-monitoring/capture-navigation.js b/packages/rum-core/src/performance-monitoring/capture-navigation.js deleted file mode 100644 index d64d71046..000000000 --- a/packages/rum-core/src/performance-monitoring/capture-navigation.js +++ /dev/null @@ -1,382 +0,0 @@ -/** - * MIT License - * - * Copyright (c) 2017-present, Elasticsearch BV - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - */ - -import Span from './span' -import { - RESOURCE_INITIATOR_TYPES, - MAX_SPAN_DURATION, - USER_TIMING_THRESHOLD, - PAGE_LOAD, - RESOURCE, - MEASURE -} from '../common/constants' -import { - stripQueryStringFromUrl, - PERF, - isPerfTimelineSupported -} from '../common/utils' -import { state } from '../state' - -/** - * Navigation Timing Spans - * - * eventPairs[0] -> start time of span - * eventPairs[1] -> end time of span - * eventPairs[2] -> name of the span - */ -const eventPairs = [ - ['domainLookupStart', 'domainLookupEnd', 'Domain lookup'], - ['connectStart', 'connectEnd', 'Making a connection to the server'], - ['requestStart', 'responseEnd', 'Requesting and receiving the document'], - [ - 'domLoading', - 'domInteractive', - 'Parsing the document, executing sync. scripts' - ], - [ - 'domContentLoadedEventStart', - 'domContentLoadedEventEnd', - 'Fire "DOMContentLoaded" event' - ], - ['loadEventStart', 'loadEventEnd', 'Fire "load" event'] -] - -/** - * start, end, baseTime - unsigned long long(PerformanceTiming) - * representing the moment, in milliseconds since the UNIX epoch - * - * trStart & trEnd - DOMHighResTimeStamp, measured in milliseconds. - * - * We have to convert the long values in milliseconds before doing the comparision - * eg: end - baseTime <= transactionEnd - */ -function shouldCreateSpan(start, end, trStart, trEnd, baseTime = 0) { - return ( - typeof start === 'number' && - typeof end === 'number' && - start >= baseTime && - end > start && - start - baseTime >= trStart && - end - baseTime <= trEnd && - end - start < MAX_SPAN_DURATION && - start - baseTime < MAX_SPAN_DURATION && - end - baseTime < MAX_SPAN_DURATION - ) -} - -function createNavigationTimingSpans(timings, baseTime, trStart, trEnd) { - const spans = [] - for (let i = 0; i < eventPairs.length; i++) { - const start = timings[eventPairs[i][0]] - const end = timings[eventPairs[i][1]] - - if (!shouldCreateSpan(start, end, trStart, trEnd, baseTime)) { - continue - } - const span = new Span(eventPairs[i][2], 'hard-navigation.browser-timing') - let data = null - /** - * - pageResponse flag is used to set the id of the span to - * `pageLoadSpanId` if set in config to make the distributed tracing work - * when HTML is genrated dynamically from backend agents - * - * - Populate the context.destination fields only for the Request Span - */ - if (eventPairs[i][0] === 'requestStart') { - span.pageResponse = true - data = { url: location.origin } - } - span._start = start - baseTime - span.end(end - baseTime, data) - spans.push(span) - } - return spans -} - -function createResourceTimingSpan(resourceTimingEntry) { - const { name, initiatorType, startTime, responseEnd } = resourceTimingEntry - let kind = 'resource' - if (initiatorType) { - kind += '.' + initiatorType - } - const spanName = stripQueryStringFromUrl(name) - const span = new Span(spanName, kind) - - span._start = startTime - span.end(responseEnd, { url: name, entry: resourceTimingEntry }) - return span -} - -/** - * Checks if the span is already captured via XHR/Fetch patch by - * comparing the given resource startTime(fetchStart) aganist the - * patch code execution time. - */ -function isCapturedByPatching(resourceStartTime, requestPatchTime) { - return requestPatchTime != null && resourceStartTime > requestPatchTime -} - -/** - * Check if the given url matches APM Server's Intake - * API endpoint and ignore it from Spans - */ -function isIntakeAPIEndpoint(url) { - return /intake\/v\d+\/rum\/events/.test(url) -} - -function createResourceTimingSpans(entries, requestPatchTime, trStart, trEnd) { - const spans = [] - for (let i = 0; i < entries.length; i++) { - const { initiatorType, name, startTime, responseEnd } = entries[i] - /** - * Skip span creation if initiatorType is other than known types specified as part of RESOURCE_INITIATOR_TYPES - * The reason being, there are other types like embed, video, audio, navigation etc - * - * Check the below webplatform test to know more - * https://github.com/web-platform-tests/wpt/blob/b0020d5df18998609b38786878f7a0b92cc680aa/resource-timing/resource_initiator_types.html#L93 - */ - if ( - RESOURCE_INITIATOR_TYPES.indexOf(initiatorType) === -1 || - name == null - ) { - continue - } - - /** - * Create Spans for API calls (XHR, Fetch) only if its not captured by the patch - * - * This would happen if our agent is downlaoded asyncrhonously and page does - * API requests before the agent patches the required modules. - */ - if ( - (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') && - (isIntakeAPIEndpoint(name) || - isCapturedByPatching(startTime, requestPatchTime)) - ) { - continue - } - - if (shouldCreateSpan(startTime, responseEnd, trStart, trEnd)) { - spans.push(createResourceTimingSpan(entries[i])) - } - } - return spans -} - -function createUserTimingSpans(entries, trStart, trEnd) { - const userTimingSpans = [] - for (let i = 0; i < entries.length; i++) { - const { name, startTime, duration } = entries[i] - const end = startTime + duration - - if ( - duration <= USER_TIMING_THRESHOLD || - !shouldCreateSpan(startTime, end, trStart, trEnd) - ) { - continue - } - const kind = 'app' - const span = new Span(name, kind) - span._start = startTime - span.end(end) - - userTimingSpans.push(span) - } - return userTimingSpans -} - -/** - * Navigation timing marks are reported only for page-load transactions - * - * Do not change the order of both NAVIGATION_TIMING_MARKS and - * COMPRESSED_NAV_TIMING_MARKS since compression of the fields are based on the - * order they are placed in the array - */ -const NAVIGATION_TIMING_MARKS = [ - 'fetchStart', - 'domainLookupStart', - 'domainLookupEnd', - 'connectStart', - 'connectEnd', - 'requestStart', - 'responseStart', - 'responseEnd', - 'domLoading', - 'domInteractive', - 'domContentLoadedEventStart', - 'domContentLoadedEventEnd', - 'domComplete', - 'loadEventStart', - 'loadEventEnd' -] - -const COMPRESSED_NAV_TIMING_MARKS = [ - 'fs', - 'ls', - 'le', - 'cs', - 'ce', - 'qs', - 'rs', - 're', - 'dl', - 'di', - 'ds', - 'de', - 'dc', - 'es', - 'ee' -] - -function getNavigationTimingMarks(timing) { - const { fetchStart, navigationStart, responseStart, responseEnd } = timing - /** - * Detect if NavigationTiming data is buggy and discard - * capturing navigation marks for the transaction - * - * https://bugs.webkit.org/show_bug.cgi?id=168057 - * https://bugs.webkit.org/show_bug.cgi?id=186919 - */ - if ( - fetchStart >= navigationStart && - responseStart >= fetchStart && - responseEnd >= responseStart - ) { - const marks = {} - NAVIGATION_TIMING_MARKS.forEach(function (timingKey) { - const m = timing[timingKey] - if (m && m >= fetchStart) { - marks[timingKey] = parseInt(m - fetchStart) - } - }) - return marks - } - return null -} - -function getPageLoadMarks(timing) { - const marks = getNavigationTimingMarks(timing) - if (marks == null) { - return null - } - return { - navigationTiming: marks, - agent: { - timeToFirstByte: marks.responseStart, - domInteractive: marks.domInteractive, - domComplete: marks.domComplete - } - } -} - -function captureNavigation(transaction) { - /** - * Do not capture timing related information when the - * flag is set to false, By default both page-load and route-change - * transactions set this flag to true - */ - if (!transaction.captureTimings) { - return - } - - /** - * Both start and end threshold decides if a span must be - * captured as part of the transaction - */ - const trEnd = transaction._end - /** - * Page load is considered as hard navigation and we account - * for few extra spans than soft navigations which - * happens on single page applications - */ - if (transaction.type === PAGE_LOAD) { - /** - * Adjust custom marks properly to fit in the transaction timeframe - */ - if (transaction.marks && transaction.marks.custom) { - const customMarks = transaction.marks.custom - Object.keys(customMarks).forEach(key => { - customMarks[key] += transaction._start - }) - } - /** - * must be zero otherwise the calculated relative _start time would be wrong - */ - const trStart = 0 - transaction._start = trStart - - const timings = PERF.timing - createNavigationTimingSpans( - timings, - timings.fetchStart, - trStart, - trEnd - ).forEach(span => { - span.traceId = transaction.traceId - span.sampled = transaction.sampled - if (span.pageResponse && transaction.options.pageLoadSpanId) { - span.id = transaction.options.pageLoadSpanId - } - transaction.spans.push(span) - }) - - /** - * Page load marks that are gathered from NavigationTiming API - */ - transaction.addMarks(getPageLoadMarks(timings)) - } - - if (isPerfTimelineSupported()) { - const trStart = transaction._start - /** - * Capture resource timing information as spans - */ - const resourceEntries = PERF.getEntriesByType(RESOURCE) - createResourceTimingSpans( - resourceEntries, - state.bootstrapTime, - trStart, - trEnd - ).forEach(span => transaction.spans.push(span)) - - /** - * Capture user timing measures as spans - */ - const userEntries = PERF.getEntriesByType(MEASURE) - createUserTimingSpans(userEntries, trStart, trEnd).forEach(span => - transaction.spans.push(span) - ) - } -} - -export { - getPageLoadMarks, - captureNavigation, - createNavigationTimingSpans, - createResourceTimingSpans, - createUserTimingSpans, - NAVIGATION_TIMING_MARKS, - COMPRESSED_NAV_TIMING_MARKS -} diff --git a/packages/rum-core/src/performance-monitoring/metrics.js b/packages/rum-core/src/performance-monitoring/metrics.js index 7afe2b253..9ca2a0500 100644 --- a/packages/rum-core/src/performance-monitoring/metrics.js +++ b/packages/rum-core/src/performance-monitoring/metrics.js @@ -30,7 +30,12 @@ import { FIRST_INPUT, LAYOUT_SHIFT } from '../common/constants' -import { noop, PERF, isPerfTypeSupported } from '../common/utils' +import { + noop, + PERF, + isPerfTypeSupported, + isRedirectInfoAvailable +} from '../common/utils' import Span from './span' export const metrics = { @@ -262,10 +267,17 @@ export function captureObserverEntries(list, { isHardNavigation, trStart }) { */ const timing = PERF.timing /** + * * To avoid capturing the unload event handler effect * as part of the page-load transaction duration */ - const unloadDiff = timing.fetchStart - timing.navigationStart + let unloadDiff = timing.fetchStart - timing.navigationStart + if (isRedirectInfoAvailable(timing)) { + // this makes sure the FCP startTime includes the redirect time + // otherwise the mark would not show up properly in the UI waterfall + unloadDiff = 0 + } + const fcpEntry = list.getEntriesByName(FIRST_CONTENTFUL_PAINT)[0] if (fcpEntry) { const fcp = parseInt( diff --git a/packages/rum-core/src/performance-monitoring/navigation/capture-navigation.js b/packages/rum-core/src/performance-monitoring/navigation/capture-navigation.js new file mode 100644 index 000000000..07e515fc6 --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/capture-navigation.js @@ -0,0 +1,125 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { + PERF, + isPerfTimelineSupported, + isRedirectInfoAvailable +} from '../../common/utils' +import { PAGE_LOAD, RESOURCE, MEASURE } from '../../common/constants' +import { state } from '../../state' +import { createNavigationTimingSpans } from './navigation-timing' +import { createUserTimingSpans } from './user-timing' +import { createResourceTimingSpans } from './resource-timing' +import { getPageLoadMarks } from './marks' + +function captureNavigation(transaction) { + /** + * Do not capture timing related information when the + * flag is set to false, By default both page-load and route-change + * transactions set this flag to true + */ + if (!transaction.captureTimings) { + return + } + + /** + * Both start and end threshold decides if a span must be + * captured as part of the transaction + */ + const trEnd = transaction._end + /** + * Page load is considered as hard navigation and we account + * for few extra spans than soft navigations which + * happens on single page applications + */ + if (transaction.type === PAGE_LOAD) { + /** + * Adjust custom marks properly to fit in the transaction timeframe + */ + if (transaction.marks && transaction.marks.custom) { + const customMarks = transaction.marks.custom + Object.keys(customMarks).forEach(key => { + customMarks[key] += transaction._start + }) + } + /** + * must be zero otherwise the calculated relative _start time would be wrong + */ + const trStart = 0 + transaction._start = trStart + + const timings = PERF.timing + const baseTime = isRedirectInfoAvailable(timings) + ? timings.redirectStart // make sure navigation spans will show up after the Redirect span + : timings.fetchStart + + createNavigationTimingSpans(timings, baseTime, trStart, trEnd).forEach( + span => { + span.traceId = transaction.traceId + span.sampled = transaction.sampled + if (span.pageResponse && transaction.options.pageLoadSpanId) { + span.id = transaction.options.pageLoadSpanId + } + transaction.spans.push(span) + } + ) + + /** + * Page load marks that are gathered from NavigationTiming API + */ + transaction.addMarks(getPageLoadMarks(timings)) + } + + if (isPerfTimelineSupported()) { + const trStart = transaction._start + /** + * Capture resource timing information as spans + */ + const resourceEntries = PERF.getEntriesByType(RESOURCE) + createResourceTimingSpans( + resourceEntries, + state.bootstrapTime, + trStart, + trEnd + ).forEach(span => transaction.spans.push(span)) + + /** + * Capture user timing measures as spans + */ + const userEntries = PERF.getEntriesByType(MEASURE) + createUserTimingSpans(userEntries, trStart, trEnd).forEach(span => + transaction.spans.push(span) + ) + } +} + +export { + captureNavigation, + createNavigationTimingSpans, + createResourceTimingSpans, + createUserTimingSpans, + getPageLoadMarks +} diff --git a/packages/rum-core/src/performance-monitoring/navigation/marks.js b/packages/rum-core/src/performance-monitoring/navigation/marks.js new file mode 100644 index 000000000..19387e0ca --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/marks.js @@ -0,0 +1,127 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { isRedirectInfoAvailable } from '../../common/utils' + +/** + * Navigation timing marks are reported only for page-load transactions + * + * Do not change the order of both NAVIGATION_TIMING_MARKS and + * COMPRESSED_NAV_TIMING_MARKS since compression of the fields are based on the + * order they are placed in the array + */ +const NAVIGATION_TIMING_MARKS = [ + 'fetchStart', + 'domainLookupStart', + 'domainLookupEnd', + 'connectStart', + 'connectEnd', + 'requestStart', + 'responseStart', + 'responseEnd', + 'domLoading', + 'domInteractive', + 'domContentLoadedEventStart', + 'domContentLoadedEventEnd', + 'domComplete', + 'loadEventStart', + 'loadEventEnd' +] + +const COMPRESSED_NAV_TIMING_MARKS = [ + 'fs', + 'ls', + 'le', + 'cs', + 'ce', + 'qs', + 'rs', + 're', + 'dl', + 'di', + 'ds', + 'de', + 'dc', + 'es', + 'ee' +] + +function getPageLoadMarks(timing) { + const marks = getNavigationTimingMarks(timing) + if (marks == null) { + return null + } + return { + navigationTiming: marks, + agent: { + timeToFirstByte: marks.responseStart, + domInteractive: marks.domInteractive, + domComplete: marks.domComplete + } + } +} + +function getNavigationTimingMarks(timing) { + const { + redirectStart, + fetchStart, + navigationStart, + responseStart, + responseEnd + } = timing + /** + * Detect if NavigationTiming data is buggy and discard + * capturing navigation marks for the transaction + * + * https://bugs.webkit.org/show_bug.cgi?id=168057 + * https://bugs.webkit.org/show_bug.cgi?id=186919 + */ + if ( + fetchStart >= navigationStart && + responseStart >= fetchStart && + responseEnd >= responseStart + ) { + const marks = {} + NAVIGATION_TIMING_MARKS.forEach(function (timingKey) { + const m = timing[timingKey] + if (m && m >= fetchStart) { + if (isRedirectInfoAvailable(timing)) { + // make sure navigation marks will show up after the Redirect span + marks[timingKey] = parseInt(m - redirectStart) + } else { + marks[timingKey] = parseInt(m - fetchStart) + } + } + }) + return marks + } + return null +} + +export { + getPageLoadMarks, + NAVIGATION_TIMING_MARKS, + COMPRESSED_NAV_TIMING_MARKS +} diff --git a/packages/rum-core/src/performance-monitoring/navigation/navigation-timing.js b/packages/rum-core/src/performance-monitoring/navigation/navigation-timing.js new file mode 100644 index 000000000..898ec4bcf --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/navigation-timing.js @@ -0,0 +1,83 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { shouldCreateSpan } from './utils' +import Span from '../span' + +/** + * Navigation Timing Spans + * + * eventPairs[0] -> start time of span + * eventPairs[1] -> end time of span + * eventPairs[2] -> name of the span + */ +const eventPairs = [ + ['redirectStart', 'redirectEnd', 'Redirect'], + ['domainLookupStart', 'domainLookupEnd', 'Domain lookup'], + ['connectStart', 'connectEnd', 'Making a connection to the server'], + ['requestStart', 'responseEnd', 'Requesting and receiving the document'], + [ + 'domLoading', + 'domInteractive', + 'Parsing the document, executing sync. scripts' + ], + [ + 'domContentLoadedEventStart', + 'domContentLoadedEventEnd', + 'Fire "DOMContentLoaded" event' + ], + ['loadEventStart', 'loadEventEnd', 'Fire "load" event'] +] + +function createNavigationTimingSpans(timings, baseTime, trStart, trEnd) { + const spans = [] + for (let i = 0; i < eventPairs.length; i++) { + const start = timings[eventPairs[i][0]] + const end = timings[eventPairs[i][1]] + + if (!shouldCreateSpan(start, end, trStart, trEnd, baseTime)) { + continue + } + const span = new Span(eventPairs[i][2], 'hard-navigation.browser-timing') + let data = null + /** + * - pageResponse flag is used to set the id of the span to + * `pageLoadSpanId` if set in config to make the distributed tracing work + * when HTML is genrated dynamically from backend agents + * + * - Populate the context.destination fields only for the Request Span + */ + if (eventPairs[i][0] === 'requestStart') { + span.pageResponse = true + data = { url: location.origin } + } + span._start = start - baseTime + span.end(end - baseTime, data) + spans.push(span) + } + return spans +} + +export { createNavigationTimingSpans } diff --git a/packages/rum-core/src/performance-monitoring/navigation/resource-timing.js b/packages/rum-core/src/performance-monitoring/navigation/resource-timing.js new file mode 100644 index 000000000..f311d42aa --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/resource-timing.js @@ -0,0 +1,101 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { stripQueryStringFromUrl } from '../../common/utils' +import { shouldCreateSpan } from './utils' +import { RESOURCE_INITIATOR_TYPES } from '../../common/constants' +import Span from '../span' + +function createResourceTimingSpan(resourceTimingEntry) { + const { name, initiatorType, startTime, responseEnd } = resourceTimingEntry + let kind = 'resource' + if (initiatorType) { + kind += '.' + initiatorType + } + const spanName = stripQueryStringFromUrl(name) + const span = new Span(spanName, kind) + + span._start = startTime + span.end(responseEnd, { url: name, entry: resourceTimingEntry }) + return span +} + +/** + * Checks if the span is already captured via XHR/Fetch patch by + * comparing the given resource startTime(fetchStart) aganist the + * patch code execution time. + */ +function isCapturedByPatching(resourceStartTime, requestPatchTime) { + return requestPatchTime != null && resourceStartTime > requestPatchTime +} + +/** + * Check if the given url matches APM Server's Intake + * API endpoint and ignore it from Spans + */ +function isIntakeAPIEndpoint(url) { + return /intake\/v\d+\/rum\/events/.test(url) +} + +function createResourceTimingSpans(entries, requestPatchTime, trStart, trEnd) { + const spans = [] + for (let i = 0; i < entries.length; i++) { + const { initiatorType, name, startTime, responseEnd } = entries[i] + /** + * Skip span creation if initiatorType is other than known types specified as part of RESOURCE_INITIATOR_TYPES + * The reason being, there are other types like embed, video, audio, navigation etc + * + * Check the below webplatform test to know more + * https://github.com/web-platform-tests/wpt/blob/b0020d5df18998609b38786878f7a0b92cc680aa/resource-timing/resource_initiator_types.html#L93 + */ + if ( + RESOURCE_INITIATOR_TYPES.indexOf(initiatorType) === -1 || + name == null + ) { + continue + } + + /** + * Create Spans for API calls (XHR, Fetch) only if its not captured by the patch + * + * This would happen if our agent is downlaoded asyncrhonously and page does + * API requests before the agent patches the required modules. + */ + if ( + (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') && + (isIntakeAPIEndpoint(name) || + isCapturedByPatching(startTime, requestPatchTime)) + ) { + continue + } + + if (shouldCreateSpan(startTime, responseEnd, trStart, trEnd)) { + spans.push(createResourceTimingSpan(entries[i])) + } + } + return spans +} + +export { createResourceTimingSpans } diff --git a/packages/rum-core/src/performance-monitoring/navigation/user-timing.js b/packages/rum-core/src/performance-monitoring/navigation/user-timing.js new file mode 100644 index 000000000..9ae9064de --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/user-timing.js @@ -0,0 +1,52 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { USER_TIMING_THRESHOLD } from '../../common/constants' +import { shouldCreateSpan } from './utils' +import Span from '../span' + +function createUserTimingSpans(entries, trStart, trEnd) { + const userTimingSpans = [] + for (let i = 0; i < entries.length; i++) { + const { name, startTime, duration } = entries[i] + const end = startTime + duration + + if ( + duration <= USER_TIMING_THRESHOLD || + !shouldCreateSpan(startTime, end, trStart, trEnd) + ) { + continue + } + const kind = 'app' + const span = new Span(name, kind) + span._start = startTime + span.end(end) + + userTimingSpans.push(span) + } + return userTimingSpans +} + +export { createUserTimingSpans } diff --git a/packages/rum-core/src/performance-monitoring/navigation/utils.js b/packages/rum-core/src/performance-monitoring/navigation/utils.js new file mode 100644 index 000000000..e961a080b --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/navigation/utils.js @@ -0,0 +1,51 @@ +/** + * MIT License + * + * Copyright (c) 2017-present, Elasticsearch BV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { MAX_SPAN_DURATION } from '../../common/constants' + +/** + * start, end, baseTime - unsigned long long(PerformanceTiming) + * representing the moment, in milliseconds since the UNIX epoch + * + * trStart & trEnd - DOMHighResTimeStamp, measured in milliseconds. + * + * We have to convert the long values in milliseconds before doing the comparision + * eg: end - baseTime <= transactionEnd + */ +function shouldCreateSpan(start, end, trStart, trEnd, baseTime = 0) { + return ( + typeof start === 'number' && + typeof end === 'number' && + start >= baseTime && + end > start && + start - baseTime >= trStart && + end - baseTime <= trEnd && + end - start < MAX_SPAN_DURATION && + start - baseTime < MAX_SPAN_DURATION && + end - baseTime < MAX_SPAN_DURATION + ) +} + +export { shouldCreateSpan } diff --git a/packages/rum-core/src/performance-monitoring/transaction-service.js b/packages/rum-core/src/performance-monitoring/transaction-service.js index 36fc69f50..4116a0a2b 100644 --- a/packages/rum-core/src/performance-monitoring/transaction-service.js +++ b/packages/rum-core/src/performance-monitoring/transaction-service.js @@ -39,7 +39,7 @@ import { isPerfTypeSupported, generateRandomId } from '../common/utils' -import { captureNavigation } from './capture-navigation' +import { captureNavigation } from './navigation/capture-navigation' import { PAGE_LOAD, NAME_UNKNOWN, diff --git a/packages/rum-core/test/benchmarks/capture-navigation.bench.js b/packages/rum-core/test/benchmarks/capture-navigation.bench.js index a5eaa10ff..9724746fb 100644 --- a/packages/rum-core/test/benchmarks/capture-navigation.bench.js +++ b/packages/rum-core/test/benchmarks/capture-navigation.bench.js @@ -22,7 +22,7 @@ * THE SOFTWARE. * */ -import { captureNavigation } from '../../src/performance-monitoring/capture-navigation' +import { captureNavigation } from '../../src/performance-monitoring/navigation/capture-navigation' import Transaction from '../../src/performance-monitoring/transaction' import { PAGE_LOAD, ROUTE_CHANGE } from '../../src/common/constants' diff --git a/packages/rum-core/test/common/context.spec.js b/packages/rum-core/test/common/context.spec.js index 023870ecf..f1c73eebe 100644 --- a/packages/rum-core/test/common/context.spec.js +++ b/packages/rum-core/test/common/context.spec.js @@ -211,7 +211,7 @@ describe('Context', () => { ...trContext }) - const unmock = mockGetEntriesByType() + const unMock = mockGetEntriesByType() const pageloadTr = new Transaction('test', PAGE_LOAD) pageloadTr.end() addTransactionContext(pageloadTr, configContext) @@ -231,6 +231,6 @@ describe('Context', () => { ...userContext }) - unmock() + unMock() }) }) diff --git a/packages/rum-core/test/index.js b/packages/rum-core/test/index.js index 1498c2ad1..9d9d9e576 100644 --- a/packages/rum-core/test/index.js +++ b/packages/rum-core/test/index.js @@ -27,6 +27,7 @@ import { createServiceFactory as originalFactory } from '../src' import Transaction from '../src/performance-monitoring/transaction' import { captureBreakdown } from '../src/performance-monitoring/breakdown' import { APM_SERVER } from '@elastic/apm-rum-core' +import { mockPerformanceTimingEntries } from './utils/globals-mock' export function createServiceFactory() { var serviceFactory = originalFactory() @@ -112,3 +113,19 @@ export function generateErrors(count) { } return result } + +// IE11 and Android 4.0 don't allow to monkey patch window.performance.timing API +export function canMockPerfTimingApi() { + const anyValue = 567 + const unMock = mockPerformanceTimingEntries({ + redirectStart: anyValue + }) + + const redirectStart = performance.timing.redirectStart + if (redirectStart != anyValue) { + return false + } + + unMock() + return true +} diff --git a/packages/rum-core/test/performance-monitoring/capture-navigation.spec.js b/packages/rum-core/test/performance-monitoring/capture-navigation.spec.js index 00baa12d3..9d07a72f9 100644 --- a/packages/rum-core/test/performance-monitoring/capture-navigation.spec.js +++ b/packages/rum-core/test/performance-monitoring/capture-navigation.spec.js @@ -23,21 +23,27 @@ * */ +import * as navTiming from '../../src/performance-monitoring/navigation/navigation-timing' import { + captureNavigation, createNavigationTimingSpans, - createResourceTimingSpans, createUserTimingSpans, - captureNavigation, + createResourceTimingSpans, getPageLoadMarks -} from '../../src/performance-monitoring/capture-navigation' +} from '../../src/performance-monitoring/navigation/capture-navigation' import Transaction from '../../src/performance-monitoring/transaction' import { PAGE_LOAD, ROUTE_CHANGE } from '../../src/common/constants' +import { spyOnFunction } from '../../../../dev-utils/jasmine' import { extend } from '../../src/common/utils' import resourceEntries from '../fixtures/resource-entries' import userTimingEntries from '../fixtures/user-timing-entries' import navTimingSpans from '../fixtures/navigation-timing-span-snapshot' import { TIMING_LEVEL1_ENTRY as timings } from '../fixtures/navigation-entries' -import { mockGetEntriesByType } from '../utils/globals-mock' +import { canMockPerfTimingApi } from '../' +import { + mockGetEntriesByType, + mockPerformanceTimingEntries +} from '../utils/globals-mock' const spanSnapshot = navTimingSpans.map(mapSpan) @@ -151,7 +157,7 @@ describe('Capture hard navigation', function () { ]) }) - it('should populate desination context only for requestStart span', () => { + it('should populate destination context only for requestStart span', () => { const spans = createNavigationTimingSpans( timings, timings.fetchStart, @@ -312,7 +318,7 @@ describe('Capture hard navigation', function () { }) it('should capture resource/user timing spans for soft navigation', function () { - const unmock = mockGetEntriesByType() + const unMock = mockGetEntriesByType() const tr = new Transaction('test', ROUTE_CHANGE) tr.captureTimings = true const xhrSpan = tr.startSpan('GET http://example.com', 'external.http') @@ -328,11 +334,11 @@ describe('Capture hard navigation', function () { span.type === 'app' ) expect(foundSpans.length).toBeGreaterThanOrEqual(3) - unmock() + unMock() }) it('should capture resource/user timings when captureTimings flag is set', function () { - const unmock = mockGetEntriesByType() + const unMock = mockGetEntriesByType() const tr = new Transaction('test', 'test') tr.captureTimings = true tr._start = transactionStart @@ -343,7 +349,7 @@ describe('Capture hard navigation', function () { span => span.type === 'resource' || span.type === 'app' ) expect(foundSpans.length).toBeGreaterThanOrEqual(2) - unmock() + unMock() }) it('should capture agent marks in page load transaction', function () { @@ -423,6 +429,105 @@ describe('Capture hard navigation', function () { ) }) + describe('when there is redirection available', () => { + it('should create Redirect span when calculating createNavigationTimingSpans', function () { + // Add redirect info + const timingObj = { + ...timings, + redirectStart: 1572362095000, + redirectEnd: 1572362095181 + } + let spans = createNavigationTimingSpans( + timingObj, + timingObj.redirectStart, + transactionStart, + transactionEnd + ) + + expect(spans.map(mapSpan)).toEqual([ + { name: 'Redirect', _end: 181, _start: 0 }, + { name: 'Domain lookup', _end: 201, _start: 182 }, + { name: 'Making a connection to the server', _end: 269, _start: 201 }, + { + name: 'Requesting and receiving the document', + _end: 390, + _start: 270 + }, + { + name: 'Parsing the document, executing sync. scripts', + _end: 723, + _start: 346 + }, + { name: 'Fire "DOMContentLoaded" event', _end: 835, _start: 815 }, + { name: 'Fire "load" event', _end: 1145, _start: 1143 } + ]) + }) + + it('should include the redirect duration when calculating load navigation marks', function () { + // Add redirect info + const timingObj = { + ...timings, + redirectStart: 1572362095000, + redirectEnd: 1572362095181 + } + const marks = getPageLoadMarks(timingObj) + expect(marks.navigationTiming).toEqual( + jasmine.objectContaining({ + responseEnd: 390, + domInteractive: 723, + domComplete: 1143 + }) + ) + }) + }) + + if (canMockPerfTimingApi()) { + describe('baseTime when calculating createNavigationTimingSpans', function () { + ;[ + { + name: + 'should use redirectStart as a baseTime when there is redirection', + redirectStart: 1, + redirectEnd: 2, + expected: 'redirectStart' + }, + { + name: + 'should use fetchStart as a baseTime when there is no redirection', + redirectStart: 0, + redirectEnd: 0, + expected: 'fetchStart' + } + ].forEach(({ name, redirectStart, redirectEnd, expected }) => { + it(name, function () { + const unMock = mockPerformanceTimingEntries({ + redirectStart, + redirectEnd + }) + + const navTimingSpansSpy = spyOnFunction( + navTiming, + 'createNavigationTimingSpans' + ).and.callThrough() + + const tr = new Transaction('test', PAGE_LOAD) + tr.captureTimings = true + tr.end() + captureNavigation(tr) + + expect(navTimingSpansSpy).toHaveBeenCalledWith( + performance.timing, // timings + performance.timing[expected], // baseTime + 0, // transaction start + tr._end // transaction end + ) + + unMock() + }) + }) + }) + } + describe('Buggy Navigation Timing data', () => { it('when timestamps are 0-based instead of unix epoch', () => { /** diff --git a/packages/rum-core/test/performance-monitoring/metrics.spec.js b/packages/rum-core/test/performance-monitoring/metrics.spec.js index cdee8b5a8..0348e902c 100644 --- a/packages/rum-core/test/performance-monitoring/metrics.spec.js +++ b/packages/rum-core/test/performance-monitoring/metrics.spec.js @@ -36,10 +36,13 @@ import { LARGEST_CONTENTFUL_PAINT, LONG_TASK } from '../../src/common/constants' import { isPerfTypeSupported } from '../../src/common/utils' import { mockObserverEntryTypes, - mockObserverEntryNames + mockObserverEntryNames, + mockPerformanceTimingEntries } from '../utils/globals-mock' import longtaskEntries from '../fixtures/longtask-entries' import fidEntries from '../fixtures/fid-entries' +import { fcpEntries } from '../fixtures/paint-entries' +import { canMockPerfTimingApi } from '..' describe('Metrics', () => { describe('PerfEntryRecorder', () => { @@ -96,19 +99,66 @@ describe('Metrics', () => { }) }) - it('should set firstContentfulPaint if isHardNavigation is true ', () => { - list.getEntriesByName.and.callFake(mockObserverEntryNames) - const { marks: notHardNavigation } = captureObserverEntries(list, { - isHardNavigation: false - }) - expect(notHardNavigation).toEqual({}) + describe('firstContentfulPaint', () => { + it('should set FCP if isHardNavigation is true ', () => { + list.getEntriesByName.and.callFake(mockObserverEntryNames) + const { marks: notHardNavigation } = captureObserverEntries(list, { + isHardNavigation: false + }) + expect(notHardNavigation).toEqual({}) - const { marks: hardNavigation } = captureObserverEntries(list, { - isHardNavigation: true - }) - expect(hardNavigation).toEqual({ - firstContentfulPaint: jasmine.any(Number) + const { marks: hardNavigation } = captureObserverEntries(list, { + isHardNavigation: true + }) + expect(hardNavigation).toEqual({ + firstContentfulPaint: jasmine.any(Number) + }) }) + + if (canMockPerfTimingApi()) { + ;[ + { + name: + 'should subtract the unload event duration from FCP when there is no redirection', + navigationEntries: { + navigationStart: 0, + fetchStart: 30 + }, + originalFcp: 200, + expectedFcp: 170 + }, + { + name: 'should keep the FCP as it is when there is a redirection', + navigationEntries: { + navigationStart: 0, + redirectStart: 20, + fetchStart: 30 + }, + originalFcp: 200, + expectedFcp: 200 + } + ].forEach(({ name, navigationEntries, originalFcp, expectedFcp }) => { + it(name, () => { + const originalFcpEntry = fcpEntries[0] + // fcp startTime + fcpEntries[0].startTime = originalFcp + + // unload event duration (timing.fetchStart - timing.navigationStart) + const unMock = mockPerformanceTimingEntries(navigationEntries) + + list.getEntriesByName.and.callFake(mockObserverEntryNames) + const { marks } = captureObserverEntries(list, { + isHardNavigation: true + }) + expect(marks).toEqual({ + firstContentfulPaint: expectedFcp + }) + + fcpEntries[0] = originalFcpEntry + unMock() + }) + }) + } }) it('should create long tasks attribution data in span context', () => { diff --git a/packages/rum-core/test/utils/globals-mock.js b/packages/rum-core/test/utils/globals-mock.js index 0d7b0ab52..5cc7baddf 100644 --- a/packages/rum-core/test/utils/globals-mock.js +++ b/packages/rum-core/test/utils/globals-mock.js @@ -26,6 +26,7 @@ import { fcpEntries } from '../fixtures/paint-entries' import resourceEntries from '../fixtures/resource-entries' import userTimingEntries from '../fixtures/user-timing-entries' +import { TIMING_LEVEL1_ENTRY as timings } from '../fixtures/navigation-entries' import { TIMING_LEVEL2_ENTRIES } from '../fixtures/navigation-entries' import longtaskEntries from '../fixtures/longtask-entries' import largestContentfulPaintEntries from '../fixtures/lcp-entries' @@ -78,3 +79,20 @@ export function mockGetEntriesByType() { window.performance.getEntriesByType = _getEntriesByType } } + +export function mockPerformanceTimingEntries(customEntries = {}) { + const perfTiming = window.performance.timing + const timingsObj = { + ...timings, + ...customEntries + } + + Object.defineProperty(window.performance, 'timing', { + writable: true, + value: timingsObj + }) + + return function unMock() { + window.performance.timing = perfTiming + } +}