@samitouri / QOS-React-1 / commits / 94fce500bc

[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 }),