Skip to content

Commit 15aa515

Browse files
committed
Add object traversal and pre-JSON sanitisation
1 parent fd3a2f3 commit 15aa515

File tree

4 files changed

+215
-36
lines changed

4 files changed

+215
-36
lines changed

README.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,22 +174,33 @@ thread you want to capture stack traces from.
174174

175175
- `threadName` (optional): Name for the thread. Defaults to the current thread
176176
ID.
177-
- `asyncStorage`: `AsyncStorageArgs` to fetch state from `AsyncLocalStorage` on
178-
stack trace capture.
177+
- `asyncStorage` (optional): `AsyncStorageArgs` to fetch state from
178+
`AsyncLocalStorage` on stack trace capture.
179179

180180
```ts
181181
type AsyncStorageArgs = {
182-
// AsyncLocalStorage instance to fetch state from
182+
/** AsyncLocalStorage instance to fetch state from */
183183
asyncLocalStorage: AsyncLocalStorage<unknown>;
184-
// Optional key to fetch specific property from the store object
185-
storageKey?: string | symbol;
184+
/**
185+
* Optional array of keys to pick a specific property from the store.
186+
* Key will be traversed in order through Objects/Maps to reach the desired property.
187+
*
188+
* This is useful if you want to capture Open Telemetry context values as state.
189+
*
190+
* To get this value:
191+
* context.getValue(MY_UNIQUE_SYMBOL_REF)
192+
*
193+
* You would set:
194+
* stateLookup: ['_currentContext', MY_UNIQUE_SYMBOL_REF]
195+
*/
196+
stateLookup?: Array<string | symbol>;
186197
};
187198
```
188199

189200
#### `captureStackTrace<State>(): Record<string, Thread<A, P>>`
190201

191202
Captures stack traces from all registered threads. Can be called from any thread
192-
but will not capture the stack trace of the calling thread itself.
203+
but will not capture a stack trace for the calling thread itself.
193204

