Detect what calls your Node.js code

afl_ext

Adrian

Posted on December 11, 2023

Detect what calls your Node.js code

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
)
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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
  }
]
Enter fullscreen mode Exit fullscreen mode

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")
);
Enter fullscreen mode Exit fullscreen mode

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."));
Enter fullscreen mode Exit fullscreen mode

Final results

And now finally make the breadcrumbs string:

const breadcrumbs = named.map((x) => x.identifier).join("/");
Enter fullscreen mode Exit fullscreen mode

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
  }
]
Enter fullscreen mode Exit fullscreen mode

Cheers and happy coding as always!

💖 💪 🙅 🚩
afl_ext
Adrian

Posted on December 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Detect what calls your Node.js code