Skip to content

Commit

Permalink
feat: Add stack trace parsing. (#676)
Browse files Browse the repository at this point in the history
Review after: #675

---------

Co-authored-by: Casey Waldren <[email protected]>
  • Loading branch information
kinyoklion and cwaldren-ld authored Nov 14, 2024
1 parent c8352b2 commit ca1dd49
Show file tree
Hide file tree
Showing 2 changed files with 330 additions and 0 deletions.
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 packages/telemetry/browser-telemetry/src/stack/StackParser.ts
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,
};
}

0 comments on commit ca1dd49

Please sign in to comment.