194205
```ts
195206
type Thread<A = unknown, P = unknown> = {

module.cc

Lines changed: 181 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <node.h>
55
#include <node_version.h>
66
#include <optional>
7+
#include <vector>
78

89
// Platform-specific includes for time functions
910
#ifdef _WIN32
@@ -30,8 +31,9 @@ static const int kMaxStackFrames = 50;
3031
struct AsyncLocalStorageLookup {
3132
// Async local storage instance associated with this thread
3233
v8::Global<v8::Value> async_local_storage;
33-
// Optional key used to look up specific data in an async local storage object
34-
std::optional<v8::Global<v8::Value>> storage_key;
34+
// Optional ordered array of keys (string | symbol) to traverse nested
35+
// Map/Object structures to fetch the final state object
36+
std::optional<std::vector<v8::Global<v8::Value>>> storage_keys;
3537
};
3638

3739
// Structure to hold information for each thread/isolate
@@ -75,9 +77,126 @@ struct ThreadResult {
7577
std::string poll_state;
7678
};
7779

80+
// Recursively sanitize a value to be safely JSON-stringifiable by:
81+
// - Removing properties whose values are BigInt, Function, or Symbol
82+
// (dropped for objects, omitted from arrays)
83+
// - Breaking cycles by omitting repeated objects (undefined -> dropped/omitted)
84+
// - Preserving primitives and traversing arrays/objects
85+
static v8::Local<v8::Value>
86+
SanitizeForJSON(v8::Isolate *isolate, v8::Local<v8::Context> context,
87+
v8::Local<v8::Value> value,
88+
std::vector<v8::Local<v8::Object>> &ancestors) {
89+
// Fast-path for primitives that are always JSON-compatible
90+
if (value->IsNull() || value->IsBoolean() || value->IsNumber() ||
91+
value->IsString()) {
92+
return value;
93+
}
94+
95+
// Values that JSON.stringify cannot handle directly
96+
if (value->IsBigInt() || value->IsSymbol() || value->IsFunction() ||
97+
value->IsUndefined()) {
98+
// Returning undefined here lets callers decide to drop (object) or null
99+
// (array)
100+
return v8::Undefined(isolate);
101+
}
102+
103+
// Arrays
104+
if (value->IsArray()) {
105+
auto arr = value.As<v8::Array>();
106+
// Cycle detection
107+
auto arr_obj = value.As<v8::Object>();
108+
for (auto &a : ancestors) {
109+
if (a->StrictEquals(arr_obj)) {
110+
return v8::Undefined(isolate);
111+
}
112+
}
113+
114+
auto length = arr->Length();
115+
auto out = v8::Array::New(isolate, 0);
116+
ancestors.push_back(arr_obj);
117+
118+
uint32_t out_index = 0;
119+
for (uint32_t i = 0; i < length; ++i) {
120+
auto maybeEl = arr->Get(context, i);
121+
v8::Local<v8::Value> el =
122+
maybeEl.IsEmpty() ? v8::Undefined(isolate) : maybeEl.ToLocalChecked();
123+
124+
auto sanitized = SanitizeForJSON(isolate, context, el, ancestors);
125+
if (!sanitized->IsUndefined()) {
126+
out->Set(context, out_index++, sanitized)
127+
.Check(); // omit undefined entries entirely
128+
}
129+
}
130+
ancestors.pop_back();
131+
return out;
132+
}
133+
134+
// Objects (including Dates, RegExps, Maps as objects; we only traverse
135+
// enumerable own props)
136+
if (value->IsObject()) {
137+
auto obj = value.As<v8::Object>();
138+
// Cycle detection
139+
for (auto &a : ancestors) {
140+
if (a->StrictEquals(obj)) {
141+
return v8::Undefined(isolate);
142+
}
143+
}
144+
145+
ancestors.push_back(obj);
146+
147+
// Collect own enumerable property names (string-keyed)
148+
auto maybe_props = obj->GetPropertyNames(context);
149+
if (maybe_props.IsEmpty()) {
150+
ancestors.pop_back();
151+
return obj; // Nothing enumerable to sanitize
152+
}
153+
154+
auto props = maybe_props.ToLocalChecked();
155+
auto out = v8::Object::New(isolate);
156+
auto len = props->Length();
157+
for (uint32_t i = 0; i < len; ++i) {
158+
auto maybeKey = props->Get(context, i);
159+
if (maybeKey.IsEmpty())
160+
continue;
161+
162+
auto key = maybeKey.ToLocalChecked();
163+
if (!key->IsString()) {
164+
// Skip symbol and non-string keys to match JSON behavior
165+
continue;
166+
}
167+
168+
auto maybeVal = obj->Get(context, key);
169+
if (maybeVal.IsEmpty())
170+
continue;
171+
172+
auto val = maybeVal.ToLocalChecked();
173+
auto sanitized = SanitizeForJSON(isolate, context, val, ancestors);
174+
if (!sanitized->IsUndefined()) {
175+
out->Set(context, key, sanitized).Check();
176+
}
177+
// else: undefined -> drop property
178+
}
179+
180+
ancestors.pop_back();
181+
return out;
182+
}
183+
184+
// Fallback: return as-is (shouldn't hit here for other exotic types)
185+
return value;
186+
}
187+
78188
std::string JSONStringify(Isolate *isolate, Local<Value> value) {
79189
auto context = isolate->GetCurrentContext();
80-
auto maybe_json = v8::JSON::Stringify(context, value);
190+
191+
// Sanitize the value first to avoid JSON failures (e.g., BigInt, cycles)
192+
std::vector<v8::Local<v8::Object>> ancestors;
193+
auto sanitized = SanitizeForJSON(isolate, context, value, ancestors);
194+
if (sanitized->IsUndefined()) {
195+
// Nothing serializable
196+
return "";
197+
}
198+
199+
auto maybe_json = v8::JSON::Stringify(context, sanitized);
81200
if (maybe_json.IsEmpty()) {
82201
return "";
83202
}
@@ -131,10 +250,11 @@ JsStackFrames GetStackFrames(Isolate *isolate) {
131250
// Function to fetch the thread state from the async context store
132251
std::string GetThreadState(Isolate *isolate,
133252
const AsyncLocalStorageLookup &store) {
134-
// Node.js stores the async local storage in the isolate's
135-
// "ContinuationPreservedEmbedderData" map, keyed by the
136-
// AsyncLocalStorage instance.
137-
// https://github.com/nodejs/node/blob/c6316f9db9869864cea84e5f07585fa08e3e06d2/src/async_context_frame.cc#L37
253+
254+
// Node.js stores the async local storage in the isolate's
255+
// "ContinuationPreservedEmbedderData" map, keyed by the
256+
// AsyncLocalStorage instance.
257+
// https://github.com/nodejs/node/blob/c6316f9db9869864cea84e5f07585fa08e3e06d2/src/async_context_frame.cc#L37
138258
#if GET_CONTINUATION_PRESERVED_EMBEDDER_DATA_V2
139259
auto data = isolate->GetContinuationPreservedEmbedderDataV2().As<Value>();
140260
#else
@@ -156,18 +276,36 @@ std::string GetThreadState(Isolate *isolate,
156276

157277
auto root_store = maybe_root_store.ToLocalChecked();
158278

159-
if (store.storage_key.has_value() && root_store->IsObject()) {
160-
auto local_key = store.storage_key->Get(isolate);
279+
if (store.storage_keys.has_value()) {
280+
// Walk the keys to get the desired nested value
281+
const auto &keys = store.storage_keys.value();
282+
auto current = root_store;
283+
284+
for (auto &gkey : keys) {
285+
auto local_key = gkey.Get(isolate);
286+
if (!(local_key->IsString() || local_key->IsSymbol())) {
287+
continue;
288+
}
289+
290+
v8::MaybeLocal<v8::Value> maybeValue;
291+
if (current->IsMap()) {
292+
auto map_val = current.As<v8::Map>();
293+
maybeValue = map_val->Get(context, local_key);
294+
} else if (current->IsObject()) {
295+
auto obj_val = current.As<v8::Object>();
296+
maybeValue = obj_val->Get(context, local_key);
297+
} else {
298+
return "";
299+
}
161300

162-
if (local_key->IsString() || local_key->IsSymbol()) {
163-
auto root_obj = root_store.As<v8::Object>();
164-
auto maybeValue = root_obj->Get(context, local_key);
165301
if (maybeValue.IsEmpty()) {
166302
return "";
167303
}
168304

169-
root_store = maybeValue.ToLocalChecked();
305+
current = maybeValue.ToLocalChecked();
170306
}
307+
308+
root_store = current;
171309
}
172310

173311
return JSONStringify(isolate, root_store);
@@ -422,24 +560,45 @@ void RegisterThread(const FunctionCallbackInfo<Value> &args) {
422560
return;
423561
}
424562

425-
std::optional<v8::Global<v8::Value>> storage_key = std::nullopt;
563+
std::optional<std::vector<v8::Global<v8::Value>>> storage_keys =
564+
std::nullopt;
426565

427-
auto storage_key_val = obj->Get(
428-
isolate->GetCurrentContext(),
429-
String::NewFromUtf8(isolate, "storageKey", NewStringType::kInternalized)
430-
.ToLocalChecked());
566+
auto storage_key_val =
567+
obj->Get(isolate->GetCurrentContext(),
568+
String::NewFromUtf8(isolate, "stateLookup",
569+
NewStringType::kInternalized)
570+
.ToLocalChecked());
431571

432572
if (!storage_key_val.IsEmpty()) {
573+
433574
auto local_val = storage_key_val.ToLocalChecked();
434575
if (!local_val->IsUndefined() && !local_val->IsNull()) {
435-
storage_key = v8::Global<v8::Value>(isolate, local_val);
576+
if (local_val->IsArray()) {
577+
578+
auto arr = local_val.As<v8::Array>();
579+
std::vector<v8::Global<v8::Value>> keys_vec;
580+
uint32_t length = arr->Length();
581+
for (uint32_t i = 0; i < length; ++i) {
582+
auto maybeEl = arr->Get(isolate->GetCurrentContext(), i);
583+
if (maybeEl.IsEmpty())
584+
continue;
585+
auto el = maybeEl.ToLocalChecked();
586+
if (el->IsString() || el->IsSymbol()) {
587+
588+
keys_vec.emplace_back(isolate, el);
589+
}
590+
}
591+
if (!keys_vec.empty()) {
592+
storage_keys = std::move(keys_vec);
593+
}
594+
}
436595
}
437596
}
438597

439598
auto store = AsyncLocalStorageLookup{
440599
v8::Global<v8::Value>(isolate,
441600
async_local_storage_val.ToLocalChecked()),
442-
std::move(storage_key)};
601+
std::move(storage_keys)};
443602

444603
RegisterThreadInternal(isolate, thread_name, std::move(store));
445604
} else {
@@ -449,7 +608,8 @@ void RegisterThread(const FunctionCallbackInfo<Value> &args) {
449608
"Incorrect arguments. Expected: \n"
450609
"- registerThread(threadName: string) or \n"
451610
"- registerThread(storage: {asyncLocalStorage: AsyncLocalStorage; "
452-
"storageKey?: string | symbol}, threadName: string)",
611+
"stateLookup?: Array<string | symbol>}, "
612+
"threadName: string)",
453613
NewStringType::kInternalized)
454614
.ToLocalChecked()));
455615
}

src/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@ type AsyncStorageArgs = {
1616
/** The AsyncLocalStorage instance used to fetch the store */
1717
asyncLocalStorage: AsyncLocalStorage<unknown>;
1818
/**
19-
* Optional key in the store to fetch the state from. If not provided, the entire store will be returned.
19+
* Optional array of keys to fetch a specific property from the store
20+
* Key will be traversed in order through Objects/Maps to reach the desired property.
2021
*
21-
* This can be useful to fetch only a specific part of the state or in the
22-
* case of Open Telemetry, where it stores context under a symbol key.
22+
* This is useful if you want to capture Open Telemetry context values as state.
23+
*
24+
* To get this value:
25+
* context.getValue(my_unique_symbol_ref)
26+
*
27+
* You would set:
28+
* stateLookup: ['_currentContext', my_unique_symbol_ref]
2329
*/
24-
storageKey?: string | symbol;
30+
stateLookup?: Array<string | symbol>;
2531
}
2632

2733
type Thread<A = unknown, P = unknown> = {

test/async-storage.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { registerThread } from '@sentry-internal/node-native-stacktrace';
44
import { longWork } from './long-work.js';
55

66
const asyncLocalStorage = new AsyncLocalStorage();
7-
const storageKey = Symbol.for('sentry_scopes');
7+
const SOME_UNIQUE_SYMBOL = Symbol.for('sentry_scopes');
88

9-
registerThread({ asyncLocalStorage, storageKey });
9+
registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', SOME_UNIQUE_SYMBOL] });
1010

1111
function withTraceId(traceId, fn) {
12-
return asyncLocalStorage.run({
13-
[storageKey]: { traceId },
14-
}, fn);
12+
// This is a decent approximation of how Otel stores context in the ALS store
13+
const store = {
14+
_currentContext: new Map([ [SOME_UNIQUE_SYMBOL, { traceId }] ])
15+
};
16+
return asyncLocalStorage.run(store, fn);
1517
}
1618

1719
const watchdog = new Worker('./test/watchdog.js');

0 commit comments

Comments
 (0)