Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: add util.getCallSite() API #54380

Merged
merged 9 commits into from
Sep 4, 2024
65 changes: 65 additions & 0 deletions benchmark/util/get-callsite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict';

const common = require('../common');
const { getCallSite } = require('node:util');
const assert = require('node:assert');

const bench = common.createBenchmark(main, {
n: [1e6],
method: ['ErrorCallSite', 'ErrorCallSiteSerialized', 'CPP'],
});

function ErrorGetCallSite() {
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
const originalStackFormatter = Error.prepareStackTrace;
Error.prepareStackTrace = (_err, stack) => {
if (stack && stack.length > 1) {
// Remove node:util
return stack.slice(1);
}
return stack;
};
const err = new Error();
// With the V8 Error API, the stack is not formatted until it is accessed
err.stack; // eslint-disable-line no-unused-expressions
Error.prepareStackTrace = originalStackFormatter;
return err.stack;
}

function ErrorCallSiteSerialized() {
const callsite = ErrorGetCallSite();
const serialized = [];
for (let i = 0; i < callsite.length; ++i) {
serialized.push({
functionName: callsite[i].getFunctionName(),
scriptName: callsite[i].getFileName(),
lineNumber: callsite[i].getLineNumber(),
column: callsite[i].getColumnNumber(),
});
}
return serialized;
}

function main({ n, method }) {
let fn;
switch (method) {
case 'ErrorCallSite':
fn = ErrorGetCallSite;
break;
case 'ErrorCallSiteSerialized':
fn = ErrorCallSiteSerialized;
break;
case 'CPP':
fn = getCallSite;
break;
}
let lastStack = {};

bench.start();
for (let i = 0; i < n; i++) {
const stack = fn();
lastStack = stack;
}
bench.end(n);
// Attempt to avoid dead-code elimination
assert.ok(lastStack);
}
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
57 changes: 57 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,63 @@ util.formatWithOptions({ colors: true }, 'See object %O', { foo: 42 });
// when printed to a terminal.
```

## `util.getCallSite(frames)`

> Stability: 1.1 - Active development

<!-- YAML
added: REPLACEME
-->

* `frames` {number} Number of frames returned in the stacktrace.
jasnell marked this conversation as resolved.
Show resolved Hide resolved
**Default:** `10`. Allowable range is between 1 and 200.
* Returns: {Object\[]} An array of stacktrace objects
* `functionName` {string} Returns the name of the function associated with this stack frame.
* `scriptName` {string} Returns the name of the resource that contains the script for the
function for this StackFrame.
* `lineNumber` {number} Returns the number, 1-based, of the line for the associate function call.
* `column` {number} Returns the 1-based column offset on the line for the associated function call.

Returns an array of stacktrace objects containing the stack of
the caller function.

```js
const util = require('node:util');

function exampleFunction() {
const callSites = util.getCallSite();

console.log('Call Sites:');
callSites.forEach((callSite, index) => {
console.log(`CallSite ${index + 1}:`);
console.log(`Function Name: ${callSite.functionName}`);
console.log(`Script Name: ${callSite.scriptName}`);
console.log(`Line Number: ${callSite.lineNumer}`);
console.log(`Column Number: ${callSite.column}`);
});
// CallSite 1:
// Function Name: exampleFunction
// Script Name: /home/example.js
// Line Number: 5
// Column Number: 26

// CallSite 2:
// Function Name: anotherFunction
// Script Name: /home/example.js
// Line Number: 22
// Column Number: 3

// ...
}

// A function to simulate another stack layer
function anotherFunction() {
exampleFunction();
}

