Skip to content

Commit 65a46c7

Browse files
authored
[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.
1 parent 3fb17d1 commit 65a46c7

File tree

5 files changed

+120
-55
lines changed

5 files changed

+120
-55
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ function nullRefGetter() {
675675
}
676676

677677
function getIOInfoTaskName(ioInfo: ReactIOInfo): string {
678-
return ''; // TODO
678+
return ioInfo.name || 'unknown';
679679
}
680680

681681
function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string {

packages/react-server/src/ReactFlightServer.js

Lines changed: 97 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import type {
6464
ReactAsyncInfo,
6565
ReactTimeInfo,
6666
ReactStackTrace,
67+
ReactCallSite,
6768
ReactFunctionLocation,
6869
ReactErrorInfo,
6970
ReactErrorInfoDev,
@@ -164,55 +165,73 @@ function defaultFilterStackFrame(
164165
);
165166
}
166167

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+
}
171182

172-
function filterStackTrace(
183+
function findCalledFunctionNameFromStackTrace(
173184
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;
185207
}
186-
return clone;
187208
}
209+
return '';
210+
}
211+
212+
function filterStackTrace(
213+
request: Request,
214+
stack: ReactStackTrace,
215+
): ReactStackTrace {
188216
// Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
189217
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
190218
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
191219
// the DevTools or framework's ignore lists to filter them out.
192220
const filterStackFrame = request.filterStackFrame;
193-
const stack = parseStackTrace(error, skipFrames);
221+
const filteredStack: ReactStackTrace = [];
194222
for (let i = 0; i < stack.length; i++) {
195223
const callsite = stack[i];
196224
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);
212232
}
213233
}
214-
stackTraceCache.set(error, stack);
215-
return stack;
234+
return filteredStack;
216235
}
217236

