-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add stack trace parsing. (#676)
Review after: #675 --------- Co-authored-by: Casey Waldren <[email protected]>
- Loading branch information
1 parent
c8352b2
commit ca1dd49
Showing
2 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
121 changes: 121 additions & 0 deletions
121
packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { | ||
getLines, | ||
getSrcLines, | ||
processUrlToFileName, | ||
TrimOptions, | ||
trimSourceLine, | ||
} from '../../src/stack/StackParser'; | ||
|
||
it.each([ | ||
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/', '(index)'], | ||
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test/(index)', 'test/(index)'], | ||
['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test.js', 'test.js'], | ||
['http://localhost:8080', 'http://localhost:8080/dist/main.js', 'dist/main.js'], | ||
])('handles URL parsing to file names', (origin: string, url: string, expected: string) => { | ||
expect(processUrlToFileName(url, origin)).toEqual(expected); | ||
}); | ||
|
||
it.each([ | ||
['this is the source line', 5, { maxLength: 10, beforeColumnCharacters: 2 }, 's is the s'], | ||
['this is the source line', 0, { maxLength: 10, beforeColumnCharacters: 2 }, 'this is th'], | ||
['this is the source line', 2, { maxLength: 10, beforeColumnCharacters: 0 }, 'is is the '], | ||
['12345', 0, { maxLength: 5, beforeColumnCharacters: 2 }, '12345'], | ||
['this is the source line', 21, { maxLength: 10, beforeColumnCharacters: 2 }, 'line'], | ||
])( | ||
'trims source lines', | ||
(source: string, column: number, options: TrimOptions, expected: string) => { | ||
expect(trimSourceLine(options, source, column)).toEqual(expected); | ||
}, | ||
); | ||
|
||
describe('given source lines', () => { | ||
const lines = ['1234567890', 'ABCDEFGHIJ', '0987654321', 'abcdefghij']; | ||
|
||
it('can get a range which would underflow the lines', () => { | ||
expect(getLines(-1, 2, lines, (input) => input)).toStrictEqual(['1234567890', 'ABCDEFGHIJ']); | ||
}); | ||
|
||
it('can get a range which would overflow the lines', () => { | ||
expect(getLines(2, 4, lines, (input) => input)).toStrictEqual(['0987654321', 'abcdefghij']); | ||
}); | ||
|
||
it('can get a range which is satisfied by the lines', () => { | ||
expect(getLines(0, 4, lines, (input) => input)).toStrictEqual([ | ||
'1234567890', | ||
'ABCDEFGHIJ', | ||
'0987654321', | ||
'abcdefghij', | ||
]); | ||
}); | ||
}); | ||
|
||
describe('given an input stack frame', () => { | ||
const inputFrame = { | ||
context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'], | ||
column: 0, | ||
}; | ||
|
||
it('can produce a full stack source in the output frame', () => { | ||
expect( | ||
getSrcLines(inputFrame, { | ||
source: { | ||
beforeLines: 2, | ||
afterLines: 2, | ||
maxLineLength: 280, | ||
}, | ||
}), | ||
).toMatchObject({ | ||
srcBefore: ['1234567890', 'ABCDEFGHIJ'], | ||
srcLine: 'the src line', | ||
srcAfter: ['0987654321', 'abcdefghij'], | ||
}); | ||
}); | ||
|
||
it('can trim all the lines', () => { | ||
expect( | ||
getSrcLines(inputFrame, { | ||
source: { | ||
beforeLines: 2, | ||
afterLines: 2, | ||
maxLineLength: 1, | ||
}, | ||
}), | ||
).toMatchObject({ | ||
srcBefore: ['1', 'A'], | ||
srcLine: 't', | ||
srcAfter: ['0', 'a'], | ||
}); | ||
}); | ||
|
||
it('can handle fewer input lines than the expected context', () => { | ||
expect( | ||
getSrcLines(inputFrame, { | ||
source: { | ||
beforeLines: 3, | ||
afterLines: 3, | ||
maxLineLength: 280, | ||
}, | ||
}), | ||
).toMatchObject({ | ||
srcBefore: ['1234567890', 'ABCDEFGHIJ'], | ||
srcLine: 'the src line', | ||
srcAfter: ['0987654321', 'abcdefghij'], | ||
}); | ||
}); | ||
|
||
it('can handle more input lines than the expected context', () => { | ||
expect( | ||
getSrcLines(inputFrame, { | ||
source: { | ||
beforeLines: 1, | ||
afterLines: 1, | ||
maxLineLength: 280, | ||
}, | ||
}), | ||
).toMatchObject({ | ||
srcBefore: ['ABCDEFGHIJ'], | ||
srcLine: 'the src line', | ||
srcAfter: ['0987654321'], | ||
}); | ||
}); | ||
}); |
209 changes: 209 additions & 0 deletions
209
packages/telemetry/browser-telemetry/src/stack/StackParser.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
import { computeStackTrace } from 'tracekit'; | ||
|
||
import { StackFrame } from '../api/stack/StackFrame'; | ||
import { StackTrace } from '../api/stack/StackTrace'; | ||
import { ParsedStackOptions } from '../options'; | ||
|
||
/** | ||
* In the browser we will not always be able to determine the source file that code originates | ||
* from. When you access a route it may just return HTML with embedded source, or just source, | ||
* in which case there may not be a file name. | ||
* | ||
* There will also be cases where there is no source file, such as when running with various | ||
* dev servers. | ||
* | ||
* In these situations we use this constant in place of the file name. | ||
*/ | ||
const INDEX_SPECIFIER = '(index)'; | ||
|
||
/** | ||
* For files hosted on the origin attempt to reduce to just a filename. | ||
* If the origin matches the source file, then the special identifier `(index)` will | ||
* be used. | ||
* | ||
* @param input The input URL. | ||
* @returns The output file name. | ||
*/ | ||
export function processUrlToFileName(input: string, origin: string): string { | ||
let cleaned = input; | ||
if (input.startsWith(origin)) { | ||
cleaned = input.slice(origin.length); | ||
// If the input is a single `/` then it would get removed and we would | ||
// be left with an empty string. That empty string would get replaced with | ||
// the INDEX_SPECIFIER. In cases where a `/` remains, either singular | ||
// or at the end of a path, then we will append the index specifier. | ||
// For instance the route `/test/` would ultimately be `test/(index)`. | ||
if (cleaned.startsWith('/')) { | ||
cleaned = cleaned.slice(1); | ||
} | ||
|
||
if (cleaned === '') { | ||
return INDEX_SPECIFIER; | ||
} | ||
|
||
if (cleaned.endsWith('/')) { | ||
cleaned += INDEX_SPECIFIER; | ||
} | ||
} | ||
return cleaned; | ||
} | ||
|
||
export interface TrimOptions { | ||
/** | ||
* The maximum length of the trimmed line. | ||
*/ | ||
maxLength: number; | ||
|
||
/** | ||
* If the line needs to be trimmed, then this is the number of character to retain before the | ||
* originating character of the frame. | ||
*/ | ||
beforeColumnCharacters: number; | ||
} | ||
|
||
/** | ||
* Trim a source string to a reasonable size. | ||
* | ||
* @param options Configuration which affects trimming. | ||
* @param line The source code line to trim. | ||
* @param column The column which the stack frame originates from. | ||
* @returns A trimmed source string. | ||
*/ | ||
export function trimSourceLine(options: TrimOptions, line: string, column: number): string { | ||
if (line.length <= options.maxLength) { | ||
return line; | ||
} | ||
const captureStart = Math.max(0, column - options.beforeColumnCharacters); | ||
const captureEnd = Math.min(line.length, captureStart + options.maxLength); | ||
return line.slice(captureStart, captureEnd); | ||
} | ||
|
||
/** | ||
* Given a context get trimmed source lines within the specified range. | ||
* | ||
* The context is a list of source code lines, this function returns a subset of | ||
* lines which have been trimmed. | ||
* | ||
* If an error is on a specific line of source code we want to be able to get | ||
* lines before and after that line. This is done relative to the originating | ||
* line of source. | ||
* | ||
* If you wanted to get 3 lines before the origin line, then this function would | ||
* need to be called with `start: originLine - 3, end: originLine`. | ||
* | ||
* If the `start` would underflow the context, then the start is set to 0. | ||
* If the `end` would overflow the context, then the end is set to the context | ||
* length. | ||
* | ||
* Exported for testing. | ||
* | ||
* @param start The inclusive start index. | ||
* @param end The exclusive end index. | ||
* @param trimmer Method which will trim individual lines. | ||
*/ | ||
export function getLines( | ||
start: number, | ||
end: number, | ||
context: string[], | ||
trimmer: (val: string) => string, | ||
): string[] { | ||
const adjustedStart = start < 0 ? 0 : start; | ||
const adjustedEnd = end > context.length ? context.length : end; | ||
if (adjustedStart < adjustedEnd) { | ||
return context.slice(adjustedStart, adjustedEnd).map(trimmer); | ||
} | ||
return []; | ||
} | ||
|
||
/** | ||
* Given a stack frame produce source context about that stack frame. | ||
* | ||
* The source context includes the source line of the stack frame, some number | ||
* of lines before the line of the stack frame, and some number of lines | ||
* after the stack frame. The amount of context can be controlled by the | ||
* provided options. | ||
* | ||
* Exported for testing. | ||
*/ | ||
export function getSrcLines( | ||
inFrame: { | ||
// Tracekit returns null potentially. We accept undefined as well to be as lenient here | ||
// as we can. | ||
context?: string[] | null; | ||
column?: number | null; | ||
}, | ||
options: ParsedStackOptions, | ||
): { | ||
srcBefore?: string[]; | ||
srcLine?: string; | ||
srcAfter?: string[]; | ||
} { | ||
const { context } = inFrame; | ||
// It should be present, but we don't want to trust that it is. | ||
if (!context) { | ||
return {}; | ||
} | ||
const { maxLineLength } = options.source; | ||
const beforeColumnCharacters = Math.floor(maxLineLength / 2); | ||
|
||
// The before and after lines will not be precise while we use TraceKit. | ||
// By forking it we should be able to achieve a more optimal result. | ||
// We only need to do this if we are not getting sufficient quality using this | ||
// method. | ||
|
||
// Trimmer for non-origin lines. Starts at column 0. | ||
// Non-origin lines are lines which are not the line for a specific stack | ||
// frame, but instead the lines before or after that frame. | ||
// ``` | ||
// console.log("before origin"); // non-origin line | ||
// throw new Error("this is the origin"); // origin line | ||
// console.log("after origin); // non-origin line | ||
// ``` | ||
const trimmer = (input: string) => | ||
trimSourceLine( | ||
{ | ||
maxLength: options.source.maxLineLength, | ||
beforeColumnCharacters, | ||
}, | ||
input, | ||
0, | ||
); | ||
|
||
const origin = Math.floor(context.length / 2); | ||
return { | ||
// The lines immediately preceeding the origin line. | ||
srcBefore: getLines(origin - options.source.beforeLines, origin, context, trimmer), | ||
srcLine: trimSourceLine( | ||
{ | ||
maxLength: maxLineLength, | ||
beforeColumnCharacters, | ||
}, | ||
context[origin], | ||
inFrame.column || 0, | ||
), | ||
// The lines immediately following the origin line. | ||
srcAfter: getLines(origin + 1, origin + 1 + options.source.afterLines, context, trimmer), | ||
}; | ||
} | ||
|
||
/** | ||
* Parse the browser stack trace into a StackTrace which contains frames with specific fields parsed | ||
* from the free-form stack. Browser stack traces are not standardized, so implementations handling | ||
* the output should be resilient to missing fields. | ||
* | ||
* @param error The error to generate a StackTrace for. | ||
* @returns The stack trace for the given error. | ||
*/ | ||
export default function parse(error: Error, options: ParsedStackOptions): StackTrace { | ||
const parsed = computeStackTrace(error); | ||
const frames: StackFrame[] = parsed.stack.reverse().map((inFrame) => ({ | ||
fileName: processUrlToFileName(inFrame.url, window.location.origin), | ||
function: inFrame.func, | ||
line: inFrame.line, | ||
col: inFrame.column, | ||
...getSrcLines(inFrame, options), | ||
})); | ||
return { | ||
frames, | ||
}; | ||
} |