anotherFunction();
```

## `util.getSystemErrorName(err)`

<!-- YAML
Expand Down
12 changes: 12 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,17 @@ function parseEnv(content) {
return binding.parseEnv(content);
}

/**
* Returns the callSite
* @param {number} frames
* @returns {object}
*/
function getCallSite(frames = 10) {
// Using kDefaultMaxCallStackSizeToCapture as reference
validateNumber(frames, 'frames', 1, 200);
return binding.getCallSite(frames);
};

// Keep the `exports =` so that various functions can still be monkeypatched
module.exports = {
_errnoException,
Expand All @@ -329,6 +340,7 @@ module.exports = {
format,
styleText,
formatWithOptions,
getCallSite,
getSystemErrorMap,
getSystemErrorName,
inherits,
Expand Down
4 changes: 4 additions & 0 deletions src/env_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"transferList") \
V(clone_untransferable_str, "Found invalid value in transferList.") \
V(code_string, "code") \
V(column_string, "column") \
V(commonjs_string, "commonjs") \
V(config_string, "config") \
V(constants_string, "constants") \
Expand Down Expand Up @@ -166,6 +167,7 @@
V(fragment_string, "fragment") \
V(frames_received_string, "framesReceived") \
V(frames_sent_string, "framesSent") \
V(function_name_string, "functionName") \
V(function_string, "function") \
V(get_string, "get") \
V(get_data_clone_error_string, "_getDataCloneError") \
Expand Down Expand Up @@ -215,6 +217,7 @@
V(kind_string, "kind") \
V(length_string, "length") \
V(library_string, "library") \
V(line_number_string, "lineNumber") \
V(loop_count, "loopCount") \
V(mac_string, "mac") \
V(max_buffer_string, "maxBuffer") \
Expand Down Expand Up @@ -305,6 +308,7 @@
V(salt_length_string, "saltLength") \
V(scheme_string, "scheme") \
V(scopeid_string, "scopeid") \
V(script_name_string, "scriptName") \
V(serial_number_string, "serialNumber") \
V(serial_string, "serial") \
V(servername_string, "servername") \
Expand Down
50 changes: 50 additions & 0 deletions src/node_util.cc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ using v8::Integer;
using v8::Isolate;
using v8::KeyCollectionMode;
using v8::Local;
using v8::LocalVector;
using v8::Object;
using v8::ObjectTemplate;
using v8::ONLY_CONFIGURABLE;
Expand Down Expand Up @@ -254,12 +255,60 @@ static void ParseEnv(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(dotenv.ToObject(env));
}

static void GetCallSite(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();

CHECK_EQ(args.Length(), 1);
CHECK(args[0]->IsNumber());
const uint32_t frames = args[0].As<Uint32>()->Value();
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
DCHECK(frames >= 1 && frames <= 200);

// +1 for disregarding node:util
Local<StackTrace> stack = StackTrace::CurrentStackTrace(isolate, frames + 1);
const int frame_count = stack->GetFrameCount();
LocalVector<Value> callsite_objects(isolate);

// Frame 0 is node:util. It should be skipped.
for (int i = 1; i < frame_count; ++i) {
Local<Object> obj = Object::New(isolate);
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
Local<StackFrame> stack_frame = stack->GetFrame(isolate, i);

Utf8Value function_name(isolate, stack_frame->GetFunctionName());
Utf8Value script_name(isolate, stack_frame->GetScriptName());

obj->Set(env->context(),
env->function_name_string(),
String::NewFromUtf8(isolate, *function_name).ToLocalChecked())
.Check();
obj->Set(env->context(),
env->script_name_string(),
String::NewFromUtf8(isolate, *script_name).ToLocalChecked())
.Check();
obj->Set(env->context(),
env->line_number_string(),
Integer::NewFromUnsigned(isolate, stack_frame->GetLineNumber()))
.Check();
obj->Set(env->context(),
env->column_string(),
Integer::NewFromUnsigned(isolate, stack_frame->GetColumn()))
.Check();

callsite_objects.push_back(obj);
}

Local<Array> callsites =
Array::New(isolate, callsite_objects.data(), callsite_objects.size());
args.GetReturnValue().Set(callsites);
}

void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(GetPromiseDetails);
registry->Register(GetProxyDetails);
registry->Register(GetCallerLocation);
registry->Register(IsArrayBufferDetached);
registry->Register(PreviewEntries);
registry->Register(GetCallSite);
registry->Register(GetOwnNonIndexProperties);
registry->Register(GetConstructorName);
registry->Register(GetExternalValue);
Expand Down Expand Up @@ -365,6 +414,7 @@ void Initialize(Local<Object> target,
SetMethodNoSideEffect(
context, target, "getConstructorName", GetConstructorName);
SetMethodNoSideEffect(context, target, "getExternalValue", GetExternalValue);
SetMethodNoSideEffect(context, target, "getCallSite", GetCallSite);
SetMethod(context, target, "sleep", Sleep);
SetMethod(context, target, "parseEnv", ParseEnv);

Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/get-call-site.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const util = require('node:util');
const assert = require('node:assert');
assert.ok(util.getCallSite().length > 1);
process.stdout.write(util.getCallSite()[0].scriptName);
106 changes: 106 additions & 0 deletions test/parallel/test-util-getCallSite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use strict';

const common = require('../common');

const fixtures = require('../common/fixtures');
const file = fixtures.path('get-call-site.js');

const { getCallSite } = require('node:util');
const { spawnSync } = require('node:child_process');
const assert = require('node:assert');

{
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
const callsite = getCallSite();
assert.ok(callsite.length > 1);
assert.match(
callsite[0].scriptName,
/test-util-getCallSite/,
'node:util should be ignored',
);
}

{
const callsite = getCallSite(3);
assert.strictEqual(callsite.length, 3);
assert.match(
callsite[0].scriptName,
/test-util-getCallSite/,
'node:util should be ignored',
);
}

// Guarantee dot-left numbers are ignored
{
const callsite = getCallSite(3.6);
assert.strictEqual(callsite.length, 3);
}

{
const callsite = getCallSite(3.4);
assert.strictEqual(callsite.length, 3);
}

{
assert.throws(() => {
// Max than kDefaultMaxCallStackSizeToCapture
getCallSite(201);
}, common.expectsError({
code: 'ERR_OUT_OF_RANGE'
}));
assert.throws(() => {
getCallSite(-1);
}, common.expectsError({
code: 'ERR_OUT_OF_RANGE'
}));
assert.throws(() => {
getCallSite({});
}, common.expectsError({
code: 'ERR_INVALID_ARG_TYPE'
}));
}

{
const callsite = getCallSite(1);
assert.strictEqual(callsite.length, 1);
assert.match(
callsite[0].scriptName,
/test-util-getCallSite/,
'node:util should be ignored',
);
}

// Guarantee [eval] will appear on stacktraces when using -e
{
const { status, stderr, stdout } = spawnSync(
process.execPath,
[
'-e',
`const util = require('util');
const assert = require('assert');
assert.ok(util.getCallSite().length > 1);
process.stdout.write(util.getCallSite()[0].scriptName);
`,
],
);
assert.strictEqual(status, 0, stderr.toString());
assert.strictEqual(stdout.toString(), '[eval]');
}

// Guarantee the stacktrace[0] is the filename
{
const { status, stderr, stdout } = spawnSync(
process.execPath,
[file],
);
assert.strictEqual(status, 0, stderr.toString());
assert.strictEqual(stdout.toString(), file);
}

// Error.stackTraceLimit should not influence callsite size
{
const originalStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
const callsite = getCallSite();
assert.notStrictEqual(callsite.length, 0);
Error.stackTraceLimit = originalStackTraceLimit;
}
Loading