diff --git a/.size-limit.js b/.size-limit.js index c3105a772987..c1725577c856 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -120,7 +120,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '40.5 KB', + limit: '41 KB', }, // Vue SDK (ESM) { @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '39 KB', + limit: '40 KB', }, // Node SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 7d448325b6ef..942230b4594e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -33,9 +34,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 8056cd88c3e5..435ed8398668 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -35,9 +36,7 @@ sentryTest( await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index 46f943b08551..9d83d2608893 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -3,6 +3,7 @@ import type { SpanEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -33,9 +34,7 @@ sentryTest( await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js index a941877ff88e..546698dc3d11 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js @@ -14,6 +14,7 @@ Sentry.init({ }), ], tracesSampleRate: 1, + debug: true, }); const client = Sentry.getClient(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index ac8dccd13dce..bf85d0ad99af 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -32,9 +33,7 @@ sentryTest('should capture an INP click event span during pageload', async ({ br await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; @@ -118,6 +117,14 @@ sentryTest( }); // Page hide to trigger INP + + // Important: Purposefully not using hidePage() here to test the hidden state + // via the `pagehide` event. This is necessary because iOS Safari 14.4 + // still doesn't fully emit the `visibilitychange` events but it's the lower + // bound for Safari on iOS that we support. + // If this test times out or fails, it's likely because we tried updating + // the web-vitals library which officially already dropped support for + // this iOS version await page.evaluate(() => { window.dispatchEvent(new Event('pagehide')); }); diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index a6c53800ccc1..1d35ff53853f 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -58,6 +58,7 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); + // TODO: Figure out if we can switch to using whenIdleOrHidden instead of onHidden // use pagehide event from web-vitals onHidden(() => { _collectClsOnce(); diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index c4b2b1a1c0cf..4f9d29e5f02f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -2,10 +2,10 @@ > A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. -This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2 The commit SHA used is: -[3d2b3dc8576cc003618952fa39902fab764a53e2](https://github.com/GoogleChrome/web-vitals/tree/3d2b3dc8576cc003618952fa39902fab764a53e2) +[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb) Current vendored web vitals are: @@ -27,6 +27,12 @@ web-vitals only report once per pageload. ## CHANGELOG +https://github.com/getsentry/sentry-javascript/pull/16492 + +- Bumped from Web Vitals 4.2.5 to 5.0.2 + - Mainly fixes some INP, LCP and FCP edge cases + - Original library removed FID; we still keep it around for now + https://github.com/getsentry/sentry-javascript/pull/14439 - Bumped from Web Vitals v3.5.2 to v4.2.4 diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index a9b6f9f26999..1b4d50a7c44e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; +import { initUnique } from './lib/initUnique'; +import { LayoutShiftManager } from './lib/LayoutShiftManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { onFCP } from './onFCP'; import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types'; @@ -54,58 +56,37 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = const metric = initMetric('CLS', 0); let report: ReturnType; - let sessionValue = 0; - let sessionEntries: LayoutShift[] = []; + const layoutShiftManager = initUnique(opts, LayoutShiftManager); const handleEntries = (entries: LayoutShift[]) => { - entries.forEach(entry => { - // Only count layout shifts without recent user input. - if (!entry.hadRecentInput) { - const firstSessionEntry = sessionEntries[0]; - const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; - - // If the entry occurred less than 1 second after the previous entry - // and less than 5 seconds after the first entry in the session, - // include the entry in the current session. Otherwise, start a new - // session. - if ( - sessionValue && - firstSessionEntry && - lastSessionEntry && - entry.startTime - lastSessionEntry.startTime < 1000 && - entry.startTime - firstSessionEntry.startTime < 5000 - ) { - sessionValue += entry.value; - sessionEntries.push(entry); - } else { - sessionValue = entry.value; - sessionEntries = [entry]; - } - } - }); + for (const entry of entries) { + layoutShiftManager._processEntry(entry); + } // If the current session value is larger than the current CLS value, // update CLS and the entries contributing to it. - if (sessionValue > metric.value) { - metric.value = sessionValue; - metric.entries = sessionEntries; + if (layoutShiftManager._sessionValue > metric.value) { + metric.value = layoutShiftManager._sessionValue; + metric.entries = layoutShiftManager._sessionEntries; report(); } }; const po = observe('layout-shift', handleEntries); if (po) { - report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + report = bindReporter(onReport, metric, CLSThresholds, opts!.reportAllChanges); - onHidden(() => { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); + WINDOW.document?.addEventListener('visibilitychange', () => { + if (WINDOW.document?.visibilityState === 'hidden') { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); + } }); // Queue a task to report (if nothing else triggers a report first). // This allows CLS to be reported as soon as FCP fires when // `reportAllChanges` is true. - setTimeout(report, 0); + WINDOW?.setTimeout?.(report); } }), ); diff --git a/packages/browser-utils/src/metrics/web-vitals/getFID.ts b/packages/browser-utils/src/metrics/web-vitals/getFID.ts index e8fd4fa908e7..b549f4c07c7c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getFID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getFID.ts @@ -12,6 +12,10 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * // Sentry: web-vitals removed FID reporting from v5. We're keeping it around + * for the time being. + * // TODO(v10): Remove FID reporting! */ import { bindReporter } from './lib/bindReporter'; @@ -60,6 +64,7 @@ export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts = report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges); if (po) { + // sentry: TODO: Figure out if we can use new whinIdleOrHidden insteard of onHidden onHidden( runOnce(() => { handleEntries(po.takeRecords() as FIDMetric['entries']); diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index af5bd05fb413..f5efbcbc3afc 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -14,31 +14,37 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; -import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions'; +import { initUnique } from './lib/initUnique'; +import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; import { whenActivated } from './lib/whenActivated'; -import { whenIdle } from './lib/whenIdle'; -import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; +import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; +// The default `durationThreshold` used across this library for observing +// `event` entries via PerformanceObserver. +const DEFAULT_DURATION_THRESHOLD = 40; + /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with * the `event` performance entries reported for that interaction. The reported * value is a `DOMHighResTimeStamp`. * - * A custom `durationThreshold` configuration option can optionally be passed to - * control what `event-timing` entries are considered for INP reporting. The - * default threshold is `40`, which means INP scores of less than 40 are - * reported as 0. Note that this will not affect your 75th percentile INP value - * unless that value is also less than 40 (well below the recommended + * A custom `durationThreshold` configuration option can optionally be passed + * to control what `event-timing` entries are considered for INP reporting. The + * default threshold is `40`, which means INP scores of less than 40 will not + * be reported. To avoid reporting no interactions in these cases, the library + * will fall back to the input delay of the first interaction. Note that this + * will not affect your 75th percentile INP value unless that value is also + * less than 40 (well below the recommended * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold). * * If the `reportAllChanges` configuration option is set to `true`, the @@ -55,9 +61,9 @@ export const INPThresholds: MetricRatingThresholds = [200, 500]; * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => { +export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts = {}) => { // Return if the browser doesn't support all APIs needed to measure INP. - if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) { + if (!(globalThis.PerformanceEventTiming && 'interactionId' in PerformanceEventTiming.prototype)) { return; } @@ -69,6 +75,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // eslint-disable-next-line prefer-const let report: ReturnType; + const interactionManager = initUnique(opts, InteractionManager); + const handleEntries = (entries: INPMetric['entries']) => { // Queue the `handleEntries()` callback in the next idle task. // This is needed to increase the chances that all event entries that @@ -76,13 +84,15 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // have been dispatched. Note: there is currently an experiment // running in Chrome (EventTimingKeypressAndCompositionInteractionId) // 123+ that if rolled out fully may make this no longer necessary. - whenIdle(() => { - entries.forEach(processInteractionEntry); + whenIdleOrHidden(() => { + for (const entry of entries) { + interactionManager._processEntry(entry); + } - const inp = estimateP98LongestInteraction(); + const inp = interactionManager._estimateP98LongestInteraction(); - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; + if (inp && inp._latency !== metric.value) { + metric.value = inp._latency; metric.entries = inp.entries; report(); } @@ -96,7 +106,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // and performance. Running this callback for any interaction that spans // just one or two frames is likely not worth the insight that could be // gained. - durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : DEFAULT_DURATION_THRESHOLD, + durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, }); report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); @@ -106,6 +116,9 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // where the first interaction is less than the `durationThreshold`. po.observe({ type: 'first-input', buffered: true }); + // sentry: we use onHidden instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); report(true); diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 17fd374e7611..0f2f821d9bcc 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -19,18 +19,17 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; +import { initUnique } from './lib/initUnique'; +import { LCPEntryManager } from './lib/LCPEntryManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { whenActivated } from './lib/whenActivated'; -import { whenIdle } from './lib/whenIdle'; +import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; -const reportedMetricIDs: Record = {}; - /** * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and * calls the `callback` function once the value is ready (along with the @@ -48,28 +47,32 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = const metric = initMetric('LCP'); let report: ReturnType; + const lcpEntryManager = initUnique(opts, LCPEntryManager); + const handleEntries = (entries: LCPMetric['entries']) => { // If reportAllChanges is set then call this function for each entry, // otherwise only consider the last one. - if (!opts.reportAllChanges) { + if (!opts!.reportAllChanges) { // eslint-disable-next-line no-param-reassign entries = entries.slice(-1); } - entries.forEach(entry => { + for (const entry of entries) { + lcpEntryManager._processEntry(entry); + // Only report if the page wasn't hidden prior to LCP. if (entry.startTime < visibilityWatcher.firstHiddenTime) { // The startTime attribute returns the value of the renderTime if it is // not 0, and the value of the loadTime otherwise. The activationStart // reference is used because LCP should be relative to page activation - // rather than navigation start if the page was pre-rendered. But in cases + // rather than navigation start if the page was prerendered. But in cases // where `activationStart` occurs after the LCP, this time should be // clamped at 0. metric.value = Math.max(entry.startTime - getActivationStart(), 0); metric.entries = [entry]; report(); } - }); + } }; const po = observe('largest-contentful-paint', handleEntries); @@ -77,31 +80,29 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = if (po) { report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); + // Ensure this logic only runs once, since it can be triggered from + // any of three different event listeners below. const stopListening = runOnce(() => { - if (!reportedMetricIDs[metric.id]) { - handleEntries(po.takeRecords() as LCPMetric['entries']); - po.disconnect(); - reportedMetricIDs[metric.id] = true; - report(true); - } + handleEntries(po.takeRecords() as LCPMetric['entries']); + po.disconnect(); + report(true); }); - // Stop listening after input. Note: while scrolling is an input that - // stops LCP observation, it's unreliable since it can be programmatically - // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 - ['keydown', 'click'].forEach(type => { - // Wrap in a setTimeout so the callback is run in a separate task - // to avoid extending the keyboard/click handler to reduce INP impact + // Stop listening after input or visibilitychange. + // Note: while scrolling is an input that stops LCP observation, it's + // unreliable since it can be programmatically generated. + // See: https://github.com/GoogleChrome/web-vitals/issues/75 + for (const type of ['keydown', 'click', 'visibilitychange']) { + // Wrap the listener in an idle callback so it's run in a separate + // task to reduce potential INP impact. // https://github.com/GoogleChrome/web-vitals/issues/383 if (WINDOW.document) { - addEventListener(type, () => whenIdle(stopListening as () => void), { - once: true, + addEventListener(type, () => whenIdleOrHidden(stopListening), { capture: true, + once: true, }); } - }); - - onHidden(stopListening); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts new file mode 100644 index 000000000000..033cdb2cb836 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getInteractionCount } from './polyfills/interactionCountPolyfill.js'; + +export interface Interaction { + _latency: number; + // While the `id` and `entries` properties are also internal and could be + // mangled by prefixing with an underscore, since they correspond to public + // symbols there is no need to mangle them as the library will compress + // better if we reuse the existing names. + id: number; + entries: PerformanceEventTiming[]; +} + +// To prevent unnecessary memory usage on pages with lots of interactions, +// store at most 10 of the longest interactions to consider as INP candidates. +const MAX_INTERACTIONS_TO_CONSIDER = 10; + +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; +}; + +/** + * + */ +export class InteractionManager { + /** + * A list of longest interactions on the page (by latency) sorted so the + * longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER + * long. + */ + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _longestInteractionList: Interaction[] = []; + + /** + * A mapping of longest interactions by their interaction ID. + * This is used for faster lookup. + */ + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _longestInteractionMap: Map = new Map(); + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onBeforeProcessingEntry?: (entry: PerformanceEventTiming) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onAfterProcessingINPCandidate?: (interaction: Interaction) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc + _resetInteractions() { + prevInteractionCount = getInteractionCount(); + this._longestInteractionList.length = 0; + this._longestInteractionMap.clear(); + } + + /** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _estimateP98LongestInteraction() { + const candidateInteractionIndex = Math.min( + this._longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return this._longestInteractionList[candidateInteractionIndex]; + } + + /** + * Takes a performance entry and adds it to the list of worst interactions + * if its duration is long enough to make it among the worst. If the + * entry is part of an existing interaction, it is merged and the latency + * and entries list is updated as needed. + */ + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _processEntry(entry: PerformanceEventTiming) { + this._onBeforeProcessingEntry?.(entry); + + // Skip further processing for entries that cannot be INP candidates. + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + // The least-long of the 10 longest interactions. + const minLongestInteraction = this._longestInteractionList.at(-1); + + let interaction = this._longestInteractionMap.get(entry.interactionId!); + + // Only process the entry if it's possibly one of the ten longest, + // or if it's part of an existing interaction. + if ( + interaction || + this._longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || + // If the above conditions are false, `minLongestInteraction` will be set. + entry.duration > minLongestInteraction!._latency + ) { + // If the interaction already exists, update it. Otherwise create one. + if (interaction) { + // If the new entry has a longer duration, replace the old entries, + // otherwise add to the array. + if (entry.duration > interaction._latency) { + interaction.entries = [entry]; + interaction._latency = entry.duration; + } else if (entry.duration === interaction._latency && entry.startTime === interaction.entries[0]!.startTime) { + interaction.entries.push(entry); + } + } else { + interaction = { + id: entry.interactionId!, + entries: [entry], + _latency: entry.duration, + }; + this._longestInteractionMap.set(interaction.id, interaction); + this._longestInteractionList.push(interaction); + } + + // Sort the entries by latency (descending) and keep only the top ten. + this._longestInteractionList.sort((a, b) => b._latency - a._latency); + if (this._longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { + const removedInteractions = this._longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER); + + for (const interaction of removedInteractions) { + this._longestInteractionMap.delete(interaction.id); + } + } + + // Call any post-processing on the interaction + this._onAfterProcessingINPCandidate?.(interaction); + } + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts new file mode 100644 index 000000000000..752c6c41469b --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line jsdoc/require-jsdoc +export class LCPEntryManager { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc + _processEntry(entry: LargestContentfulPaint) { + this._onBeforeProcessingEntry?.(entry); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts new file mode 100644 index 000000000000..76de0eb8290c --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts @@ -0,0 +1,55 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class LayoutShiftManager { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onAfterProcessingUnexpectedShift?: (entry: LayoutShift) => void; + + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _sessionValue = 0; + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _sessionEntries: LayoutShift[] = []; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _processEntry(entry: LayoutShift) { + // Only count layout shifts without recent user input. + if (entry.hadRecentInput) return; + + const firstSessionEntry = this._sessionEntries[0]; + const lastSessionEntry = this._sessionEntries.at(-1); + + // If the entry occurred less than 1 second after the previous entry + // and less than 5 seconds after the first entry in the session, + // include the entry in the current session. Otherwise, start a new + // session. + if ( + this._sessionValue && + firstSessionEntry && + lastSessionEntry && + entry.startTime - lastSessionEntry.startTime < 1000 && + entry.startTime - firstSessionEntry.startTime < 5000 + ) { + this._sessionValue += entry.value; + this._sessionEntries.push(entry); + } else { + this._sessionValue = entry.value; + this._sessionEntries = [entry]; + } + + this._onAfterProcessingUnexpectedShift?.(entry); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts b/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts index 43fdc8d9e541..2eba91d9effb 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts @@ -37,7 +37,7 @@ export const bindReporter = ( return (forceReport?: boolean) => { if (metric.value >= 0) { if (forceReport || reportAllChanges) { - delta = metric.value - (prevValue || 0); + delta = metric.value - (prevValue ?? 0); // Report the metric if there's a non-zero delta or if no previous // value exists (which can happen in the case of the document becoming diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts index 637d01398e0a..983ebc81ea4a 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts @@ -20,5 +20,5 @@ * @return {string} */ export const generateUniqueID = () => { - return `v4-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; + return `v5-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts index 4bdafc0c718c..33677466faf9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts @@ -18,5 +18,5 @@ import { getNavigationEntry } from './getNavigationEntry'; export const getActivationStart = (): number => { const navEntry = getNavigationEntry(); - return navEntry?.activationStart || 0; + return navEntry?.activationStart ?? 0; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts index f2c85f6127bc..77c68999b918 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts @@ -21,12 +21,12 @@ import { WINDOW } from '../../../types'; export const getNavigationEntry = (checkResponseStart = true): PerformanceNavigationTiming | void => { const navigationEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0]; // Check to ensure the `responseStart` property is present and valid. - // In some cases no value is reported by the browser (for + // In some cases a zero value is reported by the browser (for // privacy/security reasons), and in other cases (bugs) the value is // negative or is larger than the current page time. Ignore these cases: - // https://github.com/GoogleChrome/web-vitals/issues/137 - // https://github.com/GoogleChrome/web-vitals/issues/162 - // https://github.com/GoogleChrome/web-vitals/issues/275 + // - https://github.com/GoogleChrome/web-vitals/issues/137 + // - https://github.com/GoogleChrome/web-vitals/issues/162 + // - https://github.com/GoogleChrome/web-vitals/issues/275 if ( // sentry-specific change: // We don't want to check for responseStart for our own use of `getNavigationEntry` diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index b658be9475e9..3a6c0a2e42a9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types'; +import { getActivationStart } from './getActivationStart'; let firstHiddenTime = -1; @@ -24,7 +25,7 @@ const initHiddenTime = () => { // that visibility state is always 'hidden' during prerendering, so we have // to ignore that case until prerendering finishes (see: `prerenderingchange` // event logic below). - return WINDOW.document!.visibilityState === 'hidden' && !WINDOW.document!.prerendering ? 0 : Infinity; + return WINDOW.document?.visibilityState === 'hidden' && !WINDOW.document?.prerendering ? 0 : Infinity; }; const onVisibilityUpdate = (event: Event) => { @@ -61,11 +62,22 @@ const removeChangeListeners = () => { export const getVisibilityWatcher = () => { if (WINDOW.document && firstHiddenTime < 0) { - // If the document is hidden when this code runs, assume it was hidden - // since navigation start. This isn't a perfect heuristic, but it's the - // best we can do until an API is available to support querying past - // visibilityState. - firstHiddenTime = initHiddenTime(); + // Check if we have a previous hidden `visibility-state` performance entry. + const activationStart = getActivationStart(); + const firstVisibilityStateHiddenTime = !WINDOW.document.prerendering + ? globalThis.performance + .getEntriesByType('visibility-state') + .filter(e => e.name === 'hidden' && e.startTime > activationStart)[0]?.startTime + : undefined; + + // Prefer that, but if it's not available and the document is hidden when + // this code runs, assume it was hidden since navigation start. This isn't + // a perfect heuristic, but it's the best we can do until the + // `visibility-state` performance entry becomes available in all browsers. + firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime(); + // We're still going to listen to for changes so we can handle things like + // bfcache restores and/or prerender without having to examine individual + // timestamps in detail. addChangeListeners(); } return { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts index b2cfbc609a25..8771a5966c9f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts @@ -20,7 +20,7 @@ import { generateUniqueID } from './generateUniqueID'; import { getActivationStart } from './getActivationStart'; import { getNavigationEntry } from './getNavigationEntry'; -export const initMetric = (name: MetricName, value?: number) => { +export const initMetric = (name: MetricName, value: number = -1) => { const navEntry = getNavigationEntry(); let navigationType: MetricType['navigationType'] = 'navigate'; @@ -39,7 +39,7 @@ export const initMetric = (name: MetricNa return { name, - value: typeof value === 'undefined' ? -1 : value, + value, rating: 'good' as const, // If needed, will be updated when reported. `const` to keep the type from widening to `string`. delta: 0, entries, diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts new file mode 100644 index 000000000000..1eda48705b08 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const instanceMap: WeakMap = new WeakMap(); + +/** + * A function that accepts and identity object and a class object and returns + * either a new instance of that class or an existing instance, if the + * identity object was previously used. + */ +export function initUnique(identityObj: object, ClassObj: new () => T): T { + if (!instanceMap.get(identityObj)) { + instanceMap.set(identityObj, new ClassObj()); + } + return instanceMap.get(identityObj)! as T; +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts index ad71468b6fb6..9af0116cd0b1 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -41,7 +41,7 @@ interface PerformanceEntryMap { export const observe = ( type: K, callback: (entries: PerformanceEntryMap[K]) => void, - opts?: PerformanceObserverInit, + opts: PerformanceObserverInit = {}, ): PerformanceObserver | undefined => { try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { @@ -54,18 +54,10 @@ export const observe = ( callback(list.getEntries() as PerformanceEntryMap[K]); }); }); - po.observe( - Object.assign( - { - type, - buffered: true, - }, - opts || {}, - ) as PerformanceObserverInit, - ); + po.observe({ type, buffered: true, ...opts }); return po; } - } catch (e) { + } catch { // Do nothing. } return; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index f1640d4fcdac..1844a616a479 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -21,13 +21,13 @@ export interface OnHiddenCallback { } // Sentry-specific change: -// This function's logic was NOT updated to web-vitals 4.2.4 but we continue -// to use the web-vitals 3.5.2 due to us having stricter browser support. +// This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue +// to use the web-vitals 3.5.2 versiondue to us having stricter browser support. // PR with context that made the changes: https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 // The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. -// This is "more correct" but some browsers we still support (Safari 12.1-14.0) don't fully support `visibilitychange` +// This is "more correct" but some browsers we still support (Safari <14.4) don't fully support `visibilitychange` // or have known bugs w.r.t the `visibilitychange` event. -// TODO (v9): If we decide to drop support for Safari 12.1-14.0, we can use the logic from web-vitals 4.2.4 +// TODO (v10): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 // In this case, we also need to update the integration tests that currently trigger the `pagehide` event to // simulate the page being hidden. export const onHidden = (cb: OnHiddenCallback) => { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts similarity index 67% rename from packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 8914c45d7bb3..32dae5f30f8b 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -14,27 +14,28 @@ * limitations under the License. */ -import { WINDOW } from '../../../types'; -import { onHidden } from './onHidden'; -import { runOnce } from './runOnce'; +import { WINDOW } from '../../../types.js'; +import { onHidden } from './onHidden.js'; +import { runOnce } from './runOnce.js'; /** * Runs the passed callback during the next idle period, or immediately * if the browser's visibility state is (or becomes) hidden. */ -export const whenIdle = (cb: () => void): number => { +export const whenIdleOrHidden = (cb: () => void) => { const rIC = WINDOW.requestIdleCallback || WINDOW.setTimeout; - let handle = -1; - // eslint-disable-next-line no-param-reassign - cb = runOnce(cb) as () => void; // If the document is hidden, run the callback immediately, otherwise // race an idle callback with the next `visibilitychange` event. if (WINDOW.document?.visibilityState === 'hidden') { cb(); } else { - handle = rIC(cb); + // eslint-disable-next-line no-param-reassign + cb = runOnce(cb); + rIC(cb); + // sentry: we use onHidden instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. onHidden(cb); } - return handle; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts index d01001ad48ec..12fd51e29ef7 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts @@ -38,7 +38,7 @@ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = let report: ReturnType; const handleEntries = (entries: FCPMetric['entries']) => { - entries.forEach(entry => { + for (const entry of entries) { if (entry.name === 'first-contentful-paint') { po!.disconnect(); @@ -53,7 +53,7 @@ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = report(true); } } - }); + } }; const po = observe('paint', handleEntries); diff --git a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts index 235895d093aa..4633b3cd83cb 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts @@ -36,7 +36,7 @@ const whenReady = (callback: () => void) => { addEventListener('load', () => whenReady(callback), true); } else { // Queue a task so the callback runs after `loadEventEnd`. - setTimeout(callback, 0); + setTimeout(callback); } }; diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 5a17b811db96..033fbee09926 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -19,7 +19,7 @@ export * from './types/polyfills'; export * from './types/cls'; export * from './types/fcp'; -export * from './types/fid'; +export * from './types/fid'; // FIX was removed in 5.0.2 but we keep it around for now export * from './types/inp'; export * from './types/lcp'; export * from './types/ttfb'; @@ -65,7 +65,7 @@ declare global { // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution interface LayoutShiftAttribution { - node?: Node; + node: Node | null; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; } @@ -87,9 +87,48 @@ declare global { readonly element: Element | null; } + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + export type ScriptInvokerType = + | 'classic-script' + | 'module-script' + | 'event-listener' + | 'user-callback' + | 'resolve-promise' + | 'reject-promise'; + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + export type ScriptWindowAttribution = 'self' | 'descendant' | 'ancestor' | 'same-page' | 'other'; + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + interface PerformanceScriptTiming extends PerformanceEntry { + /* Overloading PerformanceEntry */ + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + + readonly invokerType: ScriptInvokerType; + readonly invoker: string; + readonly executionStart: DOMHighResTimeStamp; + readonly sourceURL: string; + readonly sourceFunctionName: string; + readonly sourceCharPosition: number; + readonly pauseDuration: DOMHighResTimeStamp; + readonly forcedStyleAndLayoutDuration: DOMHighResTimeStamp; + readonly window?: Window; + readonly windowAttribution: ScriptWindowAttribution; + } + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { - renderStart: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + readonly renderStart: DOMHighResTimeStamp; + readonly styleAndLayoutStart: DOMHighResTimeStamp; + readonly blockingDuration: DOMHighResTimeStamp; + readonly firstUIEventTimestamp: DOMHighResTimeStamp; + readonly scripts: PerformanceScriptTiming[]; } } diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index 846744d96da5..d8315b817f4a 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -24,6 +24,7 @@ import type { TTFBMetric, TTFBMetricWithAttribution } from './ttfb'; export interface Metric { /** * The name of the metric (in acronym form). + * // sentry: re-added FID here since we continue supporting it for now */ name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; @@ -78,6 +79,7 @@ export interface Metric { } /** The union of supported metric types. */ +// sentry: re-added FIDMetric here since we continue supporting it for now export type MetricType = CLSMetric | FCPMetric | FIDMetric | INPMetric | LCPMetric | TTFBMetric; /** The union of supported metric attribution types. */ @@ -104,9 +106,21 @@ export type MetricWithAttribution = */ export type MetricRatingThresholds = [number, number]; +/** + * @deprecated Use metric-specific function types instead, such as: + * `(metric: LCPMetric) => void`. If a single callback type is needed for + * multiple metrics, use `(metric: MetricType) => void`. + */ +export interface ReportCallback { + (metric: MetricType): void; +} + export interface ReportOpts { reportAllChanges?: boolean; - durationThreshold?: number; +} + +export interface AttributionReportOpts extends ReportOpts { + generateTarget?: (el: Node | null) => string; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts index 1d17c2d3eedb..5acaaa27c9ab 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -31,9 +31,10 @@ export interface CLSMetric extends Metric { */ export interface CLSAttribution { /** - * A selector identifying the first element (in document order) that - * shifted when the single largest layout shift contributing to the page's - * CLS score occurred. + * By default, a selector identifying the first element (in document order) + * that shifted when the single largest layout shift that contributed to the + * page's CLS score occurred. If the `generateTarget` configuration option + * was passed, then this will instead be the return value of that function. */ largestShiftTarget?: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts index c19be79a1ce0..e73743866301 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -14,7 +14,15 @@ * limitations under the License. */ -import type { LoadState, Metric } from './base'; +import type { AttributionReportOpts, LoadState, Metric, ReportOpts } from './base'; + +export interface INPReportOpts extends ReportOpts { + durationThreshold?: number; +} + +export interface INPAttributionReportOpts extends AttributionReportOpts { + durationThreshold?: number; +} /** * An INP-specific version of the Metric object. @@ -24,6 +32,22 @@ export interface INPMetric extends Metric { entries: PerformanceEventTiming[]; } +export interface INPLongestScriptSummary { + /** + * The longest Long Animation Frame script entry that intersects the INP + * interaction. + */ + entry: PerformanceScriptTiming; + /** + * The INP subpart where the longest script ran. + */ + subpart: 'input-delay' | 'processing-duration' | 'presentation-delay'; + /** + * The amount of time the longest script intersected the INP duration. + */ + intersectingDuration: number; +} + /** * An object containing potentially-helpful debugging information that * can be sent along with the INP value for the current page visit in order @@ -31,37 +55,20 @@ export interface INPMetric extends Metric { */ export interface INPAttribution { /** - * A selector identifying the element that the user first interacted with - * as part of the frame where the INP candidate interaction occurred. - * If this value is an empty string, that generally means the element was - * removed from the DOM after the interaction. + * By default, a selector identifying the element that the user first + * interacted with as part of the frame where the INP candidate interaction + * occurred. If this value is an empty string, that generally means the + * element was removed from the DOM after the interaction. If the + * `generateTarget` configuration option was passed, then this will instead + * be the return value of that function. */ interactionTarget: string; - /** - * A reference to the HTML element identified by `interactionTargetSelector`. - * NOTE: for attribution purpose, a selector identifying the element is - * typically more useful than the element itself. However, the element is - * also made available in case additional context is needed. - */ - interactionTargetElement: Node | undefined; /** * The time when the user first interacted during the frame where the INP * candidate interaction occurred (if more than one interaction occurred * within the frame, only the first time is reported). */ interactionTime: DOMHighResTimeStamp; - /** - * The best-guess timestamp of the next paint after the interaction. - * In general, this timestamp is the same as the `startTime + duration` of - * the event timing entry. However, since `duration` values are rounded to - * the nearest 8ms, it can sometimes appear that the paint occurred before - * processing ended (which cannot happen). This value clamps the paint time - * so it's always after `processingEnd` from the Event Timing API and - * `renderStart` from the Long Animation Frame API (where available). - * It also averages the duration values for all entries in the same - * animation frame, which should be closer to the "real" value. - */ - nextPaintTime: DOMHighResTimeStamp; /** * The type of interaction, based on the event type of the `event` entry * that corresponds to the interaction (i.e. the first `event` entry @@ -70,20 +77,19 @@ export interface INPAttribution { * and for "keydown" or "keyup" events this will be "keyboard". */ interactionType: 'pointer' | 'keyboard'; + /** + * The best-guess timestamp of the next paint after the interaction. + * In general, this timestamp is the same as the `startTime + duration` of + * the event timing entry. However, since duration values are rounded to the + * nearest 8ms (and can be rounded down), this value is clamped to always be + * reported after the processing times. + */ + nextPaintTime: DOMHighResTimeStamp; /** * An array of Event Timing entries that were processed within the same * animation frame as the INP candidate interaction. */ processedEventEntries: PerformanceEventTiming[]; - /** - * If the browser supports the Long Animation Frame API, this array will - * include any `long-animation-frame` entries that intersect with the INP - * candidate interaction's `startTime` and the `processingEnd` time of the - * last event processed within that animation frame. If the browser does not - * support the Long Animation Frame API or no `long-animation-frame` entries - * are detect, this array will be empty. - */ - longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; /** * The time from when the user interacted with the page until when the * browser was first able to start processing event listeners for that @@ -112,6 +118,48 @@ export interface INPAttribution { * (e.g. usually in the `dom-interactive` phase) it can result in long delays. */ loadState: LoadState; + /** + * If the browser supports the Long Animation Frame API, this array will + * include any `long-animation-frame` entries that intersect with the INP + * candidate interaction's `startTime` and the `processingEnd` time of the + * last event processed within that animation frame. If the browser does not + * support the Long Animation Frame API or no `long-animation-frame` entries + * are detected, this array will be empty. + */ + longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + /** + * Summary information about the longest script entry intersecting the INP + * duration. Note, only script entries above 5 milliseconds are reported by + * the Long Animation Frame API. + */ + longestScript?: INPLongestScriptSummary; + /** + * The total duration of Long Animation Frame scripts that intersect the INP + * duration excluding any forced style and layout (that is included in + * totalStyleAndLayout). Note, this is limited to scripts > 5 milliseconds. + */ + totalScriptDuration?: number; + /** + * The total style and layout duration from any Long Animation Frames + * intersecting the INP interaction. This includes any end-of-frame style and + * layout duration + any forced style and layout duration. + */ + totalStyleAndLayoutDuration?: number; + /** + * The off main-thread presentation delay from the end of the last Long + * Animation Frame (where available) until the INP end point. + */ + totalPaintDuration?: number; + /** + * The total unattributed time not included in any of the previous totals. + * This includes scripts < 5 milliseconds and other timings not attributed + * by Long Animation Frame (including when a frame is < 50ms and so has no + * Long Animation Frame). + * When no Long Animation Frames are present this will be undefined, rather + * than everything being unattributed to make it clearer when it's expected + * to be small. + */ + totalUnattributedDuration?: number; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index 2dd5ea34f798..293531b3d45c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Metric } from './base'; +import type { Metric } from './base.js'; /** * An LCP-specific version of the Metric object. @@ -31,9 +31,12 @@ export interface LCPMetric extends Metric { */ export interface LCPAttribution { /** - * The element corresponding to the largest contentful paint for the page. + * By default, a selector identifying the element corresponding to the + * largest contentful paint for the page. If the `generateTarget` + * configuration option was passed, then this will instead be the return + * value of that function. */ - element?: string; + target?: string; /** * The URL (if applicable) of the LCP image resource. If the LCP element * is a text node, this value will not be set.