[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" />
Sebastian Markbåge committed
Jul 2, 2025 at 16:12 UTC
94fce500bcd314ea19c5c89c6385be6e62397c09
1 file changed
+160
-29
packages/react-client/src/ReactFlightPerformanceTrack.js
+160
-29
@@ -300,6 +300,125 @@ function getIOColor(
300
}
301
}
302
303
+function getIODescription(value: any): string {
304
+ if (!__DEV__) {
305
+ return '';
306
+ }
307
+ try {
308
+ switch (typeof value) {
309
+ case 'object':
310
+ // Test the object for a bunch of common property names that are useful identifiers.
311
+ // While we only have the return value here, it should ideally be a name that
312
+ // describes the arguments requested.
313
+ if (value === null) {
314
+ return '';
315
+ } else if (value instanceof Error) {
316
+ // eslint-disable-next-line react-internal/safe-string-coercion
317
+ return String(value.message);
318
+ } else if (typeof value.url === 'string') {
319
+ return value.url;
320
+ } else if (typeof value.command === 'string') {
321
+ return value.command;
322
+ } else if (
323
+ typeof value.request === 'object' &&
324
+ typeof value.request.url === 'string'
325
+ ) {
326
+ return value.request.url;
327
+ } else if (
328
+ typeof value.response === 'object' &&
329
+ typeof value.response.url === 'string'
330
+ ) {
331
+ return value.response.url;
332
+ } else if (
333
+ typeof value.id === 'string' ||
334
+ typeof value.id === 'number' ||
335
+ typeof value.id === 'bigint'
336
+ ) {
337
+ // eslint-disable-next-line react-internal/safe-string-coercion
338
+ return String(value.id);
339
+ } else if (typeof value.name === 'string') {
340
+ return value.name;
341
+ } else {
342
+ const str = value.toString();
343
+ if (str.startWith('[object ') || str.length < 5 || str.length > 500) {
344
+ // This is probably not a useful description.
345
+ return '';
346
+ }
347
+ return str;
348
+ }
349
+ case 'string':
350
+ if (value.length < 5 || value.length > 500) {
351
+ return '';
352
+ }
353
+ return value;
354
+ case 'number':
355
+ case 'bigint':
356
+ // eslint-disable-next-line react-internal/safe-string-coercion
357
+ return String(value);
358
+ default:
359
+ // Not useful descriptors.
360
+ return '';
361
+ }
362
+ } catch (x) {
363
+ return '';
364
+ }
365
+}
366
+
367
+function getIOLongName(
368
+ ioInfo: ReactIOInfo,
369
+ description: string,
370
+ env: void | string,
371
+ rootEnv: string,
372
+): string {
373
+ const name = ioInfo.name;
374
+ const longName = description === '' ? name : name + ' (' + description + ')';
375
+ const isPrimaryEnv = env === rootEnv;
376
+ return isPrimaryEnv || env === undefined
377
+ ? longName
378
+ : longName + ' [' + env + ']';
379
+}
380
+
381
+function getIOShortName(
382
+ ioInfo: ReactIOInfo,
383
+ description: string,
384
+ env: void | string,
385
+ rootEnv: string,
386
+): string {
387
+ const name = ioInfo.name;
388
+ const isPrimaryEnv = env === rootEnv;
389
+ const envSuffix = isPrimaryEnv || env === undefined ? '' : ' [' + env + ']';
390
+ let desc = '';
391
+ const descMaxLength = 30 - name.length - envSuffix.length;
392
+ if (descMaxLength > 1) {
393
+ const l = description.length;
394
+ if (l > 0 && l <= descMaxLength) {
395
+ // We can fit the full description
396
+ desc = ' (' + description + ')';
397
+ } else if (
398
+ description.startsWith('http://') ||
399
+ description.startsWith('https://') ||
400
+ description.startsWith('/')
401
+ ) {
402
+ // Looks like a URL. Let's see if we can extract something shorter.
403
+ // We don't have to do a full parse so let's try something cheaper.
404
+ let queryIdx = description.indexOf('?');
405
+ if (queryIdx === -1) {
406
+ queryIdx = description.length;
407
+ }
408
+ if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) {
409
+ // Ends with slash. Look before that.
410
+ queryIdx--;
411
+ }
412
+ const slashIdx = description.lastIndexOf('/', queryIdx - 1);
413
+ if (queryIdx - slashIdx < descMaxLength) {
414
+ // This may now be either the file name or the host.
415
+ desc = ' (' + description.slice(slashIdx + 1, queryIdx) + ')';
416
+ }
417
+ }
418
+ }
419
+ return name + desc + envSuffix;
420
+}
421
+
422
export function logComponentAwaitAborted(
423
asyncInfo: ReactAsyncInfo,
424
trackIdx: number,
@@ -308,17 +427,16 @@ export function logComponentAwaitAborted(
427
rootEnv: string,
428
): void {
429
if (supportsUserTiming && endTime > 0) {
311
- const env = asyncInfo.env;
312
- const name = asyncInfo.awaited.name;
313
- const isPrimaryEnv = env === rootEnv;
430
const entryName =
315
- 'await ' +
316
- (isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
431
+ 'await ' + getIOShortName(asyncInfo.awaited, '', asyncInfo.env, rootEnv);
432
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
433
if (__DEV__ && debugTask) {
434
const properties = [
435
['Aborted', 'The stream was aborted before this Promise resolved.'],
436
];
437
+ const tooltipText =
438
+ getIOLongName(asyncInfo.awaited, '', asyncInfo.env, rootEnv) +
439
+ ' Aborted';
440
debugTask.run(
441
// $FlowFixMe[method-unbinding]
442
performance.measure.bind(performance, entryName, {
@@ -330,7 +448,7 @@ export function logComponentAwaitAborted(
448
track: trackNames[trackIdx],
449
trackGroup: COMPONENTS_TRACK,
450
properties,
333
- tooltipText: entryName + ' Aborted',
451
+ tooltipText,
452
},
453
},
454
}),
@@ -357,12 +475,10 @@ export function logComponentAwaitErrored(
475
error: mixed,
476
): void {
477
if (supportsUserTiming && endTime > 0) {
360
- const env = asyncInfo.env;
361
- const name = asyncInfo.awaited.name;
362
- const isPrimaryEnv = env === rootEnv;
478
+ const description = getIODescription(error);
479
const entryName =
480
'await ' +
365
- (isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
481
+ getIOShortName(asyncInfo.awaited, description, asyncInfo.env, rootEnv);
482
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
483
if (__DEV__ && debugTask) {
484
const message =
@@ -374,6 +490,9 @@ export function logComponentAwaitErrored(
490
: // eslint-disable-next-line react-internal/safe-string-coercion
491
String(error);
492
const properties = [['Rejected', message]];
493
+ const tooltipText =
494
+ getIOLongName(asyncInfo.awaited, description, asyncInfo.env, rootEnv) +
495
+ ' Rejected';
496
debugTask.run(
497
// $FlowFixMe[method-unbinding]
498
performance.measure.bind(performance, entryName, {
@@ -385,7 +504,7 @@ export function logComponentAwaitErrored(
504
track: trackNames[trackIdx],
505
trackGroup: COMPONENTS_TRACK,
506
properties,
388
- tooltipText: entryName + ' Rejected',
507
+ tooltipText,
508
},
509
},
510
}),
@@ -412,13 +531,15 @@ export function logComponentAwait(
531
value: mixed,
532
): void {
533
if (supportsUserTiming && endTime > 0) {
415
- const env = asyncInfo.env;
416
- const name = asyncInfo.awaited.name;
417
- const isPrimaryEnv = env === rootEnv;
534
+ const description = getIODescription(value);
535
+ const name = getIOShortName(
536
+ asyncInfo.awaited,
537
+ description,
538
+ asyncInfo.env,
539
+ rootEnv,
540
+ );
541
+ const entryName = 'await ' + name;
542
const color = getIOColor(name);
419
- const entryName =
420
- 'await ' +
421
- (isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']');
543
const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask;
544
if (__DEV__ && debugTask) {
545
const properties: Array<[string, string]> = [];
@@ -427,6 +548,12 @@ export function logComponentAwait(
548
} else if (value !== undefined) {
549
addValueToProperties('Resolved', value, properties, 0, '');
550
}
551
+ const tooltipText = getIOLongName(
552
+ asyncInfo.awaited,
553
+ description,
554
+ asyncInfo.env,
555
+ rootEnv,
556
+ );
557
debugTask.run(
558
// $FlowFixMe[method-unbinding]
559
performance.measure.bind(performance, entryName, {
@@ -438,6 +565,7 @@ export function logComponentAwait(
565
track: trackNames[trackIdx],
566
trackGroup: COMPONENTS_TRACK,
567
properties,
568
+ tooltipText,
569
},
570
},
571
}),
@@ -463,11 +591,8 @@ export function logIOInfoErrored(
591
const startTime = ioInfo.start;
592
const endTime = ioInfo.end;
593
if (supportsUserTiming && endTime >= 0) {
466
- const name = ioInfo.name;
467
- const env = ioInfo.env;
468
- const isPrimaryEnv = env === rootEnv;
469
- const entryName =
470
- isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
594
+ const description = getIODescription(error);
595
+ const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
596
const debugTask = ioInfo.debugTask;
597
if (__DEV__ && debugTask) {
598
const message =
@@ -479,6 +604,8 @@ export function logIOInfoErrored(
604
: // eslint-disable-next-line react-internal/safe-string-coercion
605
String(error);
606
const properties = [['Rejected', message]];
607
+ const tooltipText =
608
+ getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected';
609
debugTask.run(
610
// $FlowFixMe[method-unbinding]
611
performance.measure.bind(performance, entryName, {
@@ -489,7 +616,7 @@ export function logIOInfoErrored(
616
color: 'error',
617
track: IO_TRACK,
618
properties,
492
- tooltipText: entryName + ' Rejected',
619
+ tooltipText,
620
},
621
},
622
}),
@@ -515,13 +642,10 @@ export function logIOInfo(
642
const startTime = ioInfo.start;
643
const endTime = ioInfo.end;
644
if (supportsUserTiming && endTime >= 0) {
518
- const name = ioInfo.name;
519
- const env = ioInfo.env;
520
- const isPrimaryEnv = env === rootEnv;
521
- const entryName =
522
- isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
645
+ const description = getIODescription(value);
646
+ const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
647
+ const color = getIOColor(entryName);
648
const debugTask = ioInfo.debugTask;
524
- const color = getIOColor(name);
649
if (__DEV__ && debugTask) {
650
const properties: Array<[string, string]> = [];
651
if (typeof value === 'object' && value !== null) {
@@ -529,6 +653,12 @@ export function logIOInfo(
653
} else if (value !== undefined) {
654
addValueToProperties('Resolved', value, properties, 0, '');
655
}
656
+ const tooltipText = getIOLongName(
657
+ ioInfo,
658
+ description,
659
+ ioInfo.env,
660
+ rootEnv,
661
+ );
662
debugTask.run(
663
// $FlowFixMe[method-unbinding]
664
performance.measure.bind(performance, entryName, {
@@ -539,6 +669,7 @@ export function logIOInfo(
669
color: color,
670
track: IO_TRACK,
671
properties,
672
+ tooltipText,
673
},
674
},
675
}),