import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react'; import {captureException, captureMessage} from '@sentry/react'; import * as Sentry from '@sentry/react'; import {IdleTransaction} from '@sentry/tracing'; import {Transaction, TransactionEvent} from '@sentry/types'; import {browserPerformanceTimeOrigin, timestampWithMs} from '@sentry/utils'; import getCurrentSentryReactTransaction from './getCurrentSentryReactTransaction'; const MIN_UPDATE_SPAN_TIME = 16; // Frame boundary @ 60fps const WAIT_POST_INTERACTION = 50; // Leave a small amount of time for observers and onRenderCallback to log since they come in after they occur and not during. const INTERACTION_TIMEOUT = 2 * 60_000; // 2min. Wrap interactions up after this time since we don't want transactions sticking around forever. /** * It depends on where it is called but the way we fetch transactions can be empty despite an ongoing transaction existing. * This will return an interaction-type transaction held onto by a class static if one exists. */ export function getPerformanceTransaction(): IdleTransaction | Transaction | undefined { return PerformanceInteraction.getTransaction() ?? getCurrentSentryReactTransaction(); } /** * Callback for React Profiler https://reactjs.org/docs/profiler.html */ export function onRenderCallback( id: string, phase: 'mount' | 'update', actualDuration: number ) { try { const transaction: Transaction | undefined = getPerformanceTransaction(); if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) { const now = timestampWithMs(); transaction.startChild({ description: `<${id}>`, op: `ui.react.${phase}`, startTimestamp: now - actualDuration / 1000, endTimestamp: now, }); } } catch (_) { // Add defensive catch since this wraps all of App } } export class PerformanceInteraction { private static interactionTransaction: Transaction | null = null; private static interactionTimeoutId: number | undefined = undefined; static getTransaction() { return PerformanceInteraction.interactionTransaction; } static startInteraction(name: string, timeout = INTERACTION_TIMEOUT, immediate = true) { try { const currentIdleTransaction = getCurrentSentryReactTransaction(); if (currentIdleTransaction) { // If interaction is started while idle still exists. currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout. currentIdleTransaction.finish(); } PerformanceInteraction.finishInteraction(immediate); const txn = Sentry?.startTransaction({ name: `ui.${name}`, op: 'interaction', }); PerformanceInteraction.interactionTransaction = txn; // Auto interaction timeout PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => { if (!PerformanceInteraction.interactionTransaction) { return; } PerformanceInteraction.interactionTransaction.setTag( 'ui.interaction.finish', 'timeout' ); PerformanceInteraction.finishInteraction(true); }, timeout); } catch (e) { captureMessage(e); } } static async finishInteraction(immediate = false) { try { if (!PerformanceInteraction.interactionTransaction) { return; } clearTimeout(PerformanceInteraction.interactionTimeoutId); if (immediate) { PerformanceInteraction.interactionTransaction?.finish(); PerformanceInteraction.interactionTransaction = null; return; } // Add a slight wait if this isn't called as the result of another transaction starting. await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION)); PerformanceInteraction.interactionTransaction?.finish(); PerformanceInteraction.interactionTransaction = null; return; } catch (e) { captureMessage(e); } } } export class LongTaskObserver { private static observer: PerformanceObserver; private static longTaskCount = 0; private static longTaskDuration = 0; private static lastTransaction: IdleTransaction | Transaction | undefined; static setLongTaskData(t: IdleTransaction | Transaction) { const group = [ 1, 2, 5, 10, 25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1001, ].find(n => LongTaskObserver.longTaskCount <= n) || -1; t.setTag('ui.longTaskCount.grouped', group < 1001 ? `<=${group}` : `>1000`); t.setMeasurement('longTaskCount', LongTaskObserver.longTaskCount, ''); t.setMeasurement('longTaskDuration', LongTaskObserver.longTaskDuration, ''); } static startPerformanceObserver(): PerformanceObserver | null { try { if (LongTaskObserver.observer) { LongTaskObserver.observer.disconnect(); try { LongTaskObserver.observer.observe({entryTypes: ['longtask']}); } catch (_) { // Safari doesn't support longtask, ignore this error. } return LongTaskObserver.observer; } if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) { return null; } const observer = new PerformanceObserver(function () { try { const transaction = getPerformanceTransaction(); if (!transaction) { return; } if (transaction !== LongTaskObserver.lastTransaction) { // If long tasks observer is active and is called while the transaction has changed. if (LongTaskObserver.lastTransaction) { LongTaskObserver.setLongTaskData(LongTaskObserver.lastTransaction); } LongTaskObserver.longTaskCount = 0; LongTaskObserver.longTaskDuration = 0; LongTaskObserver.lastTransaction = transaction; } LongTaskObserver.setLongTaskData(transaction); } catch (_) { // Defensive catch. } }); if (!observer || !observer.observe) { return null; } LongTaskObserver.observer = observer; try { LongTaskObserver.observer.observe({entryTypes: ['longtask']}); } catch (_) { // Safari doesn't support longtask, ignore this error. } return LongTaskObserver.observer; } catch (e) { captureException(e); // Defensive try catch. } return null; } } export const CustomerProfiler = ({id, children}: {children: ReactNode; id: string}) => { return ( {children} ); }; /** * This component wraps the main component on a page with a measurement checking for visual completedness. * It uses the data check to make sure endpoints have resolved and the component is meaningfully rendering * which sets it apart from simply checking LCP, which makes it a good back up check the LCP heuristic performance. * * Since this component is guaranteed to be part of the -real- critical path, it also wraps the component with the custom profiler. */ export const VisuallyCompleteWithData = ({ id, hasData, children, }: { children: ReactNode; hasData: boolean; id: string; }) => { const isDataCompleteSet = useRef(false); const longTaskCount = useRef(0); useEffect(() => { let observer; try { if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) { return () => {}; } observer = LongTaskObserver.startPerformanceObserver(); } catch (_) { // Defensive since this is auxiliary code. } return () => { if (observer && observer.disconnect) { observer.disconnect(); } }; }, []); const num = useRef(1); const isVCDSet = useRef(false); if (isVCDSet && hasData && performance && performance.mark) { performance.mark(`${id}-vcsd-start`); isVCDSet.current = true; } useEffect(() => { try { const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api. if (!transaction) { return; } if (!isDataCompleteSet.current && hasData) { isDataCompleteSet.current = true; performance.mark(`${id}-vcsd-end-pre-timeout`); window.setTimeout(() => { if (!browserPerformanceTimeOrigin) { return; } performance.mark(`${id}-vcsd-end`); const measureName = `VCD [${id}] #${num.current}`; performance.measure( `VCD [${id}] #${num.current}`, `${id}-vcsd-start`, `${id}-vcsd-end` ); num.current = num.current++; const [measureEntry] = performance.getEntriesByName(measureName); if (!measureEntry) { return; } transaction.registerBeforeFinishCallback((t: Transaction) => { if (!browserPerformanceTimeOrigin) { return; } // Should be called after performance entries finish callback. const lcp = (t as any)._measurements.lcp?.value; // Adjust to be relative to transaction.startTimestamp const entryStartSeconds = browserPerformanceTimeOrigin / 1000 + measureEntry.startTime / 1000; const time = (entryStartSeconds - transaction.startTimestamp) * 1000; if (lcp) { t.setMeasurement('lcpDiffVCD', lcp - time, 'millisecond'); } t.setTag('longTaskCount', longTaskCount.current); t.setMeasurement('visuallyCompleteData', time, 'millisecond'); }); }, 0); } } catch (_) { // Defensive catch since this code is auxiliary. } }, [hasData, id]); return ( {children} ); }; interface OpAssetMeasurementDefinition { key: string; } const OP_ASSET_MEASUREMENT_MAP: Record = { 'resource.script': { key: 'script', }, }; const ASSET_MEASUREMENT_ALL = 'allResources'; const SENTRY_ASSET_DOMAINS = ['sentry-cdn.com']; const measureAssetsOnTransaction = (transaction: TransactionEvent) => { const spans = transaction.spans; if (!spans) { return; } let allTransfered = 0; let allEncoded = 0; let allCount = 0; let hasAssetTimings = false; for (const [op, _] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) { const filtered = spans.filter( s => s.op === op && SENTRY_ASSET_DOMAINS.every( domain => !s.description || s.description.includes(domain) ) ); const count = filtered.length; const transfered = filtered.reduce( (acc, curr) => acc + (curr.data['Transfer Size'] ?? 0), 0 ); const encoded = filtered.reduce( (acc, curr) => acc + (curr.data['Encoded Body Size'] ?? 0), 0 ); if (encoded > 0) { hasAssetTimings = true; } allCount += count; allTransfered += transfered; allEncoded += encoded; } if (!transaction.measurements || !transaction.tags) { return; } transaction.measurements[`${ASSET_MEASUREMENT_ALL}.encoded`] = { value: allEncoded, unit: 'byte', }; transaction.measurements[`${ASSET_MEASUREMENT_ALL}.transfer`] = { value: allTransfered, unit: 'byte', }; transaction.measurements[`${ASSET_MEASUREMENT_ALL}.count`] = { value: allCount, unit: 'none', }; transaction.tags.hasAnyAssetTimings = hasAssetTimings; }; const additionalMeasurements = (transaction: TransactionEvent) => { if ( !transaction.measurements || !browserPerformanceTimeOrigin || !transaction.start_timestamp ) { return; } const ttfb = Object.entries(transaction.measurements).find(([key]) => key.toLowerCase().includes('ttfb') ); if (!ttfb || !ttfb[1]) { return; } const headMark = performance.getEntriesByName('head-start')[0]; if (!headMark) { return; } const ttfbValue = ttfb[1].value; const entryStartSeconds = browserPerformanceTimeOrigin / 1000 + headMark.startTime / 1000; const time = (entryStartSeconds - transaction.start_timestamp) * 1000 - ttfbValue; transaction.measurements.pre_bundle_load = { value: time, unit: 'millisecond', }; }; export const addExtraMeasurements = (transaction: TransactionEvent) => { try { measureAssetsOnTransaction(transaction); additionalMeasurements(transaction); } catch (_) { // Defensive catch since this code is auxiliary. } };