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} 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 isVisuallyCompleteSet = useRef(false); 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 (!isVisuallyCompleteSet.current) { const time = performance.now(); transaction.registerBeforeFinishCallback((t: Transaction, _) => { // Should be called after performance entries finish callback. t.setMeasurement('visuallyComplete', time, 'ms'); }); isVisuallyCompleteSet.current = true; } 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, 'ms'); } t.setTag('longTaskCount', longTaskCount.current); t.setMeasurement('visuallyCompleteData', time, 'ms'); }); }, 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', }, 'resource.css': { key: 'css', }, 'resource.link': { key: 'link', }, 'resource.img': { key: 'img', }, }; const ASSET_MEASUREMENT_ALL = 'allResources'; const measureAssetsOnTransaction = () => { try { const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api. if (!transaction) { return; } transaction.registerBeforeFinishCallback((t: Transaction) => { const spans: any[] = (t as any).spanRecorder?.spans; const measurements = (t as any)._measurements; if (!spans) { return; } if (measurements[ASSET_MEASUREMENT_ALL]) { return; } let allTransfered = 0; let allEncoded = 0; let allCount = 0; for (const [op, definition] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) { const filtered = spans.filter(s => s.op === op); 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 (op === 'resource.script') { t.setMeasurement(`assets.${definition.key}.encoded`, encoded, ''); t.setMeasurement(`assets.${definition.key}.transfer`, transfered, ''); t.setMeasurement(`assets.${definition.key}.count`, count, ''); } allCount += count; allTransfered += transfered; allEncoded += encoded; } t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.encoded`, allEncoded, ''); t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.transfer`, allTransfered, ''); t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.count`, allCount, ''); }); } catch (_) { // Defensive catch since this code is auxiliary. } }; /** * This will add asset-measurement code to the transaction after a timeout. * Meant to be called from the sdk without pushing too many perf concerns into our initializeSdk code, * it's fine if not every transaction gets recorded. */ export const initializeMeasureAssetsTimeout = () => { setTimeout(measureAssetsOnTransaction, 1000); };