Skip to content

Commit ab3515c

Browse files
authored
feat(browser): Add ContextLines integration for html-embedded JS stack frames (#8670)
Adds a new browser integration - `ContextLines`. It can be used to add source code lines to and around stack frames that point towards JS in html files (e.g. in `<script>` tags or `onclick` handlers). The integration **does not** apply these context lines to frames pointing to actual script files as these cannot be accessed within the browser.
1 parent e7afa27 commit ab3515c

File tree

7 files changed

+326
-0
lines changed

7 files changed

+326
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { ContextLines } from '@sentry/integrations';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
integrations: [new ContextLines()],
9+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
document.getElementById('script-error-btn').addEventListener('click', () => {
2+
throw new Error('Error without context lines');
3+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="inline-error-btn" onclick="throw new Error('Error with context lines')">Click me</button>
8+
<button id="script-error-btn">Click me too</button>
9+
</body>
10+
<footer>
11+
Some text...
12+
</foot>
13+
</html>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../utils/helpers';
5+
6+
sentryTest(
7+
'should add source context lines around stack frames from errors in Html inline JS',
8+
async ({ getLocalTestPath, page, browserName }) => {
9+
if (browserName === 'webkit') {
10+
// The error we're throwing in this test is thrown as "Script error." in Webkit.
11+
// We filter "Script error." out by default in `InboundFilters`.
12+
// I don't think there's much value to disable InboundFilters defaults for this test,
13+
// given that most of our users won't do that either.
14+
// Let's skip it instead for Webkit.
15+
sentryTest.skip();
16+
}
17+
18+
const url = await getLocalTestPath({ testDir: __dirname });
19+
20+
const eventReqPromise = waitForErrorRequestOnUrl(page, url);
21+
22+
const clickPromise = page.click('#inline-error-btn');
23+
24+
const [req] = await Promise.all([eventReqPromise, clickPromise]);
25+
26+
const eventData = envelopeRequestParser(req);
27+
28+
expect(eventData.exception?.values).toHaveLength(1);
29+
30+
const exception = eventData.exception?.values?.[0];
31+
32+
expect(exception).toMatchObject({
33+
stacktrace: {
34+
frames: [
35+
{
36+
pre_context: [' <meta charset="utf-8">', ' </head>', ' <body>'],
37+
context_line:
38+
' <button id="inline-error-btn" onclick="throw new Error(\'Error with context lines\')">Click me</button>',
39+
post_context: [
40+
' <button id="script-error-btn">Click me too</button>',
41+
expect.stringContaining('subject.bundle.js'), // this line varies in the test based on tarball/cdn bundle (+variants)
42+
' <footer>',
43+
],
44+
},
45+
],
46+
},
47+
});
48+
},
49+
);
50+
51+
sentryTest('should not add source context lines to errors from script files', async ({ getLocalTestPath, page }) => {
52+
const url = await getLocalTestPath({ testDir: __dirname });
53+
54+
const eventReqPromise = waitForErrorRequestOnUrl(page, url);
55+
56+
const clickPromise = page.click('#script-error-btn');
57+
58+
const [req] = await Promise.all([eventReqPromise, clickPromise]);
59+
60+
const eventData = envelopeRequestParser(req);
61+
62+
const exception = eventData.exception?.values?.[0];
63+
const frames = exception?.stacktrace?.frames;
64+
expect(frames).toHaveLength(1);
65+
frames?.forEach(f => {
66+
expect(f).not.toHaveProperty('pre_context');
67+
expect(f).not.toHaveProperty('context_line');
68+
expect(f).not.toHaveProperty('post_context');
69+
});
70+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { Event, EventProcessor, Integration, StackFrame } from '@sentry/types';
2+
import { GLOBAL_OBJ, stripUrlQueryAndFragment } from '@sentry/utils';
3+
4+
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;
5+
6+
interface ContextLinesOptions {
7+
/**
8+
* Sets the number of context lines for each frame when loading a file.
9+
* Defaults to 7.
10+
*
11+
* Set to 0 to disable loading and inclusion of source files.
12+
**/
13+
frameContextLines?: number;
14+
}
15+
16+
/**
17+
* Collects source context lines around the lines of stackframes pointing to JS embedded in
18+
* the current page's HTML.
19+
*
20+
* This integration DOES NOT work for stack frames pointing to JS files that are loaded by the browser.
21+
* For frames pointing to files, context lines are added during ingestion and symbolication
22+
* by attempting to download the JS files to the Sentry backend.
23+
*
24+
* Use this integration if you have inline JS code in HTML pages that can't be accessed
25+
* by our backend (e.g. due to a login-protected page).
26+
*/
27+
export class ContextLines implements Integration {
28+
/**
29+
* @inheritDoc
30+
*/
31+
public static id: string = 'ContextLines';
32+
33+
/**
34+
* @inheritDoc
35+
*/
36+
public name: string = ContextLines.id;
37+
38+
public constructor(private readonly _options: ContextLinesOptions = {}) {}
39+
40+
/**
41+
* @inheritDoc
42+
*/
43+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void {
44+
addGlobalEventProcessor(event => this.addSourceContext(event));
45+
}
46+
47+
/** Processes an event and adds context lines */
48+
public addSourceContext(event: Event): Event {
49+
const doc = WINDOW.document;
50+
const htmlFilename = WINDOW.location && stripUrlQueryAndFragment(WINDOW.location.href);
51+
if (!doc || !htmlFilename) {
52+
return event;
53+
}
54+
55+
const exceptions = event.exception && event.exception.values;
56+
if (!exceptions || !exceptions.length) {
57+
return event;
58+
}
59+
60+
const html = doc.documentElement.innerHTML;
61+
62+
const htmlLines = ['<!DOCTYPE html>', '<html>', ...html.split('\n'), '</html>'];
63+
if (!htmlLines.length) {
64+
return event;
65+
}
66+
67+
exceptions.forEach(exception => {
68+
const stacktrace = exception.stacktrace;
69+
if (stacktrace && stacktrace.frames) {
70+
stacktrace.frames = stacktrace.frames.map(frame =>
71+
applySourceContextToFrame(frame, htmlLines, htmlFilename, this._options.frameContextLines || 7),
72+
);
73+
}
74+
});
75+
76+
return event;
77+
}
78+
}
79+
80+
/**
81+
* Only exported for testing
82+
*/
83+
export function applySourceContextToFrame(
84+
frame: StackFrame,
85+
htmlLines: string[],
86+
htmlFilename: string,
87+
contextRange: number,
88+
): StackFrame {
89+
if (frame.filename !== htmlFilename || !frame.lineno || !htmlLines.length) {
90+
return frame;
91+
}
92+
93+
const sourroundingRange = Math.floor(contextRange / 2);
94+
const contextLineIndex = frame.lineno - 1;
95+
const preStartIndex = Math.max(contextLineIndex - sourroundingRange, 0);
96+
const postEndIndex = Math.min(contextLineIndex + sourroundingRange, htmlLines.length - 1);
97+
98+
const preLines = htmlLines.slice(preStartIndex, contextLineIndex);
99+
const contextLine = htmlLines[contextLineIndex];
100+
const postLines = htmlLines.slice(contextLineIndex + 1, postEndIndex + 1);
101+
102+
if (preLines.length) {
103+
frame.pre_context = preLines;
104+
}
105+
106+
if (contextLine) {
107+
frame.context_line = contextLine;
108+
}
109+
110+
if (postLines.length) {
111+
frame.post_context = postLines || undefined;
112+
}
113+
114+
return frame;
115+
}

packages/integrations/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { RewriteFrames } from './rewriteframes';
99
export { SessionTiming } from './sessiontiming';
1010
export { Transaction } from './transaction';
1111
export { HttpClient } from './httpclient';
12+
export { ContextLines } from './contextlines';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { StackFrame } from '@sentry/types';
2+
3+
import { applySourceContextToFrame } from '../src/contextlines';
4+
5+
const lines = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7', 'line8', 'line9'];
6+
describe('ContextLines', () => {
7+
describe('applySourceContextToFrame', () => {
8+
it.each([
9+
[
10+
5,
11+
{
12+
pre_context: ['line2', 'line3', 'line4'],
13+
context_line: 'line5',
14+
post_context: ['line6', 'line7', 'line8'],
15+
},
16+
],
17+
[
18+
1,
19+
{
20+
context_line: 'line1',
21+
post_context: ['line2', 'line3', 'line4'],
22+
},
23+
],
24+
[
25+
2,
26+
{
27+
pre_context: ['line1'],
28+
context_line: 'line2',
29+
post_context: ['line3', 'line4', 'line5'],
30+
},
31+
],
32+
[
33+
9,
34+
{
35+
pre_context: ['line6', 'line7', 'line8'],
36+
context_line: 'line9',
37+
},
38+
],
39+
[
40+
11,
41+
{
42+
pre_context: ['line8', 'line9'],
43+
},
44+
],
45+
])(
46+
'correctly applies pre, post contexts and context lines for an inline stack frame (lineno %s)',
47+
(lineno, contextLines) => {
48+
const frame: StackFrame = {
49+
lineno,
50+
filename: 'https://mydomain.com/index.html',
51+
};
52+
53+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 7)).toStrictEqual({
54+
filename: 'https://mydomain.com/index.html',
55+
lineno,
56+
...contextLines,
57+
});
58+
},
59+
);
60+
61+
it('only applies the context line if the range is 1', () => {
62+
const frame: StackFrame = {
63+
lineno: 5,
64+
filename: 'https://mydomain.com/index.html',
65+
};
66+
67+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 1)).toStrictEqual({
68+
filename: 'https://mydomain.com/index.html',
69+
lineno: 5,
70+
context_line: 'line5',
71+
});
72+
});
73+
74+
it("no-ops if the frame's line number is out of bounds for the found lines", () => {
75+
const frame: StackFrame = {
76+
lineno: 20,
77+
filename: 'https://mydomain.com/index.html',
78+
};
79+
80+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 7)).toStrictEqual(frame);
81+
});
82+
83+
it("no-ops if the frame's filename is not the html file's name", () => {
84+
const frame: StackFrame = {
85+
filename: '/someScript.js',
86+
};
87+
88+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 7)).toStrictEqual(frame);
89+
});
90+
91+
it("no-ops if the frame doesn't have a line number", () => {
92+
const frame: StackFrame = {
93+
filename: '/index.html',
94+
};
95+
96+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 0)).toStrictEqual(frame);
97+
});
98+
99+
it("no-ops if the frame doesn't have a filename", () => {
100+
const frame: StackFrame = {
101+
lineno: 9,
102+
};
103+
104+
expect(applySourceContextToFrame(frame, lines, 'https://mydomain.com/index.html', 0)).toStrictEqual(frame);
105+
});
106+
107+
it('no-ops if there are no html lines available', () => {
108+
const frame: StackFrame = {
109+
lineno: 9,
110+
filename: '/index.html',
111+
};
112+
expect(applySourceContextToFrame(frame, [], 'https://mydomain.com/index.html', 0)).toStrictEqual(frame);
113+
});
114+
});
115+
});

0 commit comments

Comments
 (0)