-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
/
localvariables.ts
324 lines (274 loc) · 10.6 KB
/
localvariables.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import type { Event, EventProcessor, Exception, Hub, Integration, StackFrame, StackParser } from '@sentry/types';
import type { Debugger, InspectorNotification, Runtime, Session } from 'inspector';
import { LRUMap } from 'lru_map';
import type { NodeClientOptions } from '../types';
export interface DebugSession {
/** Configures and connects to the debug session */
configureAndConnect(
onPause: (message: InspectorNotification<Debugger.PausedEventDataType>) => void,
captureAll: boolean,
): void;
/** Gets local variables for an objectId */
getLocalVariables(objectId: string): Promise<Record<string, unknown>>;
}
/**
* Promise API is available as `Experimental` and in Node 19 only.
*
* Callback-based API is `Stable` since v14 and `Experimental` since v8.
* Because of that, we are creating our own `AsyncSession` class.
*
* https://nodejs.org/docs/latest-v19.x/api/inspector.html#promises-api
* https://nodejs.org/docs/latest-v14.x/api/inspector.html
*/
class AsyncSession implements DebugSession {
private readonly _session: Session;
/** Throws if inspector API is not available */
public constructor() {
/*
TODO: We really should get rid of this require statement below for a couple of reasons:
1. It makes the integration unusable in the SvelteKit SDK, as it's not possible to use `require`
in SvelteKit server code (at least not by default).
2. Throwing in a constructor is bad practice
More context for a future attempt to fix this:
We already tried replacing it with import but didn't get it to work because of async problems.
We still called import in the constructor but assigned to a promise which we "awaited" in
`configureAndConnect`. However, this broke the Node integration tests as no local variables
were reported any more. We probably missed a place where we need to await the promise, too.
*/
// Node can be build without inspector support so this can throw
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Session } = require('inspector');
this._session = new Session();
}
/** @inheritdoc */
public configureAndConnect(
onPause: (message: InspectorNotification<Debugger.PausedEventDataType>) => void,
captureAll: boolean,
): void {
this._session.connect();
this._session.on('Debugger.paused', onPause);
this._session.post('Debugger.enable');
// We only want to pause on uncaught exceptions
this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' });
}
/** @inheritdoc */
public async getLocalVariables(objectId: string): Promise<Record<string, unknown>> {
const props = await this._getProperties(objectId);
const unrolled: Record<string, unknown> = {};
for (const prop of props) {
if (prop?.value?.objectId && prop?.value.className === 'Array') {
unrolled[prop.name] = await this._unrollArray(prop.value.objectId);
} else if (prop?.value?.objectId && prop?.value?.className === 'Object') {
unrolled[prop.name] = await this._unrollObject(prop.value.objectId);
} else if (prop?.value?.value || prop?.value?.description) {
unrolled[prop.name] = prop.value.value || `<${prop.value.description}>`;
}
}
return unrolled;
}
/**
* Gets all the PropertyDescriptors of an object
*/
private _getProperties(objectId: string): Promise<Runtime.PropertyDescriptor[]> {
return new Promise((resolve, reject) => {
this._session.post(
'Runtime.getProperties',
{
objectId,
ownProperties: true,
},
(err, params) => {
if (err) {
reject(err);
} else {
resolve(params.result);
}
},
);
});
}
/**
* Unrolls an array property
*/
private async _unrollArray(objectId: string): Promise<unknown> {
const props = await this._getProperties(objectId);
return props
.filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10)))
.sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10))
.map(v => v?.value?.value);
}
/**
* Unrolls an object property
*/
private async _unrollObject(objectId: string): Promise<Record<string, unknown>> {
const props = await this._getProperties(objectId);
return props
.map<[string, unknown]>(v => [v.name, v?.value?.value])
.reduce((obj, [key, val]) => {
obj[key] = val;
return obj;
}, {} as Record<string, unknown>);
}
}
/**
* When using Vercel pkg, the inspector module is not available.
* https://github.com/getsentry/sentry-javascript/issues/6769
*/
function tryNewAsyncSession(): AsyncSession | undefined {
try {
return new AsyncSession();
} catch (e) {
return undefined;
}
}
// Add types for the exception event data
type PausedExceptionEvent = Debugger.PausedEventDataType & {
data: {
// This contains error.stack
description: string;
};
};
/** Could this be an anonymous function? */
function isAnonymous(name: string | undefined): boolean {
return name !== undefined && ['', '?', '<anonymous>'].includes(name);
}
/** Do the function names appear to match? */
function functionNamesMatch(a: string | undefined, b: string | undefined): boolean {
return a === b || (isAnonymous(a) && isAnonymous(b));
}
/** Creates a unique hash from stack frames */
function hashFrames(frames: StackFrame[] | undefined): string | undefined {
if (frames === undefined) {
return;
}
// Only hash the 10 most recent frames (ie. the last 10)
return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, '');
}
/**
* We use the stack parser to create a unique hash from the exception stack trace
* This is used to lookup vars when the exception passes through the event processor
*/
function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined {
if (stack === undefined) {
return undefined;
}
return hashFrames(stackParser(stack, 1));
}
export interface FrameVariables {
function: string;
vars?: Record<string, unknown>;
}
/** There are no options yet. This allows them to be added later without breaking changes */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Options {
/**
* Capture local variables for both handled and unhandled exceptions
*
* Default: false - Only captures local variables for uncaught exceptions
*/
captureAllExceptions?: boolean;
}
/**
* Adds local variables to exception frames
*/
export class LocalVariables implements Integration {
public static id: string = 'LocalVariables';
public readonly name: string = LocalVariables.id;
private readonly _cachedFrames: LRUMap<string, Promise<FrameVariables[]>> = new LRUMap(20);
public constructor(
private readonly _options: Options = {},
private readonly _session: DebugSession | undefined = tryNewAsyncSession(),
) {}
/**
* @inheritDoc
*/
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
this._setup(addGlobalEventProcessor, getCurrentHub().getClient()?.getOptions());
}
/** Setup in a way that's easier to call from tests */
private _setup(
addGlobalEventProcessor: (callback: EventProcessor) => void,
clientOptions: NodeClientOptions | undefined,
): void {
if (this._session && clientOptions?.includeLocalVariables) {
this._session.configureAndConnect(
ev => this._handlePaused(clientOptions.stackParser, ev as InspectorNotification<PausedExceptionEvent>),
!!this._options.captureAllExceptions,
);
addGlobalEventProcessor(async event => this._addLocalVariables(event));
}
}
/**
* Handle the pause event
*/
private async _handlePaused(
stackParser: StackParser,
{ params: { reason, data, callFrames } }: InspectorNotification<PausedExceptionEvent>,
): Promise<void> {
if (reason !== 'exception' && reason !== 'promiseRejection') {
return;
}
// data.description contains the original error.stack
const exceptionHash = hashFromStack(stackParser, data?.description);
if (exceptionHash == undefined) {
return;
}
const framePromises = callFrames.map(async ({ scopeChain, functionName, this: obj }) => {
const localScope = scopeChain.find(scope => scope.type === 'local');
// obj.className is undefined in ESM modules
const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`;
if (localScope?.object.objectId === undefined) {
return { function: fn };
}
const vars = await this._session?.getLocalVariables(localScope.object.objectId);
return { function: fn, vars };
});
// We add the un-awaited promise to the cache rather than await here otherwise the event processor
// can be called before we're finished getting all the vars
this._cachedFrames.set(exceptionHash, Promise.all(framePromises));
}
/**
* Adds local variables event stack frames.
*/
private async _addLocalVariables(event: Event): Promise<Event> {
for (const exception of event?.exception?.values || []) {
await this._addLocalVariablesToException(exception);
}
return event;
}
/**
* Adds local variables to the exception stack frames.
*/
private async _addLocalVariablesToException(exception: Exception): Promise<void> {
const hash = hashFrames(exception?.stacktrace?.frames);
if (hash === undefined) {
return;
}
// Check if we have local variables for an exception that matches the hash
// delete is identical to get but also removes the entry from the cache
const cachedFrames = await this._cachedFrames.delete(hash);
if (cachedFrames === undefined) {
return;
}
const frameCount = exception.stacktrace?.frames?.length || 0;
for (let i = 0; i < frameCount; i++) {
// Sentry frames are in reverse order
const frameIndex = frameCount - i - 1;
// Drop out if we run out of frames to match up
if (!exception?.stacktrace?.frames?.[frameIndex] || !cachedFrames[i]) {
break;
}
if (
// We need to have vars to add
cachedFrames[i].vars === undefined ||
// We're not interested in frames that are not in_app because the vars are not relevant
exception.stacktrace.frames[frameIndex].in_app === false ||
// The function names need to match
!functionNamesMatch(exception.stacktrace.frames[frameIndex].function, cachedFrames[i].function)
) {
continue;
}
exception.stacktrace.frames[frameIndex].vars = cachedFrames[i].vars;
}
}
}