Skip to content

Commit b642be1

Browse files
committed
feat: Capture thread state by calling global function
1 parent d881dbd commit b642be1

File tree

11 files changed

+178
-73
lines changed

11 files changed

+178
-73
lines changed

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module.exports = {
22
extends: ['@sentry-internal/sdk'],
33
env: {
44
node: true,
5-
es6: true,
5+
es2020: true
66
},
77
parserOptions: {
88
sourceType: 'module',

README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,23 +107,27 @@ Set up automatic detection of blocked event loops:
107107

108108
### 1. Set up thread heartbeats
109109

110-
Send regular heartbeats with optional state information:
110+
Send regular heartbeats:
111111

112112
```ts
113113
import {
114114
registerThread,
115+
registerThreadStateCallback,
115116
threadPoll,
116117
} from "@sentry-internal/node-native-stacktrace";
117118

119+
// Register a function that is called to get the thread state whenever stack traces are captured.
120+
// This must return a serializable object.
121+
registerThreadStateCallback(() => {
122+
return state.snapshot();
123+
});
124+
118125
// Register this thread
119126
registerThread();
120127

121-
// Send heartbeats every 200ms with optional state
128+
// Send heartbeats every 200ms
122129
setInterval(() => {
123-
threadPoll({
124-
endpoint: "/api/current-request",
125-
userId: getCurrentUserId(),
126-
});
130+
threadPoll();
127131
}, 200);
128132
```
129133

@@ -162,8 +166,8 @@ setInterval(() => {
162166

163167
#### `registerThread(threadName?: string): void`
164168

165-
Registers the current thread for monitoring. Must be called from each thread you
166-
want to capture stack traces from.
169+
Registers the current thread for stack trace capture. Must be called from each
170+
thread you want to capture stack traces from.
167171

168172
- `threadName` (optional): Name for the thread. Defaults to the current thread
169173
ID.
@@ -187,18 +191,21 @@ type StackFrame = {
187191
};
188192
```
189193

190-
#### `threadPoll<State>(state?: State, disableLastSeen?: boolean): void`
194+
#### `threadPoll<State>(disableLastSeen?: boolean): void`
191195

192-
Sends a heartbeat from the current thread with optional state information. The
193-
state object will be serialized and included as a JavaScript object with the
194-
corresponding stack trace.
196+
Sends a heartbeat from the current thread.
195197

196-
- `state` (optional): An object containing state information to include with the
197-
stack trace.
198198
- `disableLastSeen` (optional): If `true`, disables the tracking of the last
199199
seen time for this thread.
200200

201201
#### `getThreadsLastSeen(): Record<string, number>`
202202

203203
Returns the time in milliseconds since each registered thread called
204204
`threadPoll()`.
205+
206+
#### `registerThreadStateCallback<State>(callback: () => object | undefined): void`
207+
208+
Registers a callback function that is called whenever stack traces are captured
209+
from this thread. The return value of the callback is included in the `state`
210+
property of the corresponding `Thread` object returned by `captureStackTrace()`.
211+
The callback must return a serializable object.

module.cc

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,14 @@ using namespace v8;
1616
using namespace node;
1717
using namespace std::chrono;
1818

19-
static const int kMaxStackFrames = 255;
19+
static const int kMaxStackFrames = 50;
2020

2121
// Structure to hold information for each thread/isolate
2222
struct ThreadInfo {
2323
// Thread name
2424
std::string thread_name;
2525
// Last time this thread was seen in milliseconds since epoch
2626
milliseconds last_seen;
27-
// Some JSON serialized state for the thread
28-
std::string state;
2927
};
3028

3129
static std::mutex threads_mutex;
@@ -41,21 +39,26 @@ struct JsStackFrame {
4139
};
4240

4341
// Type alias for a vector of JsStackFrame
44-
using JsStackTrace = std::vector<JsStackFrame>;
42+
using JsStackFrames = std::vector<JsStackFrame>;
43+
44+
struct JsStackTrace {
45+
// The frames in the stack trace
46+
std::vector<JsStackFrame> frames;
47+
// JSON serialized string of the state
48+
std::string state;
49+
};
4550

4651
struct ThreadResult {
4752
std::string thread_name;
48-
std::string state;
49-
JsStackTrace stack_frames;
53+
JsStackTrace stack_trace;
5054
};
5155

52-
// Function to be called when an isolate's execution is interrupted
53-
static void ExecutionInterrupted(Isolate *isolate, void *data) {
54-
auto promise = static_cast<std::promise<JsStackTrace> *>(data);
56+
// Function to get stack frames from a V8 stack trace
57+
JsStackFrames GetStackFrames(Isolate *isolate) {
5558
auto stack = StackTrace::CurrentStackTrace(isolate, kMaxStackFrames,
5659
StackTrace::kDetailed);
5760

58-
JsStackTrace frames;
61+
JsStackFrames frames;
5962
if (!stack.IsEmpty()) {
6063
for (int i = 0; i < stack->GetFrameCount(); i++) {
6164
auto frame = stack->GetFrame(isolate, i);
@@ -89,7 +92,45 @@ static void ExecutionInterrupted(Isolate *isolate, void *data) {
8992
}
9093
}
9194

92-
promise->set_value(frames);
95+
return frames;
96+
}
97+
98+
// Function to fetch the thread state from the isolate by calling a global
99+
// function called __get_thread_state_callback__
100+
std::string GetThreadState(Isolate *isolate) {
101+
auto callback_name =
102+
v8::String::NewFromUtf8(isolate, "__get_thread_state_callback__",
103+
v8::NewStringType::kNormal)
104+
.ToLocalChecked();
105+
auto context = isolate->GetCurrentContext();
106+
auto callback =
107+
context->Global()->Get(context, callback_name).ToLocalChecked();
108+
109+
if (callback->IsFunction()) {
110+
v8::TryCatch try_catch(isolate);
111+
112+
auto result = Local<Function>::Cast(callback)->Call(
113+
context, Undefined(isolate), 0, {});
114+
115+
MaybeLocal<String> maybe_json =
116+
v8::JSON::Stringify(context, result.ToLocalChecked());
117+
118+
if (!maybe_json.IsEmpty()) {
119+
v8::String::Utf8Value utf8_state(isolate, maybe_json.ToLocalChecked());
120+
if (*utf8_state) {
121+
return *utf8_state;
122+
}
123+
}
124+
}
125+
126+
return "";
127+
}
128+
129+
// Function to be called when an isolate's execution is interrupted
130+
static void ExecutionInterrupted(Isolate *isolate, void *data) {
131+
auto promise = static_cast<std::promise<JsStackTrace> *>(data);
132+
133+
promise->set_value({GetStackFrames(isolate), GetThreadState(isolate)});
93134
}
94135

95136
// Function to capture the stack trace of a single isolate
@@ -116,12 +157,11 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
116157
if (thread_isolate == capture_from_isolate)
117158
continue;
118159
auto thread_name = thread_info.thread_name;
119-
auto state = thread_info.state;
120160

121161
futures.emplace_back(std::async(
122162
std::launch::async,
123-
[thread_name, state](Isolate *isolate) -> ThreadResult {
124-
return ThreadResult{thread_name, state, CaptureStackTrace(isolate)};
163+
[thread_name](Isolate *isolate) -> ThreadResult {
164+
return ThreadResult{thread_name, CaptureStackTrace(isolate)};
125165
},
126166
thread_isolate));
127167
}
@@ -137,9 +177,9 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
137177
.ToLocalChecked();
138178

139179
Local<Array> jsFrames =
140-
Array::New(capture_from_isolate, result.stack_frames.size());
141-
for (size_t i = 0; i < result.stack_frames.size(); ++i) {
142-
const auto &frame = result.stack_frames[i];
180+
Array::New(capture_from_isolate, result.stack_trace.frames.size());
181+
for (size_t i = 0; i < result.stack_trace.frames.size(); ++i) {
182+
const auto &frame = result.stack_trace.frames[i];
143183
Local<Object> frameObj = Object::New(capture_from_isolate);
144184
frameObj
145185
->Set(current_context,
@@ -189,9 +229,10 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
189229
jsFrames)
190230
.Check();
191231

192-
if (!result.state.empty()) {
232+
if (!result.stack_trace.state.empty()) {
193233
v8::MaybeLocal<v8::String> stateStr = v8::String::NewFromUtf8(
194-
capture_from_isolate, result.state.c_str(), NewStringType::kNormal);
234+
capture_from_isolate, result.stack_trace.state.c_str(),
235+
NewStringType::kNormal);
195236
if (!stateStr.IsEmpty()) {
196237
v8::MaybeLocal<v8::Value> maybeStateVal =
197238
v8::JSON::Parse(current_context, stateStr.ToLocalChecked());
@@ -243,8 +284,7 @@ void RegisterThread(const FunctionCallbackInfo<Value> &args) {
243284
std::lock_guard<std::mutex> lock(threads_mutex);
244285
auto found = threads.find(isolate);
245286
if (found == threads.end()) {
246-
threads.emplace(isolate,
247-
ThreadInfo{thread_name, milliseconds::zero(), ""});
287+
threads.emplace(isolate, ThreadInfo{thread_name, milliseconds::zero()});
248288
// Register a cleanup hook to remove this thread when the isolate is
249289
// destroyed
250290
node::AddEnvironmentCleanupHook(isolate, Cleanup, isolate);
@@ -280,32 +320,17 @@ steady_clock::time_point GetUnbiasedMonotonicTime() {
280320
// Function to track a thread and set its state
281321
void ThreadPoll(const FunctionCallbackInfo<Value> &args) {
282322
auto isolate = args.GetIsolate();
283-
auto context = isolate->GetCurrentContext();
284-
285-
std::string state_str;
286-
if (args.Length() > 0 && args[0]->IsValue()) {
287-
MaybeLocal<String> maybe_json = v8::JSON::Stringify(context, args[0]);
288-
if (!maybe_json.IsEmpty()) {
289-
v8::String::Utf8Value utf8_state(isolate, maybe_json.ToLocalChecked());
290-
state_str = *utf8_state ? *utf8_state : "";
291-
} else {
292-
state_str = "";
293-
}
294-
} else {
295-
state_str = "";
296-
}
297323

298324
bool disable_last_seen = false;
299-
if (args.Length() > 1 && args[1]->IsBoolean()) {
300-
disable_last_seen = args[1]->BooleanValue(isolate);
325+
if (args.Length() > 0 && args[0]->IsBoolean()) {
326+
disable_last_seen = args[0]->BooleanValue(isolate);
301327
}
302328

303329
{
304330
std::lock_guard<std::mutex> lock(threads_mutex);
305331
auto found = threads.find(isolate);
306332
if (found != threads.end()) {
307333
auto &thread_info = found->second;
308-
thread_info.state = state_str;
309334
if (disable_last_seen) {
310335
thread_info.last_seen = milliseconds::zero();
311336
} else {

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@
2323
"fix": "yarn fix:eslint && yarn fix:clang",
2424
"fix:eslint": "eslint . --format stylish --fix",
2525
"fix:clang": "node scripts/clang-format.mjs --fix",
26-
"build": "yarn build:lib && yarn build:bindings:configure && yarn build:bindings",
26+
"build": "yarn clean && yarn build:lib && yarn build:bindings:configure && yarn build:bindings",
2727
"build:lib": "tsc",
2828
"build:bindings:configure": "node-gyp configure",
2929
"build:bindings:configure:arm64": "node-gyp configure --arch=arm64 --target_arch=arm64",
3030
"build:bindings": "node-gyp build && node scripts/copy-target.mjs",
3131
"build:bindings:arm64": "node-gyp build --arch=arm64 && node scripts/copy-target.mjs",
3232
"build:dev": "yarn clean && yarn build:bindings:configure && yarn build",
3333
"build:tarball": "npm pack",
34-
"clean": "node-gyp clean && rm -rf lib && rm -rf build",
34+
"clean": "node-gyp clean && rm -rf lib && rm -rf build && rm -f *.tgz",
3535
"test": "yarn test:install && node ./test/prepare.mjs && vitest run --silent=false --disable-console-intercept",
3636
"test:install": "cross-env ALWAYS_THROW=true yarn install"
3737
},

src/index.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type StackFrame = {
2525

2626
interface Native {
2727
registerThread(threadName: string): void;
28-
threadPoll(state?: object, disableLastSeen?: boolean): void;
28+
threadPoll(disableLastSeen?: boolean): void;
2929
captureStackTrace<S = unknown>(): Record<string, Thread<S>>;
3030
getThreadsLastSeen(): Record<string, number>;
3131
}
@@ -186,15 +186,10 @@ export function registerThread(threadName: string = String(threadId)): void {
186186
/**
187187
* Tells the native module that the thread is still running and updates the state.
188188
*
189-
* @param state Optional state to pass to the native module.
190189
* @param disableLastSeen If true, disables the last seen tracking for this thread.
191190
*/
192-
export function threadPoll(state?: object, disableLastSeen?: boolean): void {
193-
if (typeof state === 'object' || disableLastSeen) {
194-
native.threadPoll(state, disableLastSeen);
195-
} else {
196-
native.threadPoll();
197-
}
191+
export function threadPoll(disableLastSeen?: boolean): void {
192+
native.threadPoll(!!disableLastSeen);
198193
}
199194

200195
/**
@@ -212,3 +207,16 @@ export function captureStackTrace<S = unknown>(): Record<string, Thread<S>> {
212207
export function getThreadsLastSeen(): Record<string, number> {
213208
return native.getThreadsLastSeen();
214209
}
210+
211+
type GlobalObjectWithCallback = {
212+
__get_thread_state_callback__?: () => object | undefined;
213+
};
214+
215+
/**
216+
* Registers a callback function on the global object that the native module can call to get the current thread state.
217+
*/
218+
export function registerThreadStateCallback(callback: () => object | undefined): void {
219+
(globalThis as GlobalObjectWithCallback).__get_thread_state_callback__ = callback;
220+
}
221+
222+

test/async-storage.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
2+
import { Worker } from 'node:worker_threads';
3+
import { registerThread, registerThreadStateCallback } from '@sentry-internal/node-native-stacktrace';
4+
import { longWork } from './long-work.js';
5+
6+
registerThread();
7+
8+
const asyncLocalStorage = new AsyncLocalStorage();
9+
10+
function withTraceId(traceId, fn) {
11+
return asyncLocalStorage.run(traceId, fn);
12+
}
13+
14+
registerThreadStateCallback(() => {
15+
const traceId = asyncLocalStorage.getStore();
16+
return { traceId };
17+
});
18+
19+
const watchdog = new Worker('./test/watchdog.js');
20+
21+
for (let i = 0; i < 10; i++) {
22+
withTraceId(`trace-${i}`, () => {
23+
if(i === 5) {
24+
longWork();
25+
}
26+
});
27+
}

test/e2e.test.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,38 @@ describe('e2e Tests', { timeout: 20000 }, () => {
9090
expect(stacks['2'].frames.length).toEqual(1);
9191
});
9292

93+
test('async storage state', { timeout: 20000 }, () => {
94+
const testFile = join(__dirname, 'async-storage.mjs');
95+
const result = spawnSync('node', [testFile]);
96+
97+
expect(result.status).toEqual(0);
98+
99+
const stacks = JSON.parse(result.stdout.toString());
100+
101+
expect(stacks['0'].frames).toEqual(expect.arrayContaining([
102+
{
103+
function: 'pbkdf2Sync',
104+
filename: expect.any(String),
105+
lineno: expect.any(Number),
106+
colno: expect.any(Number),
107+
},
108+
{
109+
function: 'longWork',
110+
filename: expect.stringMatching(/long-work.js$/),
111+
lineno: expect.any(Number),
112+
colno: expect.any(Number),
113+
},
114+
{
115+
function: '?',
116+
filename: expect.stringMatching(/async-storage.mjs$/),
117+
lineno: expect.any(Number),
118+
colno: expect.any(Number),
119+
},
120+
]));
121+
122+
expect(stacks['0'].state).toEqual({ traceId: 'trace-5' });
123+
});
124+
93125
test('can be disabled', { timeout: 20000 }, () => {
94126
const testFile = join(__dirname, 'stalled-disabled.js');
95127
const result = spawnSync('node', [testFile]);

0 commit comments

Comments
 (0)