Skip to content

Commit 0ecd1d2

Browse files
committed
feat: Add stack trace parsing.
1 parent 8b547c1 commit 0ecd1d2

File tree

2 files changed

+275
-0
lines changed

2 files changed

+275
-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: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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+
const INDEX_SPECIFIER = '(index)';
8+
9+
/**
10+
* For files hosted on the origin attempt to reduce to just a filename.
11+
* If the origin matches the source file, then the special identifier `(index)` will
12+
* be used.
13+
*
14+
* @param input The input URL.
15+
* @returns The output file name.
16+
*/
17+
export function processUrlToFileName(input: string, origin: string): string {
18+
let cleaned = input;
19+
if (input.startsWith(origin)) {
20+
cleaned = input.slice(origin.length);
21+
if (cleaned.startsWith('/')) {
22+
cleaned = cleaned.slice(1);
23+
}
24+
if (cleaned === '') {
25+
cleaned = INDEX_SPECIFIER;
26+
}
27+
if (cleaned.endsWith('/')) {
28+
cleaned += INDEX_SPECIFIER;
29+
}
30+
}
31+
return cleaned;
32+
}
33+
34+
export interface TrimOptions {
35+
/**
36+
* The maximum length of the trimmed line.
37+
*/
38+
maxLength: number;
39+
40+
/**
41+
* If the line needs trimmed, then this is the number of character to retain before the
42+
* originating character of the frame.
43+
*/
44+
beforeColumnCharacters: number;
45+
}
46+
47+
/**
48+
* Trim a source string to a reasonable size.
49+
*
50+
* @param options Configuration which affects trimming.
51+
* @param line The source code line to trim.
52+
* @param column The column which the stack frame originates from.
53+
* @returns A trimmed source string.
54+
*/
55+
export function trimSourceLine(options: TrimOptions, line: string, column: number): string {
56+
if (line.length <= options.maxLength) {
57+
return line;
58+
}
59+
const captureStart = Math.max(0, column - options.beforeColumnCharacters);
60+
const captureEnd = Math.min(line.length, captureStart + options.maxLength);
61+
return line.slice(captureStart, captureEnd);
62+
}
63+
64+
/**
65+
* Exported for testing.
66+
*/
67+
export function getLines(
68+
start: number,
69+
end: number,
70+
context: string[],
71+
trimmer: (val: string) => string,
72+
): string[] {
73+
const adjustedStart = start < 0 ? 0 : start;
74+
const adjustedEnd = end > context.length ? context.length : end;
75+
if (adjustedStart < adjustedEnd) {
76+
return context.slice(adjustedStart, adjustedEnd).map(trimmer);
77+
}
78+
return [];
79+
}
80+
81+
/**
82+
* Exported for testing.
83+
*/
84+
export function getSrcLines(
85+
inFrame: {
86+
// Tracekit returns null potentially. We accept undefined as well to be as lenient here
87+
// as we can.
88+
context?: string[] | null;
89+
column?: number | null;
90+
},
91+
options: ParsedStackOptions,
92+
): {
93+
srcBefore?: string[];
94+
srcLine?: string;
95+
srcAfter?: string[];
96+
} {
97+
const { context } = inFrame;
98+
// It should be present, but we don't want to trust that it is.
99+
if (!context) {
100+
return {};
101+
}
102+
const { maxLineLength } = options.source;
103+
const beforeColumnCharacters = Math.floor(maxLineLength / 2);
104+
105+
// The before and after lines will not be precise while we use TraceKit.
106+
// By forking it we should be able to achieve a more optimal result.
107+
108+
// Trimmer for non-origin lines. Starts at column 0.
109+
const trimmer = (input: string) =>
110+
trimSourceLine(
111+
{
112+
maxLength: options.source.maxLineLength,
113+
beforeColumnCharacters,
114+
},
115+
input,
116+
0,
117+
);
118+
119+
const origin = Math.floor(context.length / 2);
120+
return {
121+
srcBefore: getLines(origin - options.source.beforeLines, origin, context, trimmer),
122+
srcLine: trimSourceLine(
123+
{
124+
maxLength: maxLineLength,
125+
beforeColumnCharacters,
126+
},
127+
context[origin],
128+
inFrame.column || 0,
129+
),
130+
srcAfter: getLines(origin + 1, origin + 1 + options.source.afterLines, context, trimmer),
131+
};
132+
}
133+
134+
/**
135+
* Parse the browser stack trace into a StackTrace which contains frames with specific fields parsed
136+
* from the free-form stack. Browser stack traces are not standardized, so implementations handling
137+
* the output should be resilient to missing fields.
138+
*
139+
* @param error The error to generate a StackTrace for.
140+
* @returns The stack trace for the given error.
141+
*/
142+
export default function parse(error: Error, options: ParsedStackOptions): StackTrace {
143+
const parsed = computeStackTrace(error);
144+
const frames: StackFrame[] = parsed.stack.reverse().map((inFrame) => ({
145+
fileName: processUrlToFileName(inFrame.url, window.location.origin),
146+
function: inFrame.func,
147+
line: inFrame.line,
148+
col: inFrame.column,
149+
...getSrcLines(inFrame, options),
150+
}));
151+
return {
152+
frames,
153+
};
154+
}

0 commit comments

Comments
 (0)