[Flight] Use a heuristic to extract a useful description of I/O from the Promise value (#33662)

It's useful to be able to distinguish between different invocations of
common helper libraries (like fetch) without having to click through
each one.

This adds a heuristic to extract a useful description of I/O from the
Promise value. We try to find things like getUser(id) -> User where
User.id is the id or fetch(url) -> Response where Response.url is the
url.

For urls we use the filename (or hostname if there is none) as the short
name if it can fit. The full url is in the tooltip.

<img width="845" alt="Screenshot 2025-06-27 at 7 58 20 PM"
src="https://github.com/user-attachments/assets/95f10c08-13a8-449e-97e8-52f0083a65dc"
/>
This commit is contained in:
Sebastian Markbåge 2025-07-02 16:12:37 -04:00 committed by GitHub
parent 508f7aa78f
commit 94fce500bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -300,6 +300,125 @@ function getIOColor(
}
}
function getIODescription(value: any): string {
if (!__DEV__) {
return '';
}
try {
switch (typeof value) {
case 'object':
// Test the object for a bunch of common property names that are useful identifiers.
// While we only have the return value here, it should ideally be a name that
// describes the arguments requested.
if (value === null) {
return '';
} else if (value instanceof Error) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.message);
} else if (typeof value.url === 'string') {
return value.url;
} else if (typeof value.command === 'string') {
return value.command;
} else if (
typeof value.request === 'object' &&
typeof value.request.url === 'string'
) {
return value.request.url;
} else if (
typeof value.response === 'object' &&
typeof value.response.url === 'string'
) {
return value.response.url;
} else if (
typeof value.id === 'string' ||
typeof value.id === 'number' ||
typeof value.id === 'bigint'
) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.id);
} else if (typeof value.name === 'string') {
return value.name;
} else {
const str = value.toString();
if (str.startWith('[object ') || str.length < 5 || str.length > 500) {
// This is probably not a useful description.
return '';
}
return str;
}
case 'string':
if (value.length < 5 || value.length > 500) {
return '';
}
return value;
case 'number':
case 'bigint':
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value);
default:
// Not useful descriptors.
return '';
}
} catch (x) {
return '';
}
}
function getIOLongName(
ioInfo: ReactIOInfo,
description: string,
env: void | string,
rootEnv: string,
): string {
const name = ioInfo.name;
const longName = description === '' ? name : name + ' (' + description + ')';
const isPrimaryEnv = env === rootEnv;
return isPrimaryEnv || env === undefined
? longName
: longName + ' [' + env + ']';
}
function getIOShortName(
ioInfo: ReactIOInfo,
description: string,
env: void | string,
rootEnv: string,
): string {
const name = ioInfo.name;
const isPrimaryEnv = env === rootEnv;
const envSuffix = isPrimaryEnv || env === undefined ? '' : ' [' + env + ']';
let desc = '';
const descMaxLength = 30 - name.length - envSuffix.length;
if (descMaxLength > 1) {
const l = description.length;
if (l > 0 && l <= descMaxLength) {
// We can fit the full description
desc = ' (' + description + ')';
} else if (
description.startsWith('http://') ||
description.startsWith('https://') ||
description.startsWith('/')
) {
// Looks like a URL. Let's see if we can extract something shorter.
// We don't have to do a full parse so let's try something cheaper.
let queryIdx = description.indexOf('?');
if (queryIdx === -1) {
queryIdx = description.length;
}
if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) {
// Ends with slash. Look before that.
queryIdx--;
}
const slashIdx = description.lastIndexOf('/', queryIdx - 1);
if (queryIdx - slashIdx < descMaxLength) {
// This may now be either the file name or the host.
desc = ' (' + description.slice(slashIdx + 1, queryIdx) + ')';
}
}
}
return name + desc + envSuffix;
}
export function logComponentAwaitAborted(
asyncInfo: ReactAsyncInfo,
trackIdx: number,
@ -308,17 +427,16 @@ export function logComponentAwaitAborted(
rootEnv: string,
): void {
if (supportsUserTiming && endTime > 0) {
const env = asyncInfo.env;
const name = asyncInfo.awaited.name;
const isPrimaryEnv = env === rootEnv;
const entryName =
'await ' +
(isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
'await ' + getIOShortName(asyncInfo.awaited, '', asyncInfo.env, rootEnv);
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
if (__DEV__ && debugTask) {
const properties = [
['Aborted', 'The stream was aborted before this Promise resolved.'],
];
const tooltipText =
getIOLongName(asyncInfo.awaited, '', asyncInfo.env, rootEnv) +
' Aborted';
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
@ -330,7 +448,7 @@ export function logComponentAwaitAborted(
track: trackNames[trackIdx],
trackGroup: COMPONENTS_TRACK,
properties,
tooltipText: entryName + ' Aborted',
tooltipText,
},
},
}),
@ -357,12 +475,10 @@ export function logComponentAwaitErrored(
error: mixed,
): void {
if (supportsUserTiming && endTime > 0) {
const env = asyncInfo.env;
const name = asyncInfo.awaited.name;
const isPrimaryEnv = env === rootEnv;
const description = getIODescription(error);
const entryName =
'await ' +
(isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
getIOShortName(asyncInfo.awaited, description, asyncInfo.env, rootEnv);
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
if (__DEV__ && debugTask) {
const message =
@ -374,6 +490,9 @@ export function logComponentAwaitErrored(
: // eslint-disable-next-line react-internal/safe-string-coercion
String(error);
const properties = [['Rejected', message]];
const tooltipText =
getIOLongName(asyncInfo.awaited, description, asyncInfo.env, rootEnv) +
' Rejected';
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
@ -385,7 +504,7 @@ export function logComponentAwaitErrored(
track: trackNames[trackIdx],
trackGroup: COMPONENTS_TRACK,
properties,
tooltipText: entryName + ' Rejected',
tooltipText,
},
},
}),
@ -412,13 +531,15 @@ export function logComponentAwait(
value: mixed,
): void {
if (supportsUserTiming && endTime > 0) {
const env = asyncInfo.env;
const name = asyncInfo.awaited.name;
const isPrimaryEnv = env === rootEnv;
const description = getIODescription(value);
const name = getIOShortName(
asyncInfo.awaited,
description,
asyncInfo.env,
rootEnv,
);
const entryName = 'await ' + name;
const color = getIOColor(name);
const entryName =
'await ' +
(isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
if (__DEV__ && debugTask) {
const properties: Array<[string, string]> = [];
@ -427,6 +548,12 @@ export function logComponentAwait(
} else if (value !== undefined) {
addValueToProperties('Resolved', value, properties, 0, '');
}
const tooltipText = getIOLongName(
asyncInfo.awaited,
description,
asyncInfo.env,
rootEnv,
);
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
@ -438,6 +565,7 @@ export function logComponentAwait(
track: trackNames[trackIdx],
trackGroup: COMPONENTS_TRACK,
properties,
tooltipText,
},
},
}),
@ -463,11 +591,8 @@ export function logIOInfoErrored(
const startTime = ioInfo.start;
const endTime = ioInfo.end;
if (supportsUserTiming && endTime >= 0) {
const name = ioInfo.name;
const env = ioInfo.env;
const isPrimaryEnv = env === rootEnv;
const entryName =
isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
const description = getIODescription(error);
const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
const debugTask = ioInfo.debugTask;
if (__DEV__ && debugTask) {
const message =
@ -479,6 +604,8 @@ export function logIOInfoErrored(
: // eslint-disable-next-line react-internal/safe-string-coercion
String(error);
const properties = [['Rejected', message]];
const tooltipText =
getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected';
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
@ -489,7 +616,7 @@ export function logIOInfoErrored(
color: 'error',
track: IO_TRACK,
properties,
tooltipText: entryName + ' Rejected',
tooltipText,
},
},
}),
@ -515,13 +642,10 @@ export function logIOInfo(
const startTime = ioInfo.start;
const endTime = ioInfo.end;
if (supportsUserTiming && endTime >= 0) {
const name = ioInfo.name;
const env = ioInfo.env;
const isPrimaryEnv = env === rootEnv;
const entryName =
isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
const description = getIODescription(value);
const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
const color = getIOColor(entryName);
const debugTask = ioInfo.debugTask;
const color = getIOColor(name);
if (__DEV__ && debugTask) {
const properties: Array<[string, string]> = [];
if (typeof value === 'object' && value !== null) {
@ -529,6 +653,12 @@ export function logIOInfo(
} else if (value !== undefined) {
addValueToProperties('Resolved', value, properties, 0, '');
}
const tooltipText = getIOLongName(
ioInfo,
description,
ioInfo.env,
rootEnv,
);
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
@ -539,6 +669,7 @@ export function logIOInfo(
color: color,
track: IO_TRACK,
properties,
tooltipText,
},
},
}),