import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
import {captureMessage, setExtra, setTag} from '@sentry/react';
import * as Sentry from '@sentry/react';
import {IdleTransaction} from '@sentry/tracing';
import {
type MeasurementUnit,
type Transaction,
type TransactionEvent,
} from '@sentry/types';
import {
_browserPerformanceTimeOriginMode,
browserPerformanceTimeOrigin,
timestampWithMs,
} from '@sentry/utils';
import {useLocation} from 'sentry/utils/useLocation';
import usePrevious from 'sentry/utils/usePrevious';
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.
const MEASUREMENT_OUTLIER_VALUE = 5 * 60_000; // Measurements over 5 minutes don't get recorded as a metric and are tagged instead.
const ASSET_OUTLIER_VALUE = 1_000_000_000; // Assets over 1GB are ignored since they are likely a reporting error.
const VCD_START = 'vcd-start';
const VCD_END = 'vcd-end';
/**
* 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() ?? Sentry.getActiveTransaction();
}
/**
* 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 = Sentry.getActiveTransaction();
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 function CustomProfiler({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 function VisuallyCompleteWithData({
id,
hasData,
children,
disabled,
isLoading,
}: {
children: ReactNode;
hasData: boolean;
id: string;
disabled?: boolean;
/**
* Add isLoading to also collect navigation timings, since the data state is sometimes constant before the reload occurs.
*/
isLoading?: boolean;
}) {
const location = useLocation();
const previousLocation = usePrevious(location);
const isDataCompleteSet = useRef(false);
const num = useRef(1);
const isVCDSet = useRef(false);
if (isVCDSet && hasData && performance && performance.mark && !disabled) {
performance.mark(`${id}-${VCD_START}`);
isVCDSet.current = true;
}
const _hasData = isLoading === undefined ? hasData : hasData && !isLoading;
useEffect(() => {
// Capture changes in location to reset VCD as it's likely indicative of a route change.
if (location !== previousLocation) {
isDataCompleteSet.current = false;
performance
.getEntriesByType('mark')
.map(m => m.name)
.filter(n => n.includes('vcd'))
.forEach(n => performance.clearMarks(n));
}
}, [location, previousLocation]);
useEffect(() => {
if (disabled) {
return;
}
try {
const transaction: any = Sentry.getActiveTransaction(); // Using any to override types for private api.
if (!transaction) {
return;
}
if (!isDataCompleteSet.current && _hasData) {
isDataCompleteSet.current = true;
performance.mark(`${id}-${VCD_END}-pretimeout`);
window.setTimeout(() => {
if (!browserPerformanceTimeOrigin) {
return;
}
performance.mark(`${id}-${VCD_END}`);
const startMarks = performance.getEntriesByName(`${id}-${VCD_START}`);
const endMarks = performance.getEntriesByName(`${id}-${VCD_END}`);
if (startMarks.length > 1 || endMarks.length > 1) {
transaction.setTag('vcd_extra_recorded_marks', true);
}
const startMark = startMarks.at(-1);
const endMark = endMarks.at(-1);
if (!startMark || !endMark) {
return;
}
performance.measure(
`VCD [${id}] #${num.current}`,
`${id}-${VCD_START}`,
`${id}-${VCD_END}`
);
num.current = num.current++;
}, 0);
}
} catch (_) {
// Defensive catch since this code is auxiliary.
}
}, [_hasData, disabled, id]);
if (disabled) {
return {children};
}
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'];
/**
* Creates aggregate measurements for assets to understand asset size impact on performance.
* The `hasAnyAssetTimings` is also added here since the asset information depends on the `allow-timing-origin` header.
*/
const addAssetMeasurements = (transaction: TransactionEvent) => {
const spans = transaction.spans;
if (!spans) {
return;
}
let allTransfered = 0;
let allEncoded = 0;
let hasAssetTimings = false;
const getOperation = data => data.operation ?? '';
const getTransferSize = data =>
data['http.response_transfer_size'] ?? data['Transfer Size'] ?? 0;
const getEncodedSize = data =>
data['http.response_content_length'] ?? data['Encoded Body Size'] ?? 0;
const getDecodedSize = data =>
data['http.decoded_response_content_length'] ?? data['Decoded Body Size'] ?? 0;
const getFields = data => ({
operation: getOperation(data),
transferSize: getTransferSize(data),
encodedSize: getEncodedSize(data),
decodedSize: getDecodedSize(data),
});
for (const [op, _] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) {
const filtered = spans.filter(
s =>
s.op === op &&
SENTRY_ASSET_DOMAINS.some(
domain =>
!s.description ||
s.description.includes(domain) ||
s.description.startsWith('/')
)
);
const transfered = filtered.reduce((acc, curr) => {
const fields = getFields(curr.data);
if (fields.transferSize > ASSET_OUTLIER_VALUE) {
return acc;
}
return acc + fields.transferSize;
}, 0);
const encoded = filtered.reduce((acc, curr) => {
const fields = getFields(curr.data);
if (
fields.encodedSize > ASSET_OUTLIER_VALUE ||
(fields.encodedSize > 0 && fields.decodedSize === 0)
) {
// There appears to be a bug where we have massive encoded sizes w/o a decode size, we'll ignore these assets for now.
return acc;
}
return acc + fields.encodedSize;
}, 0);
if (encoded > 0) {
hasAssetTimings = true;
}
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.tags.hasAnyAssetTimings = hasAssetTimings;
};
const addCustomMeasurements = (transaction: TransactionEvent) => {
if (!browserPerformanceTimeOrigin || !transaction.start_timestamp) {
return;
}
const measurements: Record = {...transaction.measurements};
const ttfb = Object.entries(measurements).find(([key]) =>
key.toLowerCase().includes('ttfb')
);
const ttfbValue = ttfb?.[1]?.value;
const context: MeasurementContext = {
transaction,
ttfb: ttfbValue,
browserTimeOrigin: browserPerformanceTimeOrigin,
transactionStart: transaction.start_timestamp,
transactionOp: (transaction.contexts?.trace?.op as string) ?? 'pageload',
};
for (const [name, fn] of Object.entries(customMeasurements)) {
const measurement = fn(context);
if (measurement) {
if (
measurement.unit === 'millisecond' &&
measurement.value > MEASUREMENT_OUTLIER_VALUE
) {
// exclude outlier measurements and don't add any of the custom measurements in case something is wrong.
if (transaction.tags) {
transaction.tags.outlier_vcd = name;
}
return;
}
measurements[name] = measurement;
}
}
transaction.measurements = measurements;
};
interface Measurement {
unit: MeasurementUnit;
value: number;
}
interface MeasurementContext {
browserTimeOrigin: number;
transaction: TransactionEvent;
transactionOp: string;
transactionStart: number;
ttfb?: number;
}
const getVCDSpan = (transaction: TransactionEvent) =>
transaction.spans?.find(s => s.description?.startsWith('VCD'));
const getBundleLoadSpan = (transaction: TransactionEvent) =>
transaction.spans?.find(s => s.description === 'app.page.bundle-load');
const customMeasurements: Record<
string,
(ctx: MeasurementContext) => Measurement | undefined
> = {
/**
* Budget measurement between the time to first byte (the beginning of the response) and the beginning of our
* webpack bundle load. Useful for us since we have an entrypoint script we want to measure the impact of.
*
* Performance budget: **0 ms**
*
* - We should get rid of delays before loading the main app bundle to improve performance.
*/
pre_bundle_load: ({ttfb, browserTimeOrigin, transactionStart}) => {
const headMark = performance.getEntriesByName('head-start')[0];
if (!headMark || !ttfb) {
return undefined;
}
const entryStartSeconds = browserTimeOrigin / 1000 + headMark.startTime / 1000;
const value = (entryStartSeconds - transactionStart) * 1000 - ttfb;
return {
value,
unit: 'millisecond',
};
},
/**
* Budget measurement representing the `app.page.bundle-load` measure.
* We can use this to track asset transfer performance impact over time as a measurement.
*
* Performance budget: **__** ms
*
*/
bundle_load: ({transaction, ttfb}) => {
const span = getBundleLoadSpan(transaction);
if (!span?.endTimestamp || !span?.startTimestamp || !ttfb) {
return undefined;
}
return {
value: (span?.endTimestamp - span?.startTimestamp) * 1000,
unit: 'millisecond',
};
},
/**
* Experience measurement representing the time when the first "visually complete" component approximately *finishes* rendering on the page.
* - Provided by the {@link VisuallyCompleteWithData} wrapper component.
* - This only fires when it receives a non-empty data set for that component. Which won't capture onboarding or empty states,
* but most 'happy path' performance for using any product occurs only in views with data.
* - Only record for pageload transactions
*
* This should replace LCP as a 'load' metric when it's present, since it also works on navigations.
*/
visually_complete_with_data: ({transaction, ttfb, transactionStart}) => {
const vcdSpan = getVCDSpan(transaction);
if (!vcdSpan?.endTimestamp || !ttfb) {
return undefined;
}
const value = (vcdSpan?.endTimestamp - transactionStart) * 1000;
return {
value,
unit: 'millisecond',
};
},
/**
* Budget measurement for the time between loading the bundle and a visually complete component finishing it's render.
*
* Fires for navigation components as well using the beginning of the navigation as 'init'
*
* For now this is a quite broad measurement but can be roughly be broken down into:
* - Post bundle load application initialization
* - Http waterfalls for data
* - Rendering of components, including the VCD component.
*/
init_to_vcd: ({transaction, transactionOp, transactionStart}) => {
const bundleSpan = getBundleLoadSpan(transaction);
const vcdSpan = getVCDSpan(transaction);
if (!vcdSpan?.endTimestamp || !['navigation', 'pageload'].includes(transactionOp)) {
return undefined;
}
const startTimestamp =
transactionOp === 'navigation' ? transactionStart : bundleSpan?.endTimestamp;
if (!startTimestamp) {
return undefined;
}
return {
value: (vcdSpan.endTimestamp - startTimestamp) * 1000,
unit: 'millisecond',
};
},
};
export const addExtraMeasurements = (transaction: TransactionEvent) => {
try {
addAssetMeasurements(transaction);
addCustomMeasurements(transaction);
} catch (_) {
// Defensive catch since this code is auxiliary.
}
};
/**
* A util function to help create some broad buckets to group entity counts without exploding cardinality.
*
* @param tagName - Name for the tag, will create `` in data and `.grouped` as a tag
* @param max - The approximate maximum value for the tag, A bucket between max and Infinity is also captured so it's fine if it's not precise, the data won't be entirely lost.
* @param n - The value to be grouped, should represent `n` entities.
* @param [buckets=[1,2,5]] - An optional param to specify the bucket progression. Default is 1,2,5 (10,20,50 etc).
*/
export const setGroupedEntityTag = (
tagName: string,
max: number,
n: number,
buckets = [1, 2, 5]
) => {
setExtra(tagName, n);
let groups = [0];
loop: for (let m = 1, mag = 0; m <= max; m *= 10, mag++) {
for (const i of buckets) {
const group = i * 10 ** mag;
if (group > max) {
break loop;
}
groups = [...groups, group];
}
}
groups = [...groups, +Infinity];
setTag(`${tagName}.grouped`, `<=${groups.find(g => n <= g)}`);
};
/**
* A temporary util function used for interaction transactions that will attach a tag to the transaction, indicating the element
* that was interacted with. This will allow for querying for transactions by a specific element. This is a high cardinality tag, but
* it is only temporary for an experiment
*/
export const addUIElementTag = (transaction: TransactionEvent) => {
if (!transaction || transaction.contexts?.trace?.op !== 'ui.action.click') {
return;
}
if (!transaction.tags) {
return;
}
const interactionSpan = transaction.spans?.find(
span => span.op === 'ui.interaction.click'
);
transaction.tags.interactionElement = interactionSpan?.description;
};