Skip to content

Commit 0fe5d3d

Browse files
committed
feat(node): Skip context lines lookup when source maps are enabled
1 parent 2ec09c9 commit 0fe5d3d

File tree

5 files changed

+184
-5
lines changed

5 files changed

+184
-5
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { IntegrationFn } from '@sentry/core';
2+
import { defineIntegration } from '@sentry/core';
3+
import { contextLinesIntegration as originalContextLinesIntegration } from '@sentry/node';
4+
5+
const _contextLinesIntegration = ((options?: { frameContextLines?: number }) => {
6+
return originalContextLinesIntegration({
7+
...options,
8+
// Nest.js always enabled this, without an easy way for us to detect this
9+
// so we just enable it by default
10+
// see: https://github.com/nestjs/nest-cli/blob/f5dbb573df1fe103139026a36b6d0efe65e8e985/actions/start.action.ts#L220
11+
hasSourceMaps: true,
12+
});
13+
}) satisfies IntegrationFn;
14+
15+
/**
16+
* Capture the lines before and after the frame's context.
17+
*
18+
* A Nest-specific variant of the node-core contextLineIntegration.
19+
* This has source maps enabled by default, as Nest.js enables this under the hood.
20+
*/
21+
export const contextLinesIntegration = defineIntegration(_contextLinesIntegration);

packages/nestjs/src/sdk.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@sentry/core';
88
import type { NodeClient, NodeOptions, Span } from '@sentry/node';
99
import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node';
10+
import { contextLinesIntegration } from './integrations/contextlines';
1011
import { nestIntegration } from './integrations/nest';
1112

1213
/**
@@ -34,7 +35,12 @@ export function init(options: NodeOptions | undefined = {}): NodeClient | undefi
3435

3536
/** Get the default integrations for the NestJS SDK. */
3637
export function getDefaultIntegrations(options: NodeOptions): Integration[] | undefined {
37-
return [nestIntegration(), ...getDefaultNodeIntegrations(options)];
38+
return [
39+
nestIntegration(),
40+
...getDefaultNodeIntegrations(options).filter(i => i.name !== 'ContextLines'),
41+
// Custom variant for Nest.js
42+
contextLinesIntegration(),
43+
];
3844
}
3945

4046
function addNestSpanAttributes(span: Span): void {

packages/node-core/src/integrations/contextlines.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ interface ContextLinesOptions {
2222
* Set to 0 to disable loading and inclusion of source files.
2323
**/
2424
frameContextLines?: number;
25+
26+
/**
27+
* If error stacktraces are already sourcemapped.
28+
* In this case, we can skip the sourcemap lookup for certain cases.
29+
* This is the case e.g. if the node process is run with `node --enable-source-maps`.
30+
* If this is undefined, the SDK tries to infer it from the environment.
31+
*/
32+
hasSourceMaps?: boolean;
2533
}
2634

2735
/**
@@ -62,6 +70,19 @@ function shouldSkipContextLinesForFile(path: string): boolean {
6270
return false;
6371
}
6472

73+
/**
74+
* Skip frames that we determine to already have been sourcemapped.
75+
*/
76+
function shouldSkipContextLinesThatAreAlreadySourceMapped(path: string, frame: StackFrame): boolean {
77+
// For non-in-app frames, we skip context lines when we are reasonably certain that the path is already sourcemapped.
78+
// For now, we only consider .ts files because they can never appear otherwise in a stackframe, if not already sourcemapped.
79+
if (frame.in_app === false && path.endsWith('.ts')) {
80+
return true;
81+
}
82+
83+
return false;
84+
}
85+
6586
/**
6687
* Determines if we should skip contextlines based off the max lineno and colno values.
6788
*/
@@ -164,10 +185,11 @@ function getContextLinesFromFile(path: string, ranges: ReadlineRange[], output:
164185

165186
// We use this inside Promise.all, so we need to resolve the promise even if there is an error
166187
// to prevent Promise.all from short circuiting the rest.
167-
function onStreamError(e: Error): void {
188+
function onStreamError(): void {
168189
// Mark file path as failed to read and prevent multiple read attempts.
169190
LRU_FILE_CONTENTS_FS_READ_FAILED.set(path, 1);
170-
DEBUG_BUILD && debug.error(`Failed to read file: ${path}. Error: ${e}`);
191+
DEBUG_BUILD &&
192+
debug.warn(`ContextLines: Failed to read file: ${path}. Skipping context line extraction for this file.`);
171193
lineReaded.close();
172194
lineReaded.removeAllListeners();
173195
destroyStreamAndResolve();
@@ -215,7 +237,10 @@ function getContextLinesFromFile(path: string, ranges: ReadlineRange[], output:
215237
* failing reads from happening.
216238
*/
217239
/* eslint-disable complexity */
218-
async function addSourceContext(event: Event, contextLines: number): Promise<Event> {
240+
async function addSourceContext(
241+
event: Event,
242+
{ contextLines, hasSourceMaps }: { contextLines: number; hasSourceMaps: boolean },
243+
): Promise<Event> {
219244
// keep a lookup map of which files we've already enqueued to read,
220245
// so we don't enqueue the same file multiple times which would cause multiple i/o reads
221246
const filesToLines: Record<string, number[]> = {};
@@ -242,6 +267,10 @@ async function addSourceContext(event: Event, contextLines: number): Promise<Eve
242267
continue;
243268
}
244269

270+
if (hasSourceMaps && shouldSkipContextLinesThatAreAlreadySourceMapped(filename, frame)) {
271+
continue;
272+
}
273+
245274
const filesToLinesOutput = filesToLines[filename];
246275
if (!filesToLinesOutput) filesToLines[filename] = [];
247276
// @ts-expect-error this is defined above
@@ -399,11 +428,12 @@ function makeContextRange(line: number, linecontext: number): [start: number, en
399428
/** Exported only for tests, as a type-safe variant. */
400429
export const _contextLinesIntegration = ((options: ContextLinesOptions = {}) => {
401430
const contextLines = options.frameContextLines !== undefined ? options.frameContextLines : DEFAULT_LINES_OF_CONTEXT;
431+
const hasSourceMaps = options.hasSourceMaps ?? inferHasSourceMaps();
402432

403433
return {
404434
name: INTEGRATION_NAME,
405435
processEvent(event) {
406-
return addSourceContext(event, contextLines);
436+
return addSourceContext(event, { contextLines, hasSourceMaps });
407437
},
408438
};
409439
}) satisfies IntegrationFn;
@@ -412,3 +442,10 @@ export const _contextLinesIntegration = ((options: ContextLinesOptions = {}) =>
412442
* Capture the lines before and after the frame's context.
413443
*/
414444
export const contextLinesIntegration = defineIntegration(_contextLinesIntegration);
445+
446+
function inferHasSourceMaps(): boolean {
447+
return (
448+
(process.env.NODE_OPTIONS?.includes('--enable-source-maps') || process.argv.includes('--enable-source-maps')) ??
449+
false
450+
);
451+
}

packages/node-core/test/integrations/contextlines.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,114 @@ describe('ContextLines', () => {
232232
expect(readStreamSpy).not.toHaveBeenCalled();
233233
});
234234
});
235+
236+
describe('hasSourceMaps', () => {
237+
let _argv: string[];
238+
let _env: any;
239+
240+
beforeEach(() => {
241+
_argv = process.argv;
242+
_env = process.env;
243+
});
244+
245+
afterEach(() => {
246+
process.argv = _argv;
247+
process.env = _env;
248+
});
249+
250+
test.each([
251+
[undefined, 'file:///var/task/hasSourceMaps/index1a.ts', true],
252+
[true, 'file:///var/task/hasSourceMaps/index1b.ts', true],
253+
[false, 'file:///var/task/hasSourceMaps/index1c.ts', false],
254+
[undefined, 'file:///var/task/hasSourceMaps/index1d.js', true],
255+
[true, 'file:///var/task/hasSourceMaps/index1e.js', true],
256+
[false, 'file:///var/task/hasSourceMaps/index1f.js', true],
257+
])('handles in_app=%s, filename=%s', async (in_app, filename, hasBeenCalled) => {
258+
contextLines = _contextLinesIntegration({ hasSourceMaps: true });
259+
const readStreamSpy = vi.spyOn(fs, 'createReadStream');
260+
261+
const frames: StackFrame[] = [
262+
{
263+
colno: 1,
264+
filename,
265+
lineno: 1,
266+
function: 'fxn1',
267+
in_app,
268+
},
269+
];
270+
271+
await addContext(frames);
272+
273+
expect(readStreamSpy).toHaveBeenCalledTimes(hasBeenCalled ? 1 : 0);
274+
});
275+
276+
test('infer hasSourceMaps from NODE_OPTIONS', async () => {
277+
process.env = {
278+
..._env,
279+
NODE_OPTIONS: '--enable-source-maps --other-option',
280+
};
281+
282+
contextLines = _contextLinesIntegration();
283+
const readStreamSpy = vi.spyOn(fs, 'createReadStream');
284+
285+
const frames: StackFrame[] = [
286+
{
287+
colno: 1,
288+
filename: 'file:///var/task/hasSourceMaps/index-infer1.ts',
289+
lineno: 1,
290+
function: 'fxn1',
291+
in_app: false,
292+
},
293+
];
294+
295+
await addContext(frames);
296+
297+
expect(readStreamSpy).toHaveBeenCalledTimes(0);
298+
});
299+
300+
test('infer hasSourceMaps from process.argv', async () => {
301+
process.argv = [..._argv, '--enable-source-maps', '--other-option'];
302+
303+
contextLines = _contextLinesIntegration();
304+
const readStreamSpy = vi.spyOn(fs, 'createReadStream');
305+
306+
const frames: StackFrame[] = [
307+
{
308+
colno: 1,
309+
filename: 'file:///var/task/hasSourceMaps/index-infer2.ts',
310+
lineno: 1,
311+
function: 'fxn1',
312+
in_app: false,
313+
},
314+
];
315+
316+
await addContext(frames);
317+
318+
expect(readStreamSpy).toHaveBeenCalledTimes(0);
319+
});
320+
321+
test('does not infer hasSourceMaps if hasSourceMaps is set', async () => {
322+
process.env = {
323+
..._env,
324+
NODE_OPTIONS: '--enable-source-maps --other-option',
325+
};
326+
327+
contextLines = _contextLinesIntegration({ hasSourceMaps: false });
328+
const readStreamSpy = vi.spyOn(fs, 'createReadStream');
329+
330+
const frames: StackFrame[] = [
331+
{
332+
colno: 1,
333+
filename: 'file:///var/task/hasSourceMaps/index-infer3.ts',
334+
lineno: 1,
335+
function: 'fxn1',
336+
in_app: false,
337+
},
338+
];
339+
340+
await addContext(frames);
341+
342+
expect(readStreamSpy).toHaveBeenCalledTimes(1);
343+
});
344+
});
235345
});

packages/node-core/test/sdk/init.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ describe('init()', () => {
220220
});
221221

222222
describe('validateOpenTelemetrySetup', () => {
223+
beforeEach(() => {
224+
// just swallowing these
225+
vi.spyOn(debug, 'log').mockImplementation(() => {});
226+
});
227+
223228
afterEach(() => {
224229
global.__SENTRY__ = {};
225230
cleanupOtel();

0 commit comments

Comments
 (0)