-
Notifications
You must be signed in to change notification settings - Fork 31
feat: Add stack trace parsing. #676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8b547c1
feat: Add stack trace parsing and options.
kinyoklion 0ecd1d2
feat: Add stack trace parsing.
kinyoklion 06b30e7
Merge branch 'main' into rlamb/add-stack-trace-parsing
kinyoklion 5c1fffc
Update packages/telemetry/browser-telemetry/src/stack/StackParser.ts
kinyoklion 89c3a76
PR comments.
kinyoklion a886e92
More comments.
kinyoklion 2509315
More comments
kinyoklion File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 hidden or 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 hidden or 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 === '') { | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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( | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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( | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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. | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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. | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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), | ||
kinyoklion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 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, | ||
| }; | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.