Detect what calls your Node.js code
Adrian
Posted on December 11, 2023
While most of the time it's actively discouraged to detect and react to how your code is called, there are times when you would really want to know how the execution came around calling it.
Examples when this would be very useful are:
- Logging libraries that autodetect and describe the caller
- Invisible caching for example if you were to reimplement useEffect on the backend
I'm sure you will find more creative ways to utilize this madness. Let's jump in and see how to do it.
How is this possible
The trick is to utilize V8 stack capturing functionality. No errors are thrown here, just a simple V8 function is being called to get the stack, and then some parsing happens to make the result useful.
Prepare
Let's first set up a function with following signature:
export function getExecutionPath(
filterDir = process.cwd(),
limit = 10
)
-
filterDir
- will be used to filter out calls from outside your code, for example, from node_modules or linked libraries -
limit
- how far into the execution history we will reach
Capture the stack
Now get the most important part, a fake Error stack trace:
const inobj = { stack: "" } satisfies { stack: string };
const oldLimit = Error.stackTraceLimit;
Error.stackTraceLimit = limit;
Error.captureStackTrace(inobj);
Error.stackTraceLimit = oldLimit;
This code sets the stack limit from the function parameter, captures the stack trace at this very point and restores the original limit.
The result is in pretty rough format:
Error
at getExecutionPath (C:\Code\server-side-state\src\state\getExecutionPath.ts:23:9)
at new SynchronizedStateful (C:\Code\server-side-state\src\state\state.ts:27:33)
at state (C:\Code\server-side-state\src\state\state.ts:90:24)
at Index (C:\Code\server-side-state\src\test\Index.tsx:7:20)
at C:\Code\server-side-state\src\server.ts:18:25
at Layer.handle [as handle_request] (C:\Code\server-side-state\node_modules\express\lib\router\layer.js:95:5)
at next (C:\Code\server-side-state\node_modules\express\lib\router\route.js:144:13)
at Route.dispatch (C:\Code\server-side-state\node_modules\express\lib\router\route.js:114:3)
at Layer.handle [as handle_request] (C:\Code\server-side-state\node_modules\express\lib\router\layer.js:95:5)
at C:\Code\server-side-state\node_modules\express\lib\router\index.js:284:15
We need to process that to make it useful.
Parse the stack
First run some regex on the lines to get only lines starting with at
and extract the identifier and its location. At the end remove the call of our detection method from the results and reverse the order.
const detectorRegex = /at (.+?) \((.+?)\)/;
const stack = inobj.stack
.split("\n")
.map((x) => x.trim())
.filter((x) => x.startsWith("at"))
.map((x) => x.match(detectorRegex))
.filter((x) => !!x) as RegExpMatchArray[];
stack.shift();
stack.reverse();
Then, transform the array of matches by parsing those strings inside. Get the identifier, and parse the second parameter to get the path, line and column in mre useful form.
const detections: StackEntry[] = stack.map((x) => {
const identifier = x[1];
const pathlinecol = x[2].split(":");
const column =
pathlinecol.length > 2 ? parseInt(pathlinecol.pop() ?? "0") : 0;
const line =
pathlinecol.length > 1 ? parseInt(pathlinecol.pop() ?? "0") : 0;
const p = pathlinecol.join(":");
return { identifier, path: p, line, column };
});
After those steps, the detections
array looks like this:
[
{
identifier: 'Layer.handle [as handle_request]',
path: 'C:\\Code\\server-side-state\\node_modules\\express\\lib\\router\\layer.js',
line: 95,
column: 5
},
{
identifier: 'Route.dispatch',
path: 'C:\\Code\\server-side-state\\node_modules\\express\\lib\\router\\route.js',
line: 114,
column: 3
},
{
identifier: 'next',
path: 'C:\\Code\\server-side-state\\node_modules\\express\\lib\\router\\route.js',
line: 144,
column: 13
},
{
identifier: 'Layer.handle [as handle_request]',
path: 'C:\\Code\\server-side-state\\node_modules\\express\\lib\\router\\layer.js',
line: 95,
column: 5
},
{
identifier: 'Index',
path: 'C:\\Code\\server-side-state\\src\\test\\Index.tsx',
line: 9,
column: 7
},
{
identifier: 'once',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 40,
column: 26
},
{
identifier: 'OnceRunner.produce',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 23,
column: 21
}
]
Filter the stack
We see we got some calls that are outside our code, let's filter them out and make the paths relative to our base directory:
const local = detections.filter(
(x) =>
x.path.startsWith(path.resolve(filterDir)) &&
!x.path.includes("node_modules")
);
For breadcrumbs, we still need to filter out anonymous functions and some other stuff, but better to put it in a separate variable named
, because the things inside local
can be very useful too.
const named = local
.filter((x) => !x.identifier.includes("<anonymous>"))
.filter((x) => !x.identifier.startsWith("Object."))
.filter((x) => !x.identifier.startsWith("Module."));
Final results
And now finally make the breadcrumbs string:
const breadcrumbs = named.map((x) => x.identifier).join("/");
And that's it! In this particular example I used, the breadcrumbs will look like this:
Index/once/OnceRunner.produce
and the local
and named
:
local: [
{
identifier: 'Index',
path: 'C:\\Code\\server-side-state\\src\\test\\Index.tsx',
line: 9,
column: 7
},
{
identifier: 'once',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 40,
column: 26
},
{
identifier: 'OnceRunner.produce',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 23,
column: 33
}
],
named: [
{
identifier: 'Index',
path: 'C:\\Code\\server-side-state\\src\\test\\Index.tsx',
line: 9,
column: 7
},
{
identifier: 'once',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 40,
column: 26
},
{
identifier: 'OnceRunner.produce',
path: 'C:\\Code\\server-side-state\\src\\state\\once.ts',
line: 23,
column: 33
}
]
Cheers and happy coding as always!
Posted on December 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.