Skip to content

Commit 1ee6ed1

Browse files
committed
feat: Capture thread state from AsyncLocalStorage store
1 parent d881dbd commit 1ee6ed1

File tree

11 files changed

+214
-94
lines changed

11 files changed

+214
-94
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: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Stack traces show where each thread is currently executing:
7070
}
7171
]
7272
},
73-
'2': { // Worker thread
73+
'2': { // Worker thread
7474
frames: [
7575
{
7676
function: 'from',
@@ -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,
115115
threadPoll,
116116
} from "@sentry-internal/node-native-stacktrace";
117+
import { AsyncLocalStorage } from "node:async_hooks";
117118

118-
// Register this thread
119-
registerThread();
119+
// Create async local storage for state tracking
120+
const asyncLocalStorage = new AsyncLocalStorage();
121+
122+
// Register this thread with async local storage
123+
registerThread(asyncLocalStorage);
124+
125+
// Set some state in the async local storage
126+
asyncLocalStorage.enterWith({ someState: "value" });
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

@@ -160,11 +164,13 @@ setInterval(() => {
160164

161165
### Functions
162166

163-
#### `registerThread(threadName?: string): void`
167+
#### `registerThread(asyncLocalStorage?: AsyncLocalStorage, 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

172+
- `asyncLocalStorage` (optional): AsyncLocalStorage instance for tracking thread
173+
state. The current value in the storage will be captured in stack traces.
168174
- `threadName` (optional): Name for the thread. Defaults to the current thread
169175
ID.
170176

@@ -187,14 +193,10 @@ type StackFrame = {
187193
};
188194
```
189195

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

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.
198+
Sends a heartbeat from the current thread.
195199

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

module.cc

Lines changed: 109 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ 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;
27+
// Async local storage associated with this thread
28+
v8::Global<v8::Value> async_local_storage;
2929
};
3030

3131
static std::mutex threads_mutex;
@@ -41,21 +41,26 @@ struct JsStackFrame {
4141
};
4242

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

4653
struct ThreadResult {
4754
std::string thread_name;
48-
std::string state;
49-
JsStackTrace stack_frames;
55+
JsStackTrace stack_trace;
5056
};
5157

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);
58+
// Function to get stack frames from a V8 stack trace
59+
JsStackFrames GetStackFrames(Isolate *isolate) {
5560
auto stack = StackTrace::CurrentStackTrace(isolate, kMaxStackFrames,
5661
StackTrace::kDetailed);
5762

58-
JsStackTrace frames;
63+
JsStackFrames frames;
5964
if (!stack.IsEmpty()) {
6065
for (int i = 0; i < stack->GetFrameCount(); i++) {
6166
auto frame = stack->GetFrame(isolate, i);
@@ -89,18 +94,76 @@ static void ExecutionInterrupted(Isolate *isolate, void *data) {
8994
}
9095
}
9196

92-
promise->set_value(frames);
97+
return frames;
98+
}
99+
100+
static std::string ToJSONOrEmpty(Isolate *isolate, v8::Local<v8::Value> value) {
101+
auto context = isolate->GetCurrentContext();
102+
MaybeLocal<String> maybe_json = v8::JSON::Stringify(context, value);
103+
104+
if (!maybe_json.IsEmpty()) {
105+
v8::String::Utf8Value utf8_state(isolate, maybe_json.ToLocalChecked());
106+
if (*utf8_state) {
107+
return *utf8_state;
108+
}
109+
}
110+
return "";
111+
}
112+
113+
// Function to fetch the thread state from the async context store
114+
std::string GetThreadState(Isolate *isolate,
115+
v8::Global<v8::Value> async_local_storage) {
116+
// Node.js stores the async local storage in the isolate's
117+
// "ContinuationPreservedEmbedderData" map, keyed by the
118+
// AsyncLocalStorage instance.
119+
// https://github.com/nodejs/node/blob/c6316f9db9869864cea84e5f07585fa08e3e06d2/src/async_context_frame.cc#L37
120+
auto data = isolate->GetContinuationPreservedEmbedderData();
121+
auto local_async_local_storage = async_local_storage.Get(isolate);
122+
123+
if (!data.IsEmpty() && data->IsMap()) {
124+
auto map = data.As<v8::Map>();
125+
auto context = isolate->GetCurrentContext();
126+
auto val = map->Get(context, local_async_local_storage);
127+
128+
if (!val.IsEmpty()) {
129+
return ToJSONOrEmpty(isolate, val.ToLocalChecked());
130+
}
131+
}
132+
133+
return "";
134+
}
135+
136+
struct InterruptArgs {
137+
std::promise<JsStackTrace> *promise;
138+
v8::Global<v8::Value> async_local_storage;
139+
};
140+
141+
// Function to be called when an isolate's execution is interrupted
142+
static void ExecutionInterrupted(Isolate *isolate, void *data) {
143+
auto args = static_cast<InterruptArgs *>(data);
144+
145+
v8::Locker locker(isolate);
146+
v8::HandleScope scope(isolate);
147+
148+
auto frames = GetStackFrames(isolate);
149+
auto state = GetThreadState(isolate, std::move(args->async_local_storage));
150+
151+
args->promise->set_value({frames, state});
93152
}
94153

95154
// Function to capture the stack trace of a single isolate
96-
JsStackTrace CaptureStackTrace(Isolate *isolate) {
155+
JsStackTrace CaptureStackTrace(Isolate *isolate,
156+
v8::Global<v8::Value> async_local_storage) {
97157
std::promise<JsStackTrace> promise;
98-
auto future = promise.get_future();
99158

100159
// The v8 isolate must be interrupted to capture the stack trace
101160
// Execution resumes automatically after ExecutionInterrupted returns
102-
isolate->RequestInterrupt(ExecutionInterrupted, &promise);
103-
return future.get();
161+
isolate->RequestInterrupt(
162+
ExecutionInterrupted,
163+
new InterruptArgs{&promise,
164+
v8::Global<v8::Value>(isolate, async_local_storage)});
165+
166+
return promise.get_future().get();
104167
}
105168

106169
// Function to capture stack traces from all registered threads
@@ -112,21 +175,27 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
112175

113176
{
114177
std::lock_guard<std::mutex> lock(threads_mutex);
115-
for (auto [thread_isolate, thread_info] : threads) {
178+
for (auto &[thread_isolate, thread_info] : threads) {
116179
if (thread_isolate == capture_from_isolate)
117180
continue;
181+
118182
auto thread_name = thread_info.thread_name;
119-
auto state = thread_info.state;
120183

121184
futures.emplace_back(std::async(
122185
std::launch::async,
123-
[thread_name, state](Isolate *isolate) -> ThreadResult {
124-
return ThreadResult{thread_name, state, CaptureStackTrace(isolate)};
186+
[thread_isolate, thread_name](
187+
v8::Global<v8::Value> async_local_storage) -> ThreadResult {
188+
return ThreadResult{
189+
thread_name, CaptureStackTrace(thread_isolate,
190+
std::move(async_local_storage))};
125191
},
126-
thread_isolate));
192+
std::move(thread_info.async_local_storage)));
127193
}
128194
}
129195

196+
v8::Locker locker(capture_from_isolate);
197+
v8::HandleScope scope(capture_from_isolate);
198+
130199
Local<Object> output = Object::New(capture_from_isolate);
131200

132201
for (auto &future : futures) {
@@ -137,9 +206,9 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
137206
.ToLocalChecked();
138207

139208
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];
209+
Array::New(capture_from_isolate, result.stack_trace.frames.size());
210+
for (size_t i = 0; i < result.stack_trace.frames.size(); ++i) {
211+
const auto &frame = result.stack_trace.frames[i];
143212
Local<Object> frameObj = Object::New(capture_from_isolate);
144213
frameObj
145214
->Set(current_context,
@@ -189,9 +258,10 @@ void CaptureStackTraces(const FunctionCallbackInfo<Value> &args) {
189258
jsFrames)
190259
.Check();
191260

192-
if (!result.state.empty()) {
261+
if (!result.stack_trace.state.empty()) {
193262
v8::MaybeLocal<v8::String> stateStr = v8::String::NewFromUtf8(
194-
capture_from_isolate, result.state.c_str(), NewStringType::kNormal);
263+
capture_from_isolate, result.stack_trace.state.c_str(),
264+
NewStringType::kNormal);
195265
if (!stateStr.IsEmpty()) {
196266
v8::MaybeLocal<v8::Value> maybeStateVal =
197267
v8::JSON::Parse(current_context, stateStr.ToLocalChecked());
@@ -226,7 +296,7 @@ void Cleanup(void *arg) {
226296
void RegisterThread(const FunctionCallbackInfo<Value> &args) {
227297
auto isolate = args.GetIsolate();
228298

229-
if (args.Length() != 1 || !args[0]->IsString()) {
299+
if (args.Length() < 1 || !args[0]->IsString()) {
230300
isolate->ThrowException(Exception::Error(
231301
String::NewFromUtf8(
232302
isolate, "registerThread(name) requires a single name argument",
@@ -236,15 +306,20 @@ void RegisterThread(const FunctionCallbackInfo<Value> &args) {
236306
return;
237307
}
238308

309+
v8::Global<v8::Value> async_local_storage;
310+
if (args.Length() > 1) {
311+
async_local_storage.Reset(isolate, args[1]);
312+
}
313+
239314
v8::String::Utf8Value utf8(isolate, args[0]);
240315
std::string thread_name(*utf8 ? *utf8 : "");
241316

242317
{
243318
std::lock_guard<std::mutex> lock(threads_mutex);
244319
auto found = threads.find(isolate);
245320
if (found == threads.end()) {
246-
threads.emplace(isolate,
247-
ThreadInfo{thread_name, milliseconds::zero(), ""});
321+
threads.emplace(isolate, ThreadInfo{thread_name, milliseconds::zero(),
322+
std::move(async_local_storage)});
248323
// Register a cleanup hook to remove this thread when the isolate is
249324
// destroyed
250325
node::AddEnvironmentCleanupHook(isolate, Cleanup, isolate);
@@ -280,37 +355,22 @@ steady_clock::time_point GetUnbiasedMonotonicTime() {
280355
// Function to track a thread and set its state
281356
void ThreadPoll(const FunctionCallbackInfo<Value> &args) {
282357
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-
}
297358

298-
bool disable_last_seen = false;
299-
if (args.Length() > 1 && args[1]->IsBoolean()) {
300-
disable_last_seen = args[1]->BooleanValue(isolate);
359+
bool enable_last_seen = true;
360+
if (args.Length() > 0 && args[0]->IsBoolean()) {
361+
enable_last_seen = args[0]->BooleanValue(isolate);
301362
}
302363

303364
{
304365
std::lock_guard<std::mutex> lock(threads_mutex);
305366
auto found = threads.find(isolate);
306367
if (found != threads.end()) {
307368
auto &thread_info = found->second;
308-
thread_info.state = state_str;
309-
if (disable_last_seen) {
310-
thread_info.last_seen = milliseconds::zero();
311-
} else {
369+
if (enable_last_seen) {
312370
thread_info.last_seen = duration_cast<milliseconds>(
313371
GetUnbiasedMonotonicTime().time_since_epoch());
372+
} else {
373+
thread_info.last_seen = milliseconds::zero();
314374
}
315375
}
316376
}

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,17 @@
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",
36+
"test:prepare": "node ./test/prepare.mjs",
3637
"test:install": "cross-env ALWAYS_THROW=true yarn install"
3738
},
3839
"engines": {
@@ -68,4 +69,4 @@
6869
"volta": {
6970
"node": "24.1.0"
7071
}
71-
}
72+
}

0 commit comments

Comments
 (0)