@samitouri / QOS-React-2 / commits / 65a46c7eeb

[Flight] Track the function name that was called for I/O entries (#33392)

Stacked on #33390. The stack trace doesn't include the thing you called when calling into ignore listed content. We consider the ignore listed content conceptually the abstraction that you called that's interesting. This extracts the name of the first ignore listed function that was called from user space. For example `"fetch"`. So we can know what kind of request this is. This could be enhanced and tweaked with heuristics in the future. For example, when you create a Promise yourself and call I/O inside of it like my `delay` examples, then we use that Promise as the I/O node but its stack doesn't have the actual I/O performed. It might be better to use the inner I/O node in that case. E.g. `setTimeout`. Currently I pick the name from the first party code instead - in my example `delay`. Another case that could be improved is the case where your whole component is third-party. In that case we still log the I/O but it has no context about what kind of I/O since the whole stack is ignored it just gets the component name for example. We could for example look at the first name that is in a different package than the package name of the ignored listed component. So if `node_modules/my-component-library/index.js` calls into `node_modules/mysql/connection.js` then we could use the name from the inner.

Sebastian Markbåge committed Jun 3, 2025 at 15:04 UTC 65a46c7eebb731ba5c1602afef87365491beb75d
5 files changed +120 -55
packages/react-client/src/ReactFlightClient.js
+1 -1
@@ -675,7 +675,7 @@ function nullRefGetter() {
675 }
676
677 function getIOInfoTaskName(ioInfo: ReactIOInfo): string {
678 - return ''; // TODO
678 + return ioInfo.name || 'unknown';
679 }
680
681 function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string {
packages/react-server/src/ReactFlightServer.js
+97 -50
@@ -64,6 +64,7 @@ import type {
64 ReactAsyncInfo,
65 ReactTimeInfo,
66 ReactStackTrace,
67 + ReactCallSite,
68 ReactFunctionLocation,
69 ReactErrorInfo,
70 ReactErrorInfoDev,
@@ -164,55 +165,73 @@ function defaultFilterStackFrame(
165 );
166 }
167
167 -// DEV-only cache of parsed and filtered stack frames.
168 -const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
169 - ? new WeakMap()
170 - : (null: any);
168 +function devirtualizeURL(url: string): string {
169 + if (url.startsWith('rsc://React/')) {
170 + // This callsite is a virtual fake callsite that came from another Flight client.
171 + // We need to reverse it back into the original location by stripping its prefix
172 + // and suffix. We don't need the environment name because it's available on the
173 + // parent object that will contain the stack.
174 + const envIdx = url.indexOf('/', 12);
175 + const suffixIdx = url.lastIndexOf('?');
176 + if (envIdx > -1 && suffixIdx > -1) {
177 + return url.slice(envIdx + 1, suffixIdx);
178 + }
179 + }
180 + return url;
181 +}
182
172 -function filterStackTrace(
183 +function findCalledFunctionNameFromStackTrace(
184 request: Request,
174 - error: Error,
175 - skipFrames: number,
176 -): ReactStackTrace {
177 - const existing = stackTraceCache.get(error);
178 - if (existing !== undefined) {
179 - // Return a clone because the Flight protocol isn't yet resilient to deduping
180 - // objects in the debug info. TODO: Support deduping stacks.
181 - const clone = existing.slice(0);
182 - for (let i = 0; i < clone.length; i++) {
183 - // $FlowFixMe[invalid-tuple-arity]
184 - clone[i] = clone[i].slice(0);
185 + stack: ReactStackTrace,
186 +): string {
187 + // Gets the name of the first function called from first party code.
188 + let bestMatch = '';
189 + const filterStackFrame = request.filterStackFrame;
190 + for (let i = 0; i < stack.length; i++) {
191 + const callsite = stack[i];
192 + const functionName = callsite[0];
193 + const url = devirtualizeURL(callsite[1]);
194 + if (filterStackFrame(url, functionName)) {
195 + if (bestMatch === '') {
196 + // If we had no good stack frames for internal calls, just use the last
197 + // first party function name.
198 + return functionName;
199 + }
200 + return bestMatch;
201 + } else if (functionName === 'new Promise') {
202 + // Ignore Promise constructors.
203 + } else if (url === 'node:internal/async_hooks') {
204 + // Ignore the stack frames from the async hooks themselves.
205 + } else {
206 + bestMatch = functionName;
207 }
186 - return clone;
208 }
209 + return '';
210 +}
211 +
212 +function filterStackTrace(
213 + request: Request,
214 + stack: ReactStackTrace,
215 +): ReactStackTrace {
216 // Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
217 // to save bandwidth even in DEV. We'll also replay these stacks on the client so by
218 // stripping them early we avoid that overhead. Otherwise we'd normally just rely on
219 // the DevTools or framework's ignore lists to filter them out.
220 const filterStackFrame = request.filterStackFrame;
193 - const stack = parseStackTrace(error, skipFrames);
221 + const filteredStack: ReactStackTrace = [];
222 for (let i = 0; i < stack.length; i++) {
223 const callsite = stack[i];
224 const functionName = callsite[0];
197 - let url = callsite[1];
198 - if (url.startsWith('rsc://React/')) {
199 - // This callsite is a virtual fake callsite that came from another Flight client.
200 - // We need to reverse it back into the original location by stripping its prefix
201 - // and suffix. We don't need the environment name because it's available on the
202 - // parent object that will contain the stack.
203 - const envIdx = url.indexOf('/', 12);
204 - const suffixIdx = url.lastIndexOf('?');
205 - if (envIdx > -1 && suffixIdx > -1) {
206 - url = callsite[1] = url.slice(envIdx + 1, suffixIdx);
207 - }
208 - }
209 - if (!filterStackFrame(url, functionName)) {
210 - stack.splice(i, 1);
211 - i--;
225 + const url = devirtualizeURL(callsite[1]);
226 + if (filterStackFrame(url, functionName)) {
227 + // Use a clone because the Flight protocol isn't yet resilient to deduping
228 + // objects in the debug info. TODO: Support deduping stacks.
229 + const clone: ReactCallSite = (callsite.slice(0): any);
230 + clone[1] = url;
231 + filteredStack.push(clone);
232 }
233 }
214 - stackTraceCache.set(error, stack);
215 - return stack;
234 + return filteredStack;
235 }
236
237 initAsyncDebugInfo();
@@ -240,8 +259,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
259 // one stack frame but keeping it simple for now and include all frames.
260 const stack = filterStackTrace(
261 request,
243 - new Error('react-stack-top-frame'),
244 - 1,
262 + parseStackTrace(new Error('react-stack-top-frame'), 1),
263 );
264 request.pendingChunks++;
265 const owner: null | ReactComponentInfo = resolveOwner();
@@ -1078,7 +1096,7 @@ function callWithDebugContextInDEV<A, T>(
1096 componentDebugInfo.stack =
1097 task.debugStack === null
1098 ? null
1081 - : filterStackTrace(request, task.debugStack, 1);
1099 + : filterStackTrace(request, parseStackTrace(task.debugStack, 1));
1100 // $FlowFixMe[cannot-write]
1101 componentDebugInfo.debugStack = task.debugStack;
1102 // $FlowFixMe[cannot-write]
@@ -1279,7 +1297,7 @@ function renderFunctionComponent<Props>(
1297 componentDebugInfo.stack =
1298 task.debugStack === null
1299 ? null
1282 - : filterStackTrace(request, task.debugStack, 1);
1300 + : filterStackTrace(request, parseStackTrace(task.debugStack, 1));
1301 // $FlowFixMe[cannot-write]
1302 componentDebugInfo.props = props;
1303 // $FlowFixMe[cannot-write]
@@ -1615,7 +1633,7 @@ function renderClientElement(
1633 task.debugOwner,
1634 task.debugStack === null
1635 ? null
1618 - : filterStackTrace(request, task.debugStack, 1),
1636 + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)),
1637 validated,
1638 ]
1639 : [REACT_ELEMENT_TYPE, type, key, props];
@@ -1748,7 +1766,7 @@ function renderElement(
1766 stack:
1767 task.debugStack === null
1768 ? null
1751 - : filterStackTrace(request, task.debugStack, 1),
1769 + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)),
1770 props: props,
1771 debugStack: task.debugStack,
1772 debugTask: task.debugTask,
@@ -1877,7 +1895,10 @@ function visitAsyncNode(
1895 // We don't log it yet though. We return it to be logged by the point where it's awaited.
1896 // The ioNode might be another PromiseNode in the case where none of the AwaitNode had
1897 // unfiltered stacks.
1880 - if (filterStackTrace(request, node.stack, 1).length === 0) {
1898 + if (
1899 + filterStackTrace(request, parseStackTrace(node.stack, 1)).length ===
1900 + 0
1901 + ) {
1902 // Typically we assume that the outer most Promise that was awaited in user space has the
1903 // most actionable stack trace for the start of the operation. However, if this Promise
1904 // was created inside only third party code, then try to use the inner node instead.
@@ -1898,7 +1919,10 @@ function visitAsyncNode(
1919 if (awaited !== null) {
1920 const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited);
1921 if (ioNode !== null) {
1901 - const stack = filterStackTrace(request, node.stack, 1);
1922 + const stack = filterStackTrace(
1923 + request,
1924 + parseStackTrace(node.stack, 1),
1925 + );
1926 if (stack.length === 0) {
1927 // If this await was fully filtered out, then it was inside third party code
1928 // such as in an external library. We return the I/O node and try another await.
@@ -3272,7 +3296,7 @@ function emitPostponeChunk(
3296 try {
3297 // eslint-disable-next-line react-internal/safe-string-coercion
3298 reason = String(postponeInstance.message);
3275 - stack = filterStackTrace(request, postponeInstance, 0);
3299 + stack = filterStackTrace(request, parseStackTrace(postponeInstance, 0));
3300 } catch (x) {
3301 stack = [];
3302 }
@@ -3295,7 +3319,7 @@ function serializeErrorValue(request: Request, error: Error): string {
3319 name = error.name;
3320 // eslint-disable-next-line react-internal/safe-string-coercion
3321 message = String(error.message);
3298 - stack = filterStackTrace(request, error, 0);
3322 + stack = filterStackTrace(request, parseStackTrace(error, 0));
3323 const errorEnv = (error: any).environmentName;
3324 if (typeof errorEnv === 'string') {
3325 // This probably came from another FlightClient as a pass through.
@@ -3334,7 +3358,7 @@ function emitErrorChunk(
3358 name = error.name;
3359 // eslint-disable-next-line react-internal/safe-string-coercion
3360 message = String(error.message);
3337 - stack = filterStackTrace(request, error, 0);
3361 + stack = filterStackTrace(request, parseStackTrace(error, 0));
3362 const errorEnv = (error: any).environmentName;
3363 if (typeof errorEnv === 'string') {
3364 // This probably came from another FlightClient as a pass through.
@@ -3496,6 +3520,7 @@ function outlineComponentInfo(
3520 function emitIOInfoChunk(
3521 request: Request,
3522 id: number,
3523 + name: string,
3524 start: number,
3525 end: number,
3526 stack: ?ReactStackTrace,
@@ -3532,6 +3557,7 @@ function emitIOInfoChunk(
3557 const relativeStartTimestamp = start - request.timeOrigin;
3558 const relativeEndTimestamp = end - request.timeOrigin;
3559 const debugIOInfo: Omit<ReactIOInfo, 'debugTask' | 'debugStack'> = {
3560 + name: name,
3561 start: relativeStartTimestamp,
3562 end: relativeEndTimestamp,
3563 stack: stack,
@@ -3551,7 +3577,14 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
3577 // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
3578 request.pendingChunks++;
3579 const id = request.nextChunkId++;
3554 - emitIOInfoChunk(request, id, ioInfo.start, ioInfo.end, ioInfo.stack);
3580 + emitIOInfoChunk(
3581 + request,
3582 + id,
3583 + ioInfo.name,
3584 + ioInfo.start,
3585 + ioInfo.end,
3586 + ioInfo.stack,
3587 + );
3588 request.writtenObjects.set(ioInfo, serializeByValueID(id));
3589 }
3590
@@ -3566,12 +3599,23 @@ function serializeIONode(
3599 }
3600
3601 let stack = null;
3602 + let name = '';
3603 if (ioNode.stack !== null) {
3570 - stack = filterStackTrace(request, ioNode.stack, 1);
3604 + const fullStack = parseStackTrace(ioNode.stack, 1);
3605 + stack = filterStackTrace(request, fullStack);
3606 + name = findCalledFunctionNameFromStackTrace(request, fullStack);
3607 + // The name can include the object that this was called on but sometimes that's
3608 + // just unnecessary context.
3609 + if (name.startsWith('Window.')) {
3610 + name = name.slice(7);
3611 + } else if (name.startsWith('<anonymous>.')) {
3612 + name = name.slice(7);
3613 + }
3614 }
3615 +
3616 request.pendingChunks++;
3617 const id = request.nextChunkId++;
3574 - emitIOInfoChunk(request, id, ioNode.start, ioNode.end, stack);
3618 + emitIOInfoChunk(request, id, name, ioNode.start, ioNode.end, stack);
3619 const ref = serializeByValueID(id);
3620 request.writtenObjects.set(ioNode, ref);
3621 return ref;
@@ -3712,7 +3756,10 @@ function renderConsoleValue(
3756 let debugStack: null | ReactStackTrace = null;
3757 if (element._debugStack != null) {
3758 // Outline the debug stack so that it doesn't get cut off.
3715 - debugStack = filterStackTrace(request, element._debugStack, 1);
3759 + debugStack = filterStackTrace(
3760 + request,
3761 + parseStackTrace(element._debugStack, 1),
3762 + );
3763 doNotLimit.add(debugStack);
3764 for (let i = 0; i < debugStack.length; i++) {
3765 doNotLimit.add(debugStack[i]);
packages/react-server/src/ReactFlightStackConfigV8.js
+14
@@ -126,10 +126,22 @@ function collectStackTrace(
126 const frameRegExp =
127 /^ {3} at (?:(.+) \((?:(.+):(\d+):(\d+)|\<anonymous\>)\)|(?:async )?(.+):(\d+):(\d+)|\<anonymous\>)$/;
128
129 +// DEV-only cache of parsed and filtered stack frames.
130 +const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
131 + ? new WeakMap()
132 + : (null: any);
133 +
134 export function parseStackTrace(
135 error: Error,
136 skipFrames: number,
137 ): ReactStackTrace {
138 + // We can only get structured data out of error objects once. So we cache the information
139 + // so we can get it again each time. It also helps performance when the same error is
140 + // referenced more than once.
141 + const existing = stackTraceCache.get(error);
142 + if (existing !== undefined) {
143 + return existing;
144 + }
145 // We override Error.prepareStackTrace with our own version that collects
146 // the structured data. We need more information than the raw stack gives us
147 // and we need to ensure that we don't get the source mapped version.
@@ -148,6 +160,7 @@ export function parseStackTrace(
160 if (collectedStackTrace !== null) {
161 const result = collectedStackTrace;
162 collectedStackTrace = null;
163 + stackTraceCache.set(error, result);
164 return result;
165 }
166
@@ -191,5 +204,6 @@ export function parseStackTrace(
204 const col = +(parsed[4] || parsed[7]);
205 parsedFrames.push([name, filename, line, col, 0, 0]);
206 }
207 + stackTraceCache.set(error, parsedFrames);
208 return parsedFrames;
209 }
packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
+7 -4
@@ -170,6 +170,7 @@ describe('ReactFlightAsyncDebugInfo', () => {
170 {
171 "awaited": {
172 "end": 0,
173 + "name": "delay",
174 "stack": [
175 [
176 "delay",
@@ -220,6 +221,7 @@ describe('ReactFlightAsyncDebugInfo', () => {
221 {
222 "awaited": {
223 "end": 0,
224 + "name": "delay",
225 "stack": [
226 [
227 "delay",
@@ -321,9 +323,9 @@ describe('ReactFlightAsyncDebugInfo', () => {
323 [
324 "Object.<anonymous>",
325 "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
324 - 291,
326 + 293,
327 109,
326 - 278,
328 + 280,
329 67,
330 ],
331 ],
@@ -331,13 +333,14 @@ describe('ReactFlightAsyncDebugInfo', () => {
333 {
334 "awaited": {
335 "end": 0,
336 + "name": "setTimeout",
337 "stack": [
338 [
339 "Component",
340 "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
338 - 281,
341 + 283,
342 7,
340 - 279,
343 + 281,
344 5,
345 ],
346 ],
packages/shared/ReactTypes.js
+1
@@ -231,6 +231,7 @@ export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;
231
232 // The point where the Async Info started which might not be the same place it was awaited.
233 export type ReactIOInfo = {
234 + +name: string, // the name of the async function being called (e.g. "fetch")
235 +start: number, // the start time
236 +end: number, // the end time (this might be different from the time the await was unblocked)
237 +stack?: null | ReactStackTrace,