218237
initAsyncDebugInfo();
@@ -240,8 +259,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
240259
// one stack frame but keeping it simple for now and include all frames.
241260
const stack = filterStackTrace(
242261
request,
243-
new Error('react-stack-top-frame'),
244-
1,
262+
parseStackTrace(new Error('react-stack-top-frame'), 1),
245263
);
246264
request.pendingChunks++;
247265
const owner: null | ReactComponentInfo = resolveOwner();
@@ -1078,7 +1096,7 @@ function callWithDebugContextInDEV<A, T>(
10781096
componentDebugInfo.stack =
10791097
task.debugStack === null
10801098
? null
1081-
: filterStackTrace(request, task.debugStack, 1);
1099+
: filterStackTrace(request, parseStackTrace(task.debugStack, 1));
10821100
// $FlowFixMe[cannot-write]
10831101
componentDebugInfo.debugStack = task.debugStack;
10841102
// $FlowFixMe[cannot-write]
@@ -1279,7 +1297,7 @@ function renderFunctionComponent<Props>(
12791297
componentDebugInfo.stack =
12801298
task.debugStack === null
12811299
? null
1282-
: filterStackTrace(request, task.debugStack, 1);
1300+
: filterStackTrace(request, parseStackTrace(task.debugStack, 1));
12831301
// $FlowFixMe[cannot-write]
12841302
componentDebugInfo.props = props;
12851303
// $FlowFixMe[cannot-write]
@@ -1615,7 +1633,7 @@ function renderClientElement(
16151633
task.debugOwner,
16161634
task.debugStack === null
16171635
? null
1618-
: filterStackTrace(request, task.debugStack, 1),
1636+
: filterStackTrace(request, parseStackTrace(task.debugStack, 1)),
16191637
validated,
16201638
]
16211639
: [REACT_ELEMENT_TYPE, type, key, props];
@@ -1748,7 +1766,7 @@ function renderElement(
17481766
stack:
17491767
task.debugStack === null
17501768
? null
1751-
: filterStackTrace(request, task.debugStack, 1),
1769+
: filterStackTrace(request, parseStackTrace(task.debugStack, 1)),
17521770
props: props,
17531771
debugStack: task.debugStack,
17541772
debugTask: task.debugTask,
@@ -1877,7 +1895,10 @@ function visitAsyncNode(
18771895
// We don't log it yet though. We return it to be logged by the point where it's awaited.
18781896
// The ioNode might be another PromiseNode in the case where none of the AwaitNode had
18791897
// unfiltered stacks.
1880-
if (filterStackTrace(request, node.stack, 1).length === 0) {
1898+
if (
1899+
filterStackTrace(request, parseStackTrace(node.stack, 1)).length ===
1900+
0
1901+
) {
18811902
// Typically we assume that the outer most Promise that was awaited in user space has the
18821903
// most actionable stack trace for the start of the operation. However, if this Promise
18831904
// was created inside only third party code, then try to use the inner node instead.
@@ -1898,7 +1919,10 @@ function visitAsyncNode(
18981919
if (awaited !== null) {
18991920
const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited);
19001921
if (ioNode !== null) {
1901-
const stack = filterStackTrace(request, node.stack, 1);
1922+
const stack = filterStackTrace(
1923+
request,
1924+
parseStackTrace(node.stack, 1),
1925+
);
19021926
if (stack.length === 0) {
19031927
// If this await was fully filtered out, then it was inside third party code
19041928
// such as in an external library. We return the I/O node and try another await.
@@ -3272,7 +3296,7 @@ function emitPostponeChunk(
32723296
try {
32733297
// eslint-disable-next-line react-internal/safe-string-coercion
32743298
reason = String(postponeInstance.message);
3275-
stack = filterStackTrace(request, postponeInstance, 0);
3299+
stack = filterStackTrace(request, parseStackTrace(postponeInstance, 0));
32763300
} catch (x) {
32773301
stack = [];
32783302
}
@@ -3295,7 +3319,7 @@ function serializeErrorValue(request: Request, error: Error): string {
32953319
name = error.name;
32963320
// eslint-disable-next-line react-internal/safe-string-coercion
32973321
message = String(error.message);
3298-
stack = filterStackTrace(request, error, 0);
3322+
stack = filterStackTrace(request, parseStackTrace(error, 0));
32993323
const errorEnv = (error: any).environmentName;
33003324
if (typeof errorEnv === 'string') {
33013325
// This probably came from another FlightClient as a pass through.
@@ -3334,7 +3358,7 @@ function emitErrorChunk(
33343358
name = error.name;
33353359
// eslint-disable-next-line react-internal/safe-string-coercion
33363360
message = String(error.message);
3337-
stack = filterStackTrace(request, error, 0);
3361+
stack = filterStackTrace(request, parseStackTrace(error, 0));
33383362
const errorEnv = (error: any).environmentName;
33393363
if (typeof errorEnv === 'string') {
33403364
// This probably came from another FlightClient as a pass through.
@@ -3496,6 +3520,7 @@ function outlineComponentInfo(
34963520
function emitIOInfoChunk(
34973521
request: Request,
34983522
id: number,
3523+
name: string,
34993524
start: number,
35003525
end: number,
35013526
stack: ?ReactStackTrace,
@@ -3532,6 +3557,7 @@ function emitIOInfoChunk(
35323557
const relativeStartTimestamp = start - request.timeOrigin;
35333558
const relativeEndTimestamp = end - request.timeOrigin;
35343559
const debugIOInfo: Omit<ReactIOInfo, 'debugTask' | 'debugStack'> = {
3560+
name: name,
35353561
start: relativeStartTimestamp,
35363562
end: relativeEndTimestamp,
35373563
stack: stack,
@@ -3551,7 +3577,14 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
35513577
// We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
35523578
request.pendingChunks++;
35533579
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+
);
35553588
request.writtenObjects.set(ioInfo, serializeByValueID(id));
35563589
}
35573590

@@ -3566,12 +3599,23 @@ function serializeIONode(
35663599
}
35673600

35683601
let stack = null;
3602+
let name = '';
35693603
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+
}
35713614
}
3615+
35723616
request.pendingChunks++;
35733617
const id = request.nextChunkId++;
3574-
emitIOInfoChunk(request, id, ioNode.start, ioNode.end, stack);
3618+
emitIOInfoChunk(request, id, name, ioNode.start, ioNode.end, stack);
35753619
const ref = serializeByValueID(id);
35763620
request.writtenObjects.set(ioNode, ref);
35773621
return ref;
@@ -3712,7 +3756,10 @@ function renderConsoleValue(
37123756
let debugStack: null | ReactStackTrace = null;
37133757
if (element._debugStack != null) {
37143758
// 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+
);
37163763
doNotLimit.add(debugStack);
37173764
for (let i = 0; i < debugStack.length; i++) {
37183765
doNotLimit.add(debugStack[i]);

packages/react-server/src/ReactFlightStackConfigV8.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,22 @@ function collectStackTrace(
126126
const frameRegExp =
127127
/^ {3} at (?:(.+) \((?:(.+):(\d+):(\d+)|\<anonymous\>)\)|(?:async )?(.+):(\d+):(\d+)|\<anonymous\>)$/;
128128

129+
// DEV-only cache of parsed and filtered stack frames.
130+
const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
131+
? new WeakMap()
132+
: (null: any);
133+
129134
export function parseStackTrace(
130135
error: Error,
131136
skipFrames: number,
132137
): 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+
}
133145
// We override Error.prepareStackTrace with our own version that collects
134146
// the structured data. We need more information than the raw stack gives us
135147
// and we need to ensure that we don't get the source mapped version.
@@ -148,6 +160,7 @@ export function parseStackTrace(
148160
if (collectedStackTrace !== null) {
149161
const result = collectedStackTrace;
150162
collectedStackTrace = null;
163+
stackTraceCache.set(error, result);
151164
return result;
152165
}
153166

@@ -191,5 +204,6 @@ export function parseStackTrace(
191204
const col = +(parsed[4] || parsed[7]);
192205
parsedFrames.push([name, filename, line, col, 0, 0]);
193206
}
207+
stackTraceCache.set(error, parsedFrames);
194208
return parsedFrames;
195209
}

packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe('ReactFlightAsyncDebugInfo', () => {
170170
{
171171
"awaited": {
172172
"end": 0,
173+
"name": "delay",
173174
"stack": [
174175
[
175176
"delay",
@@ -220,6 +221,7 @@ describe('ReactFlightAsyncDebugInfo', () => {
220221
{
221222
"awaited": {
222223
"end": 0,
224+
"name": "delay",
223225
"stack": [
224226
[
225227
"delay",
@@ -321,23 +323,24 @@ describe('ReactFlightAsyncDebugInfo', () => {
321323
[
322324
"Object.<anonymous>",
323325
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
324-
291,
326+
293,
325327
109,
326-
278,
328+
280,
327329
67,
328330
],
329331
],
330332
},
331333
{
332334
"awaited": {
333335
"end": 0,
336+
"name": "setTimeout",
334337
"stack": [
335338
[
336339
"Component",
337340
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
338-
281,
341+
283,
339342
7,
340-
279,
343+
281,
341344
5,
342345
],
343346
],

packages/shared/ReactTypes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;
231231

232232
// The point where the Async Info started which might not be the same place it was awaited.
233233
export type ReactIOInfo = {
234+
+name: string, // the name of the async function being called (e.g. "fetch")
234235
+start: number, // the start time
235236
+end: number, // the end time (this might be different from the time the await was unblocked)
236237
+stack?: null | ReactStackTrace,

0 commit comments

Comments
 (0)