Skip to content

Commit ca1dd49

Browse files
feat: Add stack trace parsing. (#676)
Review after: #675 --------- Co-authored-by: Casey Waldren <[email protected]>
1 parent c8352b2 commit ca1dd49

File tree

2 files changed

+330
-0
lines changed

2 files changed

+330
-0
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
getLines,
3+
getSrcLines,
4+
processUrlToFileName,
5+
TrimOptions,
6+
trimSourceLine,
7+
} from '../../src/stack/StackParser';
8+
9+
it.each([
10+
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/', '(index)'],
11+
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test/(index)', 'test/(index)'],
12+
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test.js', 'test.js'],
13+
['http://localhost:8080', 'http://localhost:8080/dist/main.js', 'dist/main.js'],
14+
])('handles URL parsing to file names', (origin: string, url: string, expected: string) => {
15+
expect(processUrlToFileName(url, origin)).toEqual(expected);
16+
});
17+
18+
it.each([
19+
['this is the source line', 5, { maxLength: 10, beforeColumnCharacters: 2 }, 's is the s'],
20+
['this is the source line', 0, { maxLength: 10, beforeColumnCharacters: 2 }, 'this is th'],
21+
['this is the source line', 2, { maxLength: 10, beforeColumnCharacters: 0 }, 'is is the '],
22+
['12345', 0, { maxLength: 5, beforeColumnCharacters: 2 }, '12345'],
23+
['this is the source line', 21, { maxLength: 10, beforeColumnCharacters: 2 }, 'line'],
24+
])(
25+
'trims source lines',
26+
(source: string, column: number, options: TrimOptions, expected: string) => {
27+
expect(trimSourceLine(options, source, column)).toEqual(expected);
28+
},
29+
);
30+
31+
describe('given source lines', () => {
32+
const lines = ['1234567890', 'ABCDEFGHIJ', '0987654321', 'abcdefghij'];
33+
34+
it('can get a range which would underflow the lines', () => {
35+
expect(getLines(-1, 2, lines, (input) => input)).toStrictEqual(['1234567890', 'ABCDEFGHIJ']);
36+
});
37+
38+
it('can get a range which would overflow the lines', () => {
39+
expect(getLines(2, 4, lines, (input) => input)).toStrictEqual(['0987654321', 'abcdefghij']);
40+
});
41+
42+
it('can get a range which is satisfied by the lines', () => {
43+
expect(getLines(0, 4, lines, (input) => input)).toStrictEqual([
44+
'1234567890',
45+
'ABCDEFGHIJ',
46+
'0987654321',
47+
'abcdefghij',
48+
]);
49+
});
50+
});
51+
52+
describe('given an input stack frame', () => {
53+
const inputFrame = {
54+
context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'],
55+
column: 0,
56+
};
57+
58+
it('can produce a full stack source in the output frame', () => {
59+
expect(
60+
getSrcLines(inputFrame, {
61+
source: {
62+
beforeLines: 2,
63+
afterLines: 2,
64+
maxLineLength: 280,
65+
},
66+
}),
67+
).toMatchObject({
68+
srcBefore: ['1234567890', 'ABCDEFGHIJ'],
69+
srcLine: 'the src line',
70+
srcAfter: ['0987654321', 'abcdefghij'],
71+
});
72+
});
73+
74+
it('can trim all the lines', () => {
75+
expect(
76+
getSrcLines(inputFrame, {
77+
source: {
78+
beforeLines: 2,
79+
afterLines: 2,
80+
maxLineLength: 1,
81+
},
82+
}),
83+
).toMatchObject({
84+
srcBefore: ['1', 'A'],
85+
srcLine: 't',
86+
srcAfter: ['0', 'a'],
87+
});
88+
});
89+
90+
it('can handle fewer input lines than the expected context', () => {
91+
expect(
92+
getSrcLines(inputFrame, {
93+
source: {
94+
beforeLines: 3,
95+
afterLines: 3,
96+
maxLineLength: 280,
97+
},
98+
}),
99+
).toMatchObject({
100+
srcBefore: ['1234567890', 'ABCDEFGHIJ'],
101+
srcLine: 'the src line',
102+
srcAfter: ['0987654321', 'abcdefghij'],
103+
});
104+
});
105+
106+
it('can handle more input lines than the expected context', () => {
107+
expect(
108+
getSrcLines(inputFrame, {
109+
source: {
110+
beforeLines: 1,
111+
afterLines: 1,
112+
maxLineLength: 280,
113+
},
114+
}),
115+
).toMatchObject({
116+
srcBefore: ['ABCDEFGHIJ'],
117+
srcLine: 'the src line',
118+
srcAfter: ['0987654321'],
119+
});
120+
});
121+
});
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { computeStackTrace } from 'tracekit';
2+
3+
import { StackFrame } from '../api/stack/StackFrame';
4+
import { StackTrace } from '../api/stack/StackTrace';
5+
import { ParsedStackOptions } from '../options';
6+
7+
/**
8+
* In the browser we will not always be able to determine the source file that code originates
9+
* from. When you access a route it may just return HTML with embedded source, or just source,
10+
* in which case there may not be a file name.
11+
*
12+
* There will also be cases where there is no source file, such as when running with various
13+
* dev servers.
14+
*
15+
* In these situations we use this constant in place of the file name.
16+
*/
17+
const INDEX_SPECIFIER = '(index)';
18+
19+
/**
20+
* For files hosted on the origin attempt to reduce to just a filename.
21+
* If the origin matches the source file, then the special identifier `(index)` will
22+
* be used.
23+
*
24+
* @param input The input URL.
25+
* @returns The output file name.
26+
*/
27+
export function processUrlToFileName(input: string, origin: string): string {
28+
let cleaned = input;
29+
if (input.startsWith(origin)) {
30+
cleaned = input.slice(origin.length);
31+
// If the input is a single `/` then it would get removed and we would
32+
// be left with an empty string. That empty string would get replaced with
33+
// the INDEX_SPECIFIER. In cases where a `/` remains, either singular
34+
// or at the end of a path, then we will append the index specifier.
35+
// For instance the route `/test/` would ultimately be `test/(index)`.
36+
if (cleaned.startsWith('/')) {
37+
cleaned = cleaned.slice(1);
38+
}
39+
40+
if (cleaned === '') {
41+
return INDEX_SPECIFIER;
42+
}
43+
44+
if (cleaned.endsWith('/')) {
45+
cleaned += INDEX_SPECIFIER;
46+
}
47+
}
48+
return cleaned;
49+
}
50+
51+
export interface TrimOptions {
52+
/**
53+
* The maximum length of the trimmed line.
54+
*/
55+
maxLength: number;
56+
57+
/**
58+
* If the line needs to be trimmed, then this is the number of character to retain before the
59+
* originating character of the frame.
60+
*/
61+
beforeColumnCharacters: number;
62+
}
63+
64+
/**
65+
* Trim a source string to a reasonable size.
66+
*
67+
* @param options Configuration which affects trimming.
68+
* @param line The source code line to trim.
69+
* @param column The column which the stack frame originates from.
70+
* @returns A trimmed source string.
71+
*/
72+
export function trimSourceLine(options: TrimOptions, line: string, column: number): string {
73+
if (line.length <= options.maxLength) {
74+
return line;
75+
}
76+
const captureStart = Math.max(0, column - options.beforeColumnCharacters);
77+
const captureEnd = Math.min(line.length, captureStart + options.maxLength);
78+
return line.slice(captureStart, captureEnd);
79+
}
80+
81+
/**
82+
* Given a context get trimmed source lines within the specified range.
83+
*
84+
* The context is a list of source code lines, this function returns a subset of
85+
* lines which have been trimmed.
86+
*
87+
* If an error is on a specific line of source code we want to be able to get
88+
* lines before and after that line. This is done relative to the originating
89+
* line of source.
90+
*
91+
* If you wanted to get 3 lines before the origin line, then this function would
92+
* need to be called with `start: originLine - 3, end: originLine`.
93+
*
94+
* If the `start` would underflow the context, then the start is set to 0.
95+
* If the `end` would overflow the context, then the end is set to the context
96+
* length.
97+
*
98+
* Exported for testing.
99+
*
100+
* @param start The inclusive start index.
101+
* @param end The exclusive end index.
102+
* @param trimmer Method which will trim individual lines.
103+
*/
104+
export function getLines(
105+
start: number,
106+
end: number,
107+
context: string[],
108+
trimmer: (val: string) => string,
109+
): string[] {
110+
const adjustedStart = start < 0 ? 0 : start;
111+
const adjustedEnd = end > context.length ? context.length : end;
112+
if (adjustedStart < adjustedEnd) {
113+
return context.slice(adjustedStart, adjustedEnd).map(trimmer);
114+
}
115+
return [];
116+
}
117+
118+
/**
119+
* Given a stack frame produce source context about that stack frame.
120+
*
121+
* The source context includes the source line of the stack frame, some number
122+
* of lines before the line of the stack frame, and some number of lines
123+
* after the stack frame. The amount of context can be controlled by the
124+
* provided options.
125+
*
126+
* Exported for testing.
127+
*/
128+
export function getSrcLines(
129+
inFrame: {
130+
// Tracekit returns null potentially. We accept undefined as well to be as lenient here
131+
// as we can.
132+
context?: string[] | null;
133+
column?: number | null;
134+
},
135+
options: ParsedStackOptions,
136+
): {
137+
srcBefore?: string[];
138+
srcLine?: string;
139+
srcAfter?: string[];
140+
} {
141+
const { context } = inFrame;
142+
// It should be present, but we don't want to trust that it is.
143+
if (!context) {
144+
return {};
145+
}
146+
const { maxLineLength } = options.source;
147+
const beforeColumnCharacters = Math.floor(maxLineLength / 2);
148+
149+
// The before and after lines will not be precise while we use TraceKit.
150+
// By forking it we should be able to achieve a more optimal result.
151+
// We only need to do this if we are not getting sufficient quality using this
152+
// method.
153+
154+
// Trimmer for non-origin lines. Starts at column 0.
155+
// Non-origin lines are lines which are not the line for a specific stack
156+
// frame, but instead the lines before or after that frame.
157+
// ```
158+
// console.log("before origin"); // non-origin line
159+
// throw new Error("this is the origin"); // origin line
160+
// console.log("after origin); // non-origin line
161+
// ```
162+
const trimmer = (input: string) =>
163+
trimSourceLine(
164+
{
165+
maxLength: options.source.maxLineLength,
166+
beforeColumnCharacters,
167+
},
168+
input,
169+
0,
170+
);
171+
172+
const origin = Math.floor(context.length / 2);
173+
return {
174+
// The lines immediately preceeding the origin line.
175+
srcBefore: getLines(origin - options.source.beforeLines, origin, context, trimmer),
176+
srcLine: trimSourceLine(
177+
{
178+
maxLength: maxLineLength,
179+
beforeColumnCharacters,
180+
},
181+
context[origin],
182+
inFrame.column || 0,
183+
),
184+
// The lines immediately following the origin line.
185+
srcAfter: getLines(origin + 1, origin + 1 + options.source.afterLines, context, trimmer),
186+
};
187+
}
188+
189+
/**
190+
* Parse the browser stack trace into a StackTrace which contains frames with specific fields parsed
191+
* from the free-form stack. Browser stack traces are not standardized, so implementations handling
192+
* the output should be resilient to missing fields.
193+
*
194+
* @param error The error to generate a StackTrace for.
195+
* @returns The stack trace for the given error.
196+
*/
197+
export default function parse(error: Error, options: ParsedStackOptions): StackTrace {
198+
const parsed = computeStackTrace(error);
199+
const frames: StackFrame[] = parsed.stack.reverse().map((inFrame) => ({
200+
fileName: processUrlToFileName(inFrame.url, window.location.origin),
201+
function: inFrame.func,
202+
line: inFrame.line,
203+
col: inFrame.column,
204+
...getSrcLines(inFrame, options),
205+
}));
206+
return {
207+
frames,
208+
};
209+
}

0 commit comments

Comments
 (0)