diff --git a/.ci/scripts/test.sh b/.ci/scripts/test.sh index 39ce3b2bf..3be2fc592 100755 --- a/.ci/scripts/test.sh +++ b/.ci/scripts/test.sh @@ -6,6 +6,11 @@ APM_SERVER_PORT=${APM_SERVER_PORT:-"8200"} APM_SERVER_URL=${APM_SERVER_URL:-"http://apm-server:8200"} KIBANA_URL=${KIBANA_URL:-"http://kibana:5601"} +# As long as there are issues with the ssl lets use 6.1.3 +# see https://github.com/docker/docker-py/issues/3194 +pip3 uninstall docker +pip3 install docker==6.1.3 + pip install docker-compose>=1.25.4 # Tests are run within the node-puppeteer container and can fails. diff --git a/dev-utils/test-config.js b/dev-utils/test-config.js index cd2a8caa5..81f0a60a5 100644 --- a/dev-utils/test-config.js +++ b/dev-utils/test-config.js @@ -172,7 +172,8 @@ function getBrowserList(pkg = 'default') { }, { browserName: 'firefox', - browserVersion: 'latest', + // beware that if we update to 99 or more we will need to cope with https://github.com/karma-runner/karma-sauce-launcher/issues/275 + browserVersion: '98', platformName: 'Windows 10', 'sauce:options': { geckodriverVersion: '0.30.0' // reason: https://github.com/karma-runner/karma-sauce-launcher/issues/275 diff --git a/packages/rum-core/src/common/constants.js b/packages/rum-core/src/common/constants.js index b3df4fac3..99eaf6a4b 100644 --- a/packages/rum-core/src/common/constants.js +++ b/packages/rum-core/src/common/constants.js @@ -74,6 +74,7 @@ const USER_INTERACTION = 'user-interaction' const HTTP_REQUEST_TYPE = 'http-request' const TEMPORARY_TYPE = 'temporary' const NAME_UNKNOWN = 'Unknown' +const PAGE_EXIT = 'page-exit' const TRANSACTION_TYPE_ORDER = [ PAGE_LOAD, @@ -109,6 +110,7 @@ const TRANSACTION_END = 'transaction:end' const CONFIG_CHANGE = 'config:change' const QUEUE_FLUSH = 'queue:flush' const QUEUE_ADD_TRANSACTION = 'queue:add_transaction' +const TRANSACTION_IGNORE = 'transaction:ignore' /** * Events types that are used to toggle auto instrumentations @@ -144,6 +146,7 @@ const FIRST_CONTENTFUL_PAINT = 'first-contentful-paint' const LARGEST_CONTENTFUL_PAINT = 'largest-contentful-paint' const FIRST_INPUT = 'first-input' const LAYOUT_SHIFT = 'layout-shift' +const EVENT = 'event' /** * Event types sent to APM Server on the queue @@ -193,6 +196,7 @@ export { PAGE_LOAD, ROUTE_CHANGE, NAME_UNKNOWN, + PAGE_EXIT, TYPE_CUSTOM, USER_TIMING_THRESHOLD, TRANSACTION_START, @@ -200,6 +204,7 @@ export { CONFIG_CHANGE, QUEUE_FLUSH, QUEUE_ADD_TRANSACTION, + TRANSACTION_IGNORE, XMLHTTPREQUEST, FETCH, HISTORY, @@ -232,6 +237,7 @@ export { TRUNCATED_TYPE, FIRST_INPUT, LAYOUT_SHIFT, + EVENT, OUTCOME_SUCCESS, OUTCOME_FAILURE, OUTCOME_UNKNOWN, diff --git a/packages/rum-core/src/common/context.js b/packages/rum-core/src/common/context.js index b6633083c..437892f28 100644 --- a/packages/rum-core/src/common/context.js +++ b/packages/rum-core/src/common/context.js @@ -24,7 +24,7 @@ */ import { Url } from './url' -import { PAGE_LOAD, NAVIGATION } from './constants' +import { PAGE_LOAD, PAGE_EXIT, NAVIGATION } from './constants' import { getServerTimingInfo, PERF, isPerfTimelineSupported } from './utils' const LEFT_SQUARE_BRACKET = 91 // [ @@ -186,7 +186,13 @@ export function addTransactionContext( ) { const pageContext = getPageContext() let responseContext = {} - if (transaction.type === PAGE_LOAD && isPerfTimelineSupported()) { + + if (transaction.type === PAGE_EXIT) { + transaction.ensureContext() + if (transaction.context.page && transaction.context.page.url) { + pageContext.page.url = transaction.context.page.url + } + } else if (transaction.type === PAGE_LOAD && isPerfTimelineSupported()) { let entries = PERF.getEntriesByType(NAVIGATION) if (entries && entries.length > 0) { responseContext = { diff --git a/packages/rum-core/src/common/observers/page-visibility.js b/packages/rum-core/src/common/observers/page-visibility.js index 9e84c32e1..6d928bb5c 100644 --- a/packages/rum-core/src/common/observers/page-visibility.js +++ b/packages/rum-core/src/common/observers/page-visibility.js @@ -23,9 +23,14 @@ * */ -import { QUEUE_ADD_TRANSACTION, QUEUE_FLUSH } from '../constants' +import { + QUEUE_ADD_TRANSACTION, + QUEUE_FLUSH, + TRANSACTION_IGNORE +} from '../constants' import { state } from '../../state' import { now } from '../utils' +import { reportInp } from '../../performance-monitoring/metrics/inp/report' /** * @param configService @@ -70,22 +75,60 @@ export function observePageVisibility(configService, transactionService) { } /** - * Ends an ongoing transaction if any and flushes the events queue + * Executes when page becomes hidden * @param configService * @param transactionService */ function onPageHidden(configService, transactionService) { - const tr = transactionService.getCurrentTransaction() - if (tr) { + const inpTr = reportInp(transactionService) + // we don't want to flush the queue for every transaction that is ended when page becomes hidden + // as transaction ends are scheduled async as microtasks(promise), + // so we are coordinating the flushing process + if (inpTr) { const unobserve = configService.observeEvent(QUEUE_ADD_TRANSACTION, () => { - configService.dispatchEvent(QUEUE_FLUSH) - state.lastHiddenStart = now() - - // unsubscribe listener to execute it only once. - // otherwise in SPAs we might be finding situations where we would be executing - // this logic as many times as we observed to this event + // At this point the INP transaction is in the queue + // so let's end the managed transaction if any and flush + endManagedTransaction(configService, transactionService) unobserve() }) + } else { + // In the absence of INP transaction + // let's just end the managed transaction if any and flush + endManagedTransaction(configService, transactionService) + } +} + +// Ends an ongoing managed transaction if any and flushes the events queue +function endManagedTransaction(configService, transactionService) { + const tr = transactionService.getCurrentTransaction() + if (tr) { + // Make sure that we still update lastHiddenStart if the managed transaction + // ends up being discarded + const unobserveDiscard = configService.observeEvent( + TRANSACTION_IGNORE, + () => { + state.lastHiddenStart = now() + + // unsubscribe listeners to execute it only once. + // otherwise in SPAs we might be finding situations where we would be executing + // this logic as many times as we observed to this event + unobserveDiscard() + unobserveQueueAdd() + } + ) + const unobserveQueueAdd = configService.observeEvent( + QUEUE_ADD_TRANSACTION, + () => { + configService.dispatchEvent(QUEUE_FLUSH) + state.lastHiddenStart = now() + + // unsubscribe listeners to execute it only once. + // otherwise in SPAs we might be finding situations where we would be executing + // this logic as many times as we observed to this event + unobserveQueueAdd() + unobserveDiscard() + } + ) tr.end() } else { diff --git a/packages/rum-core/src/common/utils.js b/packages/rum-core/src/common/utils.js index 8f39faabc..bb4ab85ea 100644 --- a/packages/rum-core/src/common/utils.js +++ b/packages/rum-core/src/common/utils.js @@ -401,6 +401,10 @@ function isPerfTypeSupported(type) { ) } +function isPerfInteractionCountSupported() { + return 'interactionCount' in performance +} + /** * The goal of this is to make sure that HAR files * can be created containing beacons with the payload readable @@ -475,6 +479,7 @@ export { isPerfTimelineSupported, isBrowser, isPerfTypeSupported, + isPerfInteractionCountSupported, isBeaconInspectionEnabled, isRedirectInfoAvailable } diff --git a/packages/rum-core/src/index.js b/packages/rum-core/src/index.js index 4d5efd001..380ecb8c2 100644 --- a/packages/rum-core/src/index.js +++ b/packages/rum-core/src/index.js @@ -26,7 +26,10 @@ // export public core APIs. import { registerServices as registerErrorServices } from './error-logging' -import { registerServices as registerPerfServices } from './performance-monitoring' +import { + registerServices as registerPerfServices, + observeUserInteractions +} from './performance-monitoring' import { ServiceFactory } from './common/service-factory' import { isPlatformSupported, @@ -84,6 +87,7 @@ export { ERROR_LOGGING, EVENT_TARGET, CLICK, + observeUserInteractions, bootstrap, observePageVisibility, observePageClicks diff --git a/packages/rum-core/src/performance-monitoring/index.js b/packages/rum-core/src/performance-monitoring/index.js index 42aa4b294..4b838c15e 100644 --- a/packages/rum-core/src/performance-monitoring/index.js +++ b/packages/rum-core/src/performance-monitoring/index.js @@ -34,6 +34,8 @@ import { } from '../common/constants' import { serviceCreators } from '../common/service-factory' +import { observeUserInteractions } from './metrics/inp/process' +import { reportInp } from './metrics/inp/report' function registerServices() { serviceCreators[TRANSACTION_SERVICE] = serviceFactory => { @@ -65,4 +67,4 @@ function registerServices() { } } -export { registerServices } +export { registerServices, observeUserInteractions, reportInp } diff --git a/packages/rum-core/src/performance-monitoring/metrics/inp/process.js b/packages/rum-core/src/performance-monitoring/metrics/inp/process.js new file mode 100644 index 000000000..838031055 --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/metrics/inp/process.js @@ -0,0 +1,243 @@ +/** + * 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 { EVENT, FIRST_INPUT } from '../../../common/constants' +import { isPerfInteractionCountSupported } from '../../../common/utils' +import { PerfEntryRecorder } from '../metrics' + +// Threshold recommended by Google +const INP_THRESHOLD = 40 + +// The Google guideline is just to track the 10 longest interactions as INP candidates +const MAX_INTERACTIONS_TO_CONSIDER = 10 + +export const inpState = { + minInteractionId: Infinity, + maxInteractionId: 0, + interactionCount: 0, + longestInteractions: [] +} + +/** + * Observes Event Timing API entries + */ +export function observeUserInteractions( + recorder = new PerfEntryRecorder(processUserInteractions) +) { + const isPerfCountSupported = isPerfInteractionCountSupported() + /** + * We set the lowest threshold (16) supported by browsers + * this way we can estimate the total number of different user interactions which is essential to calculate P98, among other things. + * Once browsers start exposing the `performance.interactionCount` property the + * threshold will be 40, which is the value Google recommends as the default threshold + * https://github.com/GoogleChrome/web-vitals/blob/3806160ffbc93c3c4abf210a167b81228172b31c/src/onINP.ts#L203 + */ + const durationThreshold = isPerfCountSupported ? INP_THRESHOLD : 16 // lowest threshold supported by browsers + + recorder.start(EVENT, { + buffered: true, + durationThreshold + }) + /** + * first-input is also taken into consideration for calculating INP. + * Currently, there is a bug in Chrome which causes the first-input entry to have interactionId set to 0 + * After internal investigation it has been found that only the ones triggered by a `pointerdown` + * have a valid interactionId. Bug: https://bugs.chromium.org/p/chromium/issues/detail?id=1325826 + * This is not particularly important given that the only usage for first-input is to be an "extra" + * detector that checks if there has been a interaction with a duration less than 16ms. + * Once `performance.interactionCount` is exposed, this logic will not be useful anymore + */ + if (!isPerfCountSupported) { + recorder.start(FIRST_INPUT) + } +} + +/* + Processes the observed user interactions for INP purposes + */ +export function processUserInteractions(list) { + const entries = list.getEntries() + entries.forEach(entry => { + // Only entries with interactionId should be considered to calculate INP + if (!entry.interactionId) { + return + } + + // All reported entries should be taken into account to estimate the total number of different interactions + updateInteractionCount(entry) + + /** + * The duration can be lower than the recommended threshold because + * the observer will report events with durations >= 16 until browsers expose `performance.interactionCount` + * and the reason we don't store it is because the default Google's approach is to report as 0 + * the INP if the slower duration is less than 40 + * which is well below the recommended threshold. + */ + if (entry.duration < INP_THRESHOLD) { + return + } + + storeUserInteraction(entry) + }) +} + +export function calculateInp() { + if (inpState.longestInteractions.length === 0) { + /** + * The PerformanceObserver doesn't report all interactions because of the durationThreshold limit. + * Despite so, if the interactionCount is greater than 0, we should report 0 + * which is the number/placeholder that Google has chosen as a way to convey that users had a good user experience. + */ + if (interactionCount() > 0) { + return 0 + } + + // This means not activity at all, so nothing to report + return + } + + /** + * If there are less than 50 interactions we will report the slowest interaction + * For 50 interactions or more, we will report the 98th percentile, which means picking the next in "line". + * + * One of the reasons why Google advocates for this is because it wouldn't be fair if your site would be penalized (SEO related), + * just because it received more interactions for an arbitrary reason. + */ + const interactionIndex = Math.min( + inpState.longestInteractions.length - 1, + Math.floor(interactionCount() / 50) + ) + const inp = inpState.longestInteractions[interactionIndex].duration + + return inp +} + +export function interactionCount() { + return performance.interactionCount || inpState.interactionCount +} + +export function restoreINPState() { + inpState.minInteractionId = Infinity + inpState.maxInteractionId = 0 + inpState.interactionCount = 0 + inpState.longestInteractions = [] +} + +// Stores user interaction +function storeUserInteraction(entry) { + // Don't store interactions that are faster than the current ones + // This also makes sure we don't store repeated values + const leastSlow = + inpState.longestInteractions[inpState.longestInteractions.length - 1] + if ( + typeof leastSlow !== 'undefined' && + entry.duration <= leastSlow.duration && + entry.interactionId != leastSlow.id + ) { + return + } + + /** + * different entries can share the same interactionId + * Please see the EventTiming spec for more details about + * the algorithm: https://www.w3.org/TR/event-timing/#sec-computing-interactionid + */ + const filteredInteraction = inpState.longestInteractions.filter( + interaction => interaction.id === entry.interactionId + ) + if (filteredInteraction.length > 0) { + // Override existing interaction + const foundInteraction = filteredInteraction[0] + foundInteraction.duration = Math.max( + foundInteraction.duration, + entry.duration + ) + } else { + inpState.longestInteractions.push({ + id: entry.interactionId, + duration: entry.duration + }) + } + + // Sort the interactions to keep the slowest interactions at the end and keep only the max interactions + inpState.longestInteractions.sort((a, b) => b.duration - a.duration) + inpState.longestInteractions.splice(MAX_INTERACTIONS_TO_CONSIDER) +} + +/** + * Updates the total count of different user interactions occurred in a page + * Once `performance.interactionCount` becomes globally available the "helper/polyfill logic" function + * will not be needed anymore. + * + * the reasons why interactionCount is important are: + * 1. To get the proper percentile 98 when calculating the INP value. + * 2. To report INP as 0 (rather than undefined) in those circumstances where the PerformanceObserver doesn't report entries. + * That can happen because interactions faster than 16ms will not be taken into account. + * Note: a first-input entry duration can be less than 16, which is also helpful for this + * + * Again, these are Google conventions/guidelines and things might change in the future. + * The INP metric calculations will probably evolve over time. + */ +function updateInteractionCount(entry) { + if (isPerfInteractionCountSupported()) { + return + } + + inpState.minInteractionId = Math.min( + inpState.minInteractionId, + entry.interactionId + ) + inpState.maxInteractionId = Math.max( + inpState.maxInteractionId, + entry.interactionId + ) + + /** + * Note: The explanation below is based on our conclusions after exploring and investigating, this is not referenced in any place at all. + * Understanding how the convention works is essential to give our users and customers the best service possible. + * + * To estimate the number of different user interactions in an webpage (without storing all of them in memory) + * Google uses math interpolation: + * E.g. if you have something like (1,2,3,4,5) one of the ways you have to know the total amount different values + * is doing (max-min) + 1, which in this case is 5 + * + * To understand why the calculation below uses the "/ 7" first we need to understand how interactionId is generated. + * From the EventTiming spec: "The user interaction value is increased by a small number chosen by the user agent instead of 1..." + * Please see more details here: https://www.w3.org/TR/event-timing/#user-interaction-value + * + * In case of Chromium, the interactionId increases by 7 each time. + * + * So, the "/ 7" helps to estimate how many different ids have been generated. Let's see a more real example: + * Ids that the PerformanceObserver generated: (3407, 3414, 3421, 3428, 3435) + * let's explain step by step the calculation: (3435 - 3407) / 7 + 1 + * 1. 3435-3407 is 28 + * 2. Since we know that every id is increased by 7, then do 28 / 7 + 1 which will give us 5 + * + * Again, this technique helps to estimate, it's not 100% accurate the whole time + * mainly because PerformanceObserver doesn't report all interactions. + */ + inpState.interactionCount = + (inpState.maxInteractionId - inpState.minInteractionId) / 7 + 1 +} diff --git a/packages/rum-core/src/performance-monitoring/metrics/inp/report.js b/packages/rum-core/src/performance-monitoring/metrics/inp/report.js new file mode 100644 index 000000000..6108b991c --- /dev/null +++ b/packages/rum-core/src/performance-monitoring/metrics/inp/report.js @@ -0,0 +1,68 @@ +/** + * 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 { calculateInp, restoreINPState } from './process' +import { now } from '../../../common/utils' +import { PAGE_EXIT } from '../../../common/constants' + +export function reportInp(transactionService) { + const inp = calculateInp() + if (inp >= 0) { + const startTime = now() + const inpTr = transactionService.startTransaction(PAGE_EXIT, PAGE_EXIT, { + startTime + }) + + // INP should be anchored to the page that the user "hard navigated to" + // the main reason for this is that the UX dasboard page only allows to filter those urls + // with a page-load transaction associated. + // we will revisit this logic once soft navigations start reporting Web vitals metrics + const navigations = performance.getEntriesByType('navigation') + // We should always be expecting to receive one entry. + if (navigations.length > 0) { + const hardNavigationUrl = navigations[0].name + inpTr.addContext({ + page: { + url: hardNavigationUrl + } + }) + } + + inpTr.addLabels({ + inp_value: inp + }) + + // make sure that transaction's duration is > 0 + // we do the +1 because INP can be also reported as 0 + const endTime = startTime + inp + 1 + inpTr.end(endTime) + + // restart INP tracking information + // since users can enter and leave the page multiple times + restoreINPState() + + return inpTr + } +} diff --git a/packages/rum-core/src/performance-monitoring/metrics.js b/packages/rum-core/src/performance-monitoring/metrics/metrics.js similarity index 98% rename from packages/rum-core/src/performance-monitoring/metrics.js rename to packages/rum-core/src/performance-monitoring/metrics/metrics.js index 9ca2a0500..3776fcf72 100644 --- a/packages/rum-core/src/performance-monitoring/metrics.js +++ b/packages/rum-core/src/performance-monitoring/metrics/metrics.js @@ -29,14 +29,14 @@ import { FIRST_CONTENTFUL_PAINT, FIRST_INPUT, LAYOUT_SHIFT -} from '../common/constants' +} from '../../common/constants' import { noop, PERF, isPerfTypeSupported, isRedirectInfoAvailable -} from '../common/utils' -import Span from './span' +} from '../../common/utils' +import Span from '../span' export const metrics = { fid: 0, @@ -59,6 +59,7 @@ export const metrics = { } const LONG_TASK_THRESHOLD = 50 + /** * Create Spans for the long task entries * Spec - https://w3c.github.io/longtasks/ @@ -328,7 +329,7 @@ export class PerfEntryRecorder { } } - start(type) { + start(type, options = { buffered: true }) { try { if (!isPerfTypeSupported(type)) { return @@ -341,7 +342,7 @@ export class PerfEntryRecorder { * browsers would throw error when using entryTypes options along with * buffered flag (https://w3c.github.io/performance-timeline/#observe-method) */ - this.po.observe({ type, buffered: true }) + this.po.observe({ type, ...options }) } catch (_) { /** * Even though we check supportedEntryTypes before starting the observer, diff --git a/packages/rum-core/src/performance-monitoring/performance-monitoring.js b/packages/rum-core/src/performance-monitoring/performance-monitoring.js index 97510fce5..82c680ab5 100644 --- a/packages/rum-core/src/performance-monitoring/performance-monitoring.js +++ b/packages/rum-core/src/performance-monitoring/performance-monitoring.js @@ -47,7 +47,8 @@ import { OUTCOME_FAILURE, OUTCOME_SUCCESS, OUTCOME_UNKNOWN, - QUEUE_ADD_TRANSACTION + QUEUE_ADD_TRANSACTION, + TRANSACTION_IGNORE } from '../common/constants' import { truncateModel, @@ -423,5 +424,7 @@ export default class PerformanceMonitoring { if (filtered) { return this.createTransactionDataModel(transaction) } + + this._configService.dispatchEvent(TRANSACTION_IGNORE) } } diff --git a/packages/rum-core/src/performance-monitoring/transaction-service.js b/packages/rum-core/src/performance-monitoring/transaction-service.js index 4116a0a2b..a69e8ca66 100644 --- a/packages/rum-core/src/performance-monitoring/transaction-service.js +++ b/packages/rum-core/src/performance-monitoring/transaction-service.js @@ -30,7 +30,7 @@ import { captureObserverEntries, metrics, createTotalBlockingTimeSpan -} from './metrics' +} from './metrics/metrics' import { extend, getEarliestSpan, @@ -45,6 +45,7 @@ import { NAME_UNKNOWN, TRANSACTION_START, TRANSACTION_END, + TRANSACTION_IGNORE, TEMPORARY_TYPE, TRANSACTION_TYPE_ORDER, LARGEST_CONTENTFUL_PAINT, @@ -251,13 +252,13 @@ class TransactionService { () => { const { name, type } = tr let { lastHiddenStart } = state - if (lastHiddenStart >= tr._start) { if (__DEV__) { this._logger.debug( `transaction(${tr.id}, ${name}, ${type}) was discarded! The page was hidden during the transaction!` ) } + this._config.dispatchEvent(TRANSACTION_IGNORE) return } @@ -267,6 +268,7 @@ class TransactionService { `transaction(${tr.id}, ${name}, ${type}) is ignored` ) } + this._config.dispatchEvent(TRANSACTION_IGNORE) return } diff --git a/packages/rum-core/test/common/context.spec.js b/packages/rum-core/test/common/context.spec.js index f1c73eebe..a050c7d5d 100644 --- a/packages/rum-core/test/common/context.spec.js +++ b/packages/rum-core/test/common/context.spec.js @@ -27,7 +27,7 @@ import { addSpanContext, addTransactionContext } from '../../src/common/context' import resourceEntries from '../fixtures/resource-entries' import Span from '../../src/performance-monitoring/span' import Transaction from '../../src/performance-monitoring/transaction' -import { PAGE_LOAD } from '../../src/common/constants' +import { PAGE_EXIT, PAGE_LOAD } from '../../src/common/constants' import { mockGetEntriesByType } from '../utils/globals-mock' describe('Context', () => { @@ -233,4 +233,15 @@ describe('Context', () => { unMock() }) + + it('should make sure that the page-exit transaction page context remains as it was defined', () => { + const tr = new Transaction(PAGE_EXIT, PAGE_EXIT) + tr.addContext({ + page: { + url: 'the-url-to-reuse' + } + }) + addTransactionContext(tr) + expect(tr.context.page.url).toBe('the-url-to-reuse') + }) }) diff --git a/packages/rum-core/test/common/observers/page-visibility.spec.js b/packages/rum-core/test/common/observers/page-visibility.spec.js index fc489f465..d435e236a 100644 --- a/packages/rum-core/test/common/observers/page-visibility.spec.js +++ b/packages/rum-core/test/common/observers/page-visibility.spec.js @@ -30,7 +30,8 @@ import { QUEUE_FLUSH, TRANSACTION_SERVICE, CONFIG_SERVICE, - PERFORMANCE_MONITORING + PERFORMANCE_MONITORING, + TEMPORARY_TYPE } from '../../../src/common/constants' import * as utils from '../../../src/common/utils' import { @@ -39,8 +40,18 @@ import { setDocumentVisibilityState } from '../..' import { spyOnFunction, waitFor } from '../../../../../dev-utils/jasmine' +import * as inpReporter from '../../../src/performance-monitoring/metrics/inp/report' +import * as inpProcessor from '../../../src/performance-monitoring/metrics/inp/process' describe('observePageVisibility', () => { + // Starts performanceMonitoring to observe how transaction ends + function startsPerformanceMonitoring() { + const performanceMonitoring = serviceFactory.getService( + PERFORMANCE_MONITORING + ) + performanceMonitoring.init() + } + let serviceFactory let configService let transactionService @@ -92,7 +103,35 @@ describe('observePageVisibility', () => { // the observed events by the agent... ;['visibilitychange', 'pagehide'].forEach(eventName => { describe(`when page becomes hidden due to ${eventName.toUpperCase()} event`, () => { - describe(`with every transaction already ended`, () => { + it('should report INP', async () => { + // Arrange + const dispatchEventSpy = spyOn( + configService, + 'dispatchEvent' + ).and.callThrough() + const reportSpy = spyOnFunction( + inpReporter, + 'reportInp' + ).and.callThrough() + const calculateInpSpy = spyOnFunction(inpProcessor, 'calculateInp') + calculateInpSpy.and.returnValue(100) // makes sure the inp transaction is created + unobservePageVisibility = observePageVisibility( + configService, + transactionService + ) + startsPerformanceMonitoring() + + // Act + hidePageSynthetically(eventName) + await waitFor(() => dispatchEventSpy.calls.any()) + + // Assert + expect(reportSpy).toHaveBeenCalledTimes(1) + expect(reportSpy).toHaveBeenCalledWith(transactionService) + expect(dispatchEventSpy).toHaveBeenCalledWith(QUEUE_FLUSH) + }) + + describe('with every transaction already ended', () => { it('should dispatch the QUEUE_FLUSH event', () => { const dispatchEventSpy = spyOn(configService, 'dispatchEvent') @@ -120,15 +159,7 @@ describe('observePageVisibility', () => { }) }) - describe(`with a transaction that has not ended yet`, () => { - // Starts performanceMonitoring to observe how transaction ends - function startsPerformanceMonitoring() { - const performanceMonitoring = serviceFactory.getService( - PERFORMANCE_MONITORING - ) - performanceMonitoring.init() - } - + describe('with a transaction that has not ended yet', () => { function createTransaction() { const transaction = transactionService.startTransaction( 'test-tr', @@ -159,84 +190,65 @@ describe('observePageVisibility', () => { expect(endTransactionSpy).toHaveBeenCalledTimes(1) }) - it('should dispatch the QUEUE_FLUSH event once the transaction ends and added to the queue', async () => { - // Arrange - const transaction = createTransaction() - const dispatchEventSpy = spyOn( - configService, - 'dispatchEvent' - ).and.callThrough() - spyOn(transactionService, 'getCurrentTransaction').and.returnValue( - transaction - ) - startsPerformanceMonitoring() - - // Act - unobservePageVisibility = observePageVisibility( - configService, - transactionService - ) - hidePageSynthetically(eventName) - await waitFor(() => dispatchEventSpy.calls.any()) - - // Assert - expect(dispatchEventSpy).toHaveBeenCalledWith(QUEUE_FLUSH) - }) - - it('should remove the QUEUE_ADD_TRANSACTION event listener once the transaction ends and added to the queue', async () => { - // Arrange - const unobserveSpy = jasmine.createSpy() - const observeEvent = configService.observeEvent.bind(configService) - spyOn(configService, 'observeEvent').and.callFake((name, fn) => { - observeEvent(name, fn) - return unobserveSpy + describe('event listeners', () => { + ;[ + { + name: 'should fire when transaction is added to the queue', + createTransaction, + customAssertions: ({ dispatchEventSpy }) => { + // should dispatch the QUEUE_FLUSH event + expect(dispatchEventSpy).toHaveBeenCalledWith(QUEUE_FLUSH) + } + }, + { + name: 'should fire when transaction is discarded', + createTransaction: () => { + const tr = createTransaction() + // make sure transaction will be discarded + tr.type = TEMPORARY_TYPE + + return tr + }, + customAssertions: utils.noop + } + ].forEach(({ name, createTransaction, customAssertions }) => { + it(`${name}`, async () => { + // Arrange + const anyTime = 1234567834 + spyOnFunction(utils, 'now').and.returnValue(anyTime) + const unobserveSpy = jasmine.createSpy() + const observeEvent = configService.observeEvent.bind( + configService + ) + spyOn(configService, 'observeEvent').and.callFake((name, fn) => { + observeEvent(name, fn) + return unobserveSpy + }) + const transaction = createTransaction() + const dispatchEventSpy = spyOn( + configService, + 'dispatchEvent' + ).and.callThrough() + spyOn( + transactionService, + 'getCurrentTransaction' + ).and.returnValue(transaction) + startsPerformanceMonitoring() + + // Act + unobservePageVisibility = observePageVisibility( + configService, + transactionService + ) + hidePageSynthetically(eventName) + await waitFor(() => dispatchEventSpy.calls.any()) + + // Assert + customAssertions({ dispatchEventSpy }) + expect(unobserveSpy).toHaveBeenCalledTimes(2) + expect(state.lastHiddenStart).toBe(anyTime) + }) }) - const transaction = createTransaction() - const dispatchEventSpy = spyOn( - configService, - 'dispatchEvent' - ).and.callThrough() - spyOn(transactionService, 'getCurrentTransaction').and.returnValue( - transaction - ) - startsPerformanceMonitoring() - - // Act - unobservePageVisibility = observePageVisibility( - configService, - transactionService - ) - hidePageSynthetically(eventName) - await waitFor(() => dispatchEventSpy.calls.any()) - - // Assert - expect(unobserveSpy).toHaveBeenCalledTimes(1) - }) - - it('should set lastHiddenStart once the transaction ends and added to the queue', async () => { - // Arrange - const anyTime = 1234567834 - spyOnFunction(utils, 'now').and.returnValue(anyTime) - const transaction = createTransaction() - const dispatchEventSpy = spyOn( - configService, - 'dispatchEvent' - ).and.callThrough() - spyOn(transactionService, 'getCurrentTransaction').and.returnValue( - transaction - ) - startsPerformanceMonitoring() - - // Act - unobservePageVisibility = observePageVisibility( - configService, - transactionService - ) - hidePageSynthetically(eventName) - await waitFor(() => dispatchEventSpy.calls.any()) - - // Assert - expect(state.lastHiddenStart).toBe(anyTime) }) }) }) diff --git a/packages/rum-core/test/performance-monitoring/metrics/inp/process.spec.js b/packages/rum-core/test/performance-monitoring/metrics/inp/process.spec.js new file mode 100644 index 000000000..1de5c2912 --- /dev/null +++ b/packages/rum-core/test/performance-monitoring/metrics/inp/process.spec.js @@ -0,0 +1,369 @@ +/** + * 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 * as utils from '../../../../src/common/utils' +import { + inpState, + observeUserInteractions, + processUserInteractions, + interactionCount, + restoreINPState, + calculateInp +} from '../../../../src/performance-monitoring/metrics/inp/process' +import { PerfEntryRecorder } from '../../../../src/performance-monitoring/metrics/metrics' +import { spyOnFunction } from '../../../../../../dev-utils/jasmine' +import { EVENT } from '../../../../src/common/constants' + +if (utils.isPerfTypeSupported(EVENT)) { + describe('INP', () => { + function setInteractionCount(count) { + inpState.interactionCount = count + } + + function inpCandidates() { + return inpState.longestInteractions + } + + let interactionCountSpy + const list = { + getEntries: jasmine.createSpy() + } + + beforeEach(() => { + interactionCountSpy = spyOnFunction( + utils, + 'isPerfInteractionCountSupported' + ) + restoreINPState() + }) + + describe('observeUserInteractions', () => { + describe('when performance.interactionCount is available', () => { + beforeEach(() => { + interactionCountSpy.and.returnValue(true) + }) + + it('should observe events with durationThreshold 40', () => { + const recorder = new PerfEntryRecorder(utils.noop) + spyOn(recorder, 'start') + + observeUserInteractions(recorder) + + expect(recorder.start).toHaveBeenCalledTimes(1) + expect(recorder.start).toHaveBeenCalledWith('event', { + buffered: true, + durationThreshold: 40 + }) + }) + + it('should not observe first-input event', () => { + const recorder = new PerfEntryRecorder(utils.noop) + spyOn(recorder, 'start') + + observeUserInteractions(recorder) + + expect(recorder.start).toHaveBeenCalledTimes(1) + expect(recorder.start).not.toHaveBeenCalledWith('first-input', {}) + }) + }) + + describe('when performance.interactionCount is NOT available', () => { + beforeEach(() => { + interactionCountSpy.and.returnValue(false) + }) + + it('should observe events with durationThreshold 16', () => { + const recorder = new PerfEntryRecorder(utils.noop) + spyOn(recorder, 'start') + + observeUserInteractions(recorder) + + expect(recorder.start).toHaveBeenCalledTimes(2) + expect(recorder.start).toHaveBeenCalledWith('event', { + buffered: true, + durationThreshold: 16 + }) + }) + + it('should observe first-input event', () => { + const recorder = new PerfEntryRecorder(utils.noop) + spyOn(recorder, 'start') + + observeUserInteractions(recorder) + + expect(recorder.start).toHaveBeenCalledTimes(2) + expect(recorder.start).toHaveBeenCalledWith('first-input') + }) + }) + }) + + describe('processUserInteractions', () => { + it('should not consider entries with interactionId set to 0', () => { + list.getEntries.and.callFake(() => { + return [{ interactionId: 0 }, { interactionId: 4007 }] + }) + + processUserInteractions(list) + + expect(interactionCount()).toBe(1) + }) + + it('should not consider INP candidate interactions with a duration of less than 40ms', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 39 }, + { interactionId: 3014, duration: 40 }, + { interactionId: 3021, duration: 41 } + ] + }) + + processUserInteractions(list) + + const candidates = inpCandidates() + expect(candidates.length).toBe(2) + }) + + it('should store INP candidates sorted descendingly by duration', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 40 }, + { interactionId: 3014, duration: 75 }, + { interactionId: 3021, duration: 100 } + ] + }) + + processUserInteractions(list) + + const [first, second, third] = inpCandidates() + expect(first.duration).toBe(100) + expect(second.duration).toBe(75) + expect(third.duration).toBe(40) + }) + + it('should only consider the 10 slowest interactions as INP candidates', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 40 }, + { interactionId: 3014, duration: 45 }, + { interactionId: 3021, duration: 50 }, + { interactionId: 3028, duration: 55 }, + { interactionId: 3035, duration: 60 }, + { interactionId: 3042, duration: 65 }, + { interactionId: 3049, duration: 70 }, + { interactionId: 3056, duration: 75 }, + { interactionId: 3063, duration: 80 }, + { interactionId: 3070, duration: 85 }, + { interactionId: 3077, duration: 90 } + ] + }) + + processUserInteractions(list) + + const candidates = inpCandidates() + const slowestOne = candidates[0] + const leastSlowOne = candidates[candidates.length - 1] + expect(candidates.length).toBe(10) + expect(slowestOne.duration).toBe(90) + expect(leastSlowOne.duration).toBe(45) + }) + + it('should pick the slowest interaction from those entries with the same interactionId', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 75 }, + { interactionId: 3007, duration: 60 } + ] + }) + + processUserInteractions(list) + + const candidates = inpCandidates() + expect(candidates.length).toBe(1) + expect(candidates[0].duration).toBe(75) + }) + + it('should not store duplicated values', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 100 }, + { interactionId: 3014, duration: 100 } + ] + }) + + processUserInteractions(list) + + const candidates = inpCandidates() + expect(candidates.length).toBe(1) + }) + + it('should not store interactions that are faster than the current INP candidates', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 150 }, + { interactionId: 3014, duration: 250 } + ] + }) + + processUserInteractions(list) + let candidates = inpCandidates() + expect(candidates.length).toBe(2) + + // Report a faster interaction + list.getEntries.and.callFake(() => { + return [{ interactionId: 3021, duration: 135 }] + }) + processUserInteractions(list) + + // The number of inp candidates should still be the same + candidates = inpCandidates() + expect(candidates.length).toBe(2) + }) + + describe('when performance.interactionCount is NOT available', () => { + it('should estimate interactionCount from the entries reported by PerformanceObserver', () => { + interactionCountSpy.and.returnValue(false) + + // As already documented in the code, the way (Google approach) to estimate the number is interactions + // is to make sure we increase the id by 7 + // so in thi case we should expect 2 + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 30 }, + { interactionId: 3014, duration: 35 } + ] + }) + + processUserInteractions(list) + + expect(interactionCount()).toBe(2) + }) + }) + + describe('when performance.interactionCount is available', () => { + let originalInteractionCount + beforeEach(() => { + originalInteractionCount = performance.interactionCount + }) + + afterEach(() => { + Object.defineProperty(performance, 'interactionCount', { + value: originalInteractionCount + }) + }) + + it('should determine interactionCount through performance.interactionCount', () => { + interactionCountSpy.and.returnValue(true) + + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3007, duration: 30 }, + { interactionId: 3008, duration: 35 } + ] + }) + + processUserInteractions(list) + + // the entries reported by PerformanceObserver should not be in charge of calculating the count anymore + expect(interactionCount()).toBe(0) + + Object.defineProperty(performance, 'interactionCount', { + value: 5 + }) + + // now, we should get the value from the browser api + expect(interactionCount()).toBe(5) + }) + }) + }) + + describe('calculateInp', () => { + it('should choose the slowest interaction for INP if there are less than 50', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3014, duration: 550 }, + { interactionId: 3021, duration: 600 } + ] + }) + + processUserInteractions(list) + + setInteractionCount(49) + const inpMetric = calculateInp() + + expect(inpMetric).toBe(600) + }) + + it('should calculate the percentile 98 for INP if there are 50 or more interactions', () => { + list.getEntries.and.callFake(() => { + return [ + { interactionId: 3014, duration: 500 }, + { interactionId: 3021, duration: 550 }, + { interactionId: 3028, duration: 600 } + ] + }) + + processUserInteractions(list) + + setInteractionCount(50) + let inpMetric = calculateInp() + expect(inpMetric).toBe(550) + + // P98 calculation will be different the more interactions there are + setInteractionCount(100) + inpMetric = calculateInp() + expect(inpMetric).toBe(500) + }) + + it('should not calculate INP if there is no activity at all', () => { + list.getEntries.and.callFake(() => { + return [] + }) + + processUserInteractions(list) + + let inpMetric = calculateInp() + expect(inpMetric).toBeUndefined() + }) + + it('should generate 0 for INP if the only processed interaction duration is less than 40ms', () => { + list.getEntries.and.callFake(() => { + return [{ interactionId: 3014, duration: 39 }] + }) + + processUserInteractions(list) + + // The interaction above is not considered for INP + expect(inpCandidates().length).toBe(0) + + // But we should increase the interaction counter + expect(interactionCount()).toBe(1) + + // Then, the INP calculated is 0 + let inpMetric = calculateInp() + expect(inpMetric).toBe(0) + }) + }) + }) +} diff --git a/packages/rum-core/test/performance-monitoring/metrics/inp/report.spec.js b/packages/rum-core/test/performance-monitoring/metrics/inp/report.spec.js new file mode 100644 index 000000000..57c30fff7 --- /dev/null +++ b/packages/rum-core/test/performance-monitoring/metrics/inp/report.spec.js @@ -0,0 +1,77 @@ +/** + * 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 * as inpProcessor from '../../../../src/performance-monitoring/metrics/inp/process' +import { reportInp } from '../../../../src/performance-monitoring/metrics/inp/report' +import { createServiceFactory } from '../../../../src' +import { TRANSACTION_SERVICE, EVENT } from '../../../../src/common/constants' +import * as utils from '../../../../src/common/utils' + +import { spyOnFunction } from '../../../../../../dev-utils/jasmine' +import Transaction from '../../../../src/performance-monitoring/transaction' + +if (utils.isPerfTypeSupported(EVENT)) { + describe('reportInp', () => { + let serviceFactory + let transactionService + let calculateInpSpy + + beforeEach(() => { + serviceFactory = createServiceFactory() + transactionService = serviceFactory.getService(TRANSACTION_SERVICE) + calculateInpSpy = spyOnFunction(inpProcessor, 'calculateInp') + }) + + it('should not report if metric is < 0', () => { + calculateInpSpy.and.returnValue(-1) + + const tr = reportInp(transactionService) + expect(tr).toBeUndefined() + }) + + describe('when metric is >= 0', () => { + beforeEach(() => { + calculateInpSpy.and.returnValue(0) + }) + + it('should create page-exit transaction', () => { + const hardNavigatedPage = performance.getEntriesByType('navigation')[0] + const restoreINPSpy = spyOnFunction(inpProcessor, 'restoreINPState') + const endSpy = spyOnFunction(Transaction.prototype, 'end') + endSpy.and.callThrough() // To make sure we can calculate the transaction duration + + const tr = reportInp(transactionService) + const duration = tr._end - tr._start + + expect(tr.type).toBe('page-exit') + expect(tr.context.tags.inp_value).toBe(0) + expect(tr.context.page.url).toBe(hardNavigatedPage.name) + expect(duration).toBeGreaterThanOrEqual(1) + expect(endSpy).toHaveBeenCalledTimes(1) + expect(restoreINPSpy).toHaveBeenCalledTimes(1) + }) + }) + }) +} diff --git a/packages/rum-core/test/performance-monitoring/metrics.spec.js b/packages/rum-core/test/performance-monitoring/metrics/metrics.spec.js similarity index 97% rename from packages/rum-core/test/performance-monitoring/metrics.spec.js rename to packages/rum-core/test/performance-monitoring/metrics/metrics.spec.js index 0348e902c..6543504ce 100644 --- a/packages/rum-core/test/performance-monitoring/metrics.spec.js +++ b/packages/rum-core/test/performance-monitoring/metrics/metrics.spec.js @@ -31,18 +31,21 @@ import { metrics, calculateCumulativeLayoutShift, createLongTaskSpans -} from '../../src/performance-monitoring/metrics' -import { LARGEST_CONTENTFUL_PAINT, LONG_TASK } from '../../src/common/constants' -import { isPerfTypeSupported } from '../../src/common/utils' +} from '../../../src/performance-monitoring/metrics/metrics' +import { + LARGEST_CONTENTFUL_PAINT, + LONG_TASK +} from '../../../src/common/constants' +import { isPerfTypeSupported } from '../../../src/common/utils' import { mockObserverEntryTypes, 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 '..' +} 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', () => { diff --git a/packages/rum-core/test/performance-monitoring/performance-monitoring.spec.js b/packages/rum-core/test/performance-monitoring/performance-monitoring.spec.js index cb1172023..5bbfd0c17 100644 --- a/packages/rum-core/test/performance-monitoring/performance-monitoring.spec.js +++ b/packages/rum-core/test/performance-monitoring/performance-monitoring.spec.js @@ -49,7 +49,8 @@ import { LOGGING_SERVICE, CONFIG_SERVICE, APM_SERVER, - PERFORMANCE_MONITORING + PERFORMANCE_MONITORING, + TRANSACTION_IGNORE } from '../../src/common/constants' import { state } from '../../src/state' import patchEventHandler from '../common/patch' @@ -153,7 +154,7 @@ describe('PerformanceMonitoring', function () { it('should initialize and notify the transaction has been added to the queue', async () => { performanceMonitoring.init() - spyOn(configService, 'dispatchEvent') + spyOn(configService, 'dispatchEvent').and.callThrough() const tr = performanceMonitoring._transactionService.startTransaction( 'transaction', @@ -195,6 +196,16 @@ describe('PerformanceMonitoring', function () { expect(payload.spans[0].duration).toBe(parseInt(span._end - span._start)) }) + it('should notify when a transaction has been filtered out', function () { + spyOn(configService, 'dispatchEvent') + var tr = new Transaction('transaction-no-duration', 'transaction-type') + tr.end() + + var payload = performanceMonitoring.createTransactionPayload(tr) + expect(payload).toBeUndefined() + expect(configService.dispatchEvent).toHaveBeenCalledWith(TRANSACTION_IGNORE) + }) + it('should sendPageLoadMetrics', function (done) { const unMock = mockGetEntriesByType() const transactionService = serviceFactory.getService(TRANSACTION_SERVICE) diff --git a/packages/rum-core/test/performance-monitoring/transaction-service.spec.js b/packages/rum-core/test/performance-monitoring/transaction-service.spec.js index 0345464c2..ce511c99f 100644 --- a/packages/rum-core/test/performance-monitoring/transaction-service.spec.js +++ b/packages/rum-core/test/performance-monitoring/transaction-service.spec.js @@ -32,18 +32,20 @@ import { TRANSACTION_END, PAGE_LOAD, ROUTE_CHANGE, + TEMPORARY_TYPE, LONG_TASK, LARGEST_CONTENTFUL_PAINT, PAINT, TRUNCATED_TYPE, FIRST_INPUT, LAYOUT_SHIFT, - LOCAL_CONFIG_KEY + LOCAL_CONFIG_KEY, + TRANSACTION_IGNORE } from '../../src/common/constants' import { state } from '../../src/state' import { isPerfTypeSupported } from '../../src/common/utils' import Transaction from '../../src/performance-monitoring/transaction' -import { metrics } from '../../src/performance-monitoring/metrics' +import { metrics } from '../../src/performance-monitoring/metrics/metrics' describe('TransactionService', function () { var transactionService @@ -63,6 +65,7 @@ describe('TransactionService', function () { spyOn(logger, 'debug') config = new Config() + spyOn(config, 'dispatchEvent').and.callThrough() transactionService = new TransactionService(logger, config) }) @@ -731,6 +734,7 @@ describe('TransactionService', function () { expect(logger.debug).toHaveBeenCalledWith( `transaction(${tr.id}, ${tr.name}, ${tr.type}) was discarded! The page was hidden during the transaction!` ) + expect(config.dispatchEvent).toHaveBeenCalledWith(TRANSACTION_IGNORE) state.lastHiddenStart = performance.now() - 1000 tr = transactionService.startTransaction('test-name', 'test-type') @@ -741,6 +745,28 @@ describe('TransactionService', function () { state.lastHiddenStart = lastHiddenStart }) + it('should discard TEMPORARY_TYPE transactions', async () => { + let tr = transactionService.startTransaction('test-name', TEMPORARY_TYPE) + await tr.end() + expect(logger.debug).toHaveBeenCalledWith( + `transaction(${tr.id}, ${tr.name}, ${tr.type}) is ignored` + ) + expect(config.dispatchEvent).toHaveBeenCalledWith(TRANSACTION_IGNORE) + }) + + it('should discard transaction configured to be ignored', async () => { + config.setConfig({ + ignoreTransactions: ['ignore-tr'] + }) + let tr = transactionService.startTransaction('ignore-tr', 'type') + await tr.end() + config.setConfig({ ignoreTransactions: [] }) + expect(logger.debug).toHaveBeenCalledWith( + `transaction(${tr.id}, ${tr.name}, ${tr.type}) is ignored` + ) + expect(config.dispatchEvent).toHaveBeenCalledWith(TRANSACTION_IGNORE) + }) + it('should set session information on transaction', () => { config.setConfig({ session: true }) const tr = new Transaction('test', 'test') diff --git a/packages/rum/src/apm-base.js b/packages/rum/src/apm-base.js index c8e1b59b1..cabbaebae 100644 --- a/packages/rum/src/apm-base.js +++ b/packages/rum/src/apm-base.js @@ -37,7 +37,8 @@ import { EVENT_TARGET, CLICK, observePageVisibility, - observePageClicks + observePageClicks, + observeUserInteractions } from '@elastic/apm-rum-core' export default class ApmBase { @@ -125,6 +126,7 @@ export default class ApmBase { if (flags[EVENT_TARGET] && flags[CLICK]) { observePageClicks(transactionService) } + observeUserInteractions() } else { this._disable = true loggingService.warn('RUM agent is inactive')