Skip to content

Commit efaf6cf

Browse files
authored
feat(browser): Add View Hierarchy integration (#14981)
By default it captures the entire DOM, but it is configurable: Capture only React components (uses attributes added by Sentry bundler plugins): ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.viewHierarchyIntegration({ onElement: ({componentName}) => componentName ? {} : 'children' })], }); ``` Capture only Web Components: ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.viewHierarchyIntegration({ onElement: ({tagName}) => tagName.includes('-') ? {} : 'children' })], }); ```
1 parent 34869c7 commit efaf6cf

File tree

8 files changed

+208
-3
lines changed

8 files changed

+208
-3
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 { viewHierarchyIntegration } from '@sentry/browser';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [viewHierarchyIntegration()],
9+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
throw new Error('Some error');
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<h1>Some title</h1>
9+
<p>Some text</p>
10+
</body>
11+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect } from '@playwright/test';
2+
import type { ViewHierarchyData } from '@sentry/core';
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getMultipleSentryEnvelopeRequests, envelopeParser } from '../../../utils/helpers';
5+
6+
sentryTest('Captures view hierarchy as attachment', async ({ getLocalTestUrl, page }) => {
7+
const bundle = process.env.PW_BUNDLE;
8+
if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestUrl({ testDir: __dirname });
13+
14+
const [, events] = await Promise.all([
15+
page.goto(url),
16+
getMultipleSentryEnvelopeRequests<ViewHierarchyData>(
17+
page,
18+
1,
19+
{},
20+
req => envelopeParser(req)?.[4] as ViewHierarchyData,
21+
),
22+
]);
23+
24+
expect(events).toHaveLength(1);
25+
const event: ViewHierarchyData = events[0];
26+
27+
expect(event.rendering_system).toBe('DOM');
28+
expect(event.positioning).toBe('absolute');
29+
expect(event.windows).toHaveLength(2);
30+
expect(event.windows[0].type).toBe('h1');
31+
expect(event.windows[0].visible).toBe(true);
32+
expect(event.windows[0].alpha).toBe(1);
33+
expect(event.windows[0].children).toHaveLength(0);
34+
35+
expect(event.windows[1].type).toBe('p');
36+
expect(event.windows[1].visible).toBe(true);
37+
expect(event.windows[1].alpha).toBe(1);
38+
expect(event.windows[1].children).toHaveLength(0);
39+
});

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { reportingObserverIntegration } from './integrations/reportingobserver';
77
export { httpClientIntegration } from './integrations/httpclient';
88
export { contextLinesIntegration } from './integrations/contextlines';
99
export { graphqlClientIntegration } from './integrations/graphqlClient';
10+
export { viewHierarchyIntegration } from './integrations/view-hierarchy';
1011

1112
export {
1213
captureConsoleIntegration,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Attachment, Event, EventHint, ViewHierarchyData, ViewHierarchyWindow } from '@sentry/core';
2+
import { defineIntegration, getComponentName } from '@sentry/core';
3+
import { WINDOW } from '../helpers';
4+
5+
interface OnElementArgs {
6+
/**
7+
* The element being processed.
8+
*/
9+
element: HTMLElement;
10+
/**
11+
* Lowercase tag name of the element.
12+
*/
13+
tagName: string;
14+
/**
15+
* The component name of the element.
16+
*/
17+
componentName?: string;
18+
19+
/**
20+
* The current depth of the element in the view hierarchy. The root element will have a depth of 0.
21+
*
22+
* This allows you to limit the traversal depth for large DOM trees.
23+
*/
24+
depth?: number;
25+
}
26+
27+
interface Options {
28+
/**
29+
* Whether to attach the view hierarchy to the event.
30+
*
31+
* Default: Always attach.
32+
*/
33+
shouldAttach?: (event: Event, hint: EventHint) => boolean;
34+
35+
/**
36+
* A function that returns the root element to start walking the DOM from.
37+
*
38+
* Default: `window.document.body`
39+
*/
40+
rootElement?: () => HTMLElement | undefined;
41+
42+
/**
43+
* Called for each HTMLElement as we walk the DOM.
44+
*
45+
* Return an object to include the element with any additional properties.
46+
* Return `skip` to exclude the element and its children.
47+
* Return `children` to skip the element but include its children.
48+
*/
49+
onElement?: (prop: OnElementArgs) => Record<string, string | number | boolean> | 'skip' | 'children';
50+
}
51+
52+
/**
53+
* An integration to include a view hierarchy attachment which contains the DOM.
54+
*/
55+
export const viewHierarchyIntegration = defineIntegration((options: Options = {}) => {
56+
const skipHtmlTags = ['script'];
57+
58+
/** Walk an element */
59+
function walk(element: HTMLElement, windows: ViewHierarchyWindow[], depth = 0): void {
60+
if (!element) {
61+
return;
62+
}
63+
64+
// With Web Components, we need to walk into shadow DOMs
65+
const children = 'shadowRoot' in element && element.shadowRoot ? element.shadowRoot.children : element.children;
66+
67+
for (const child of children) {
68+
if (!(child instanceof HTMLElement)) {
69+
continue;
70+
}
71+
72+
const componentName = getComponentName(child, 1) || undefined;
73+
const tagName = child.tagName.toLowerCase();
74+
75+
if (skipHtmlTags.includes(tagName)) {
76+
continue;
77+
}
78+
79+
const result = options.onElement?.({ element: child, componentName, tagName, depth }) || {};
80+
81+
if (result === 'skip') {
82+
continue;
83+
}
84+
85+
// Skip this element but include its children
86+
if (result === 'children') {
87+
walk(child, windows, depth + 1);
88+
continue;
89+
}
90+
91+
const { x, y, width, height } = child.getBoundingClientRect();
92+
93+
const window: ViewHierarchyWindow = {
94+
identifier: (child.id || undefined) as string,
95+
type: componentName || tagName,
96+
visible: true,
97+
alpha: 1,
98+
height,
99+
width,
100+
x,
101+
y,
102+
...result,
103+
};
104+
105+
const children: ViewHierarchyWindow[] = [];
106+
window.children = children;
107+
108+
// Recursively walk the children
109+
walk(child, window.children, depth + 1);
110+
111+
windows.push(window);
112+
}
113+
}
114+
115+
return {
116+
name: 'ViewHierarchy',
117+
processEvent: (event, hint) => {
118+
// only capture for error events
119+
if (event.type !== undefined || options.shouldAttach?.(event, hint) === false) {
120+
return event;
121+
}
122+
123+
const root: ViewHierarchyData = {
124+
rendering_system: 'DOM',
125+
positioning: 'absolute',
126+
windows: [],
127+
};
128+
129+
walk(options.rootElement?.() || WINDOW.document.body, root.windows);
130+
131+
const attachment: Attachment = {
132+
filename: 'view-hierarchy.json',
133+
attachmentType: 'event.view_hierarchy',
134+
contentType: 'application/json',
135+
data: JSON.stringify(root),
136+
};
137+
138+
hint.attachments = hint.attachments || [];
139+
hint.attachments.push(attachment);
140+
141+
return event;
142+
},
143+
};
144+
});

packages/core/src/types-hoist/view-hierarchy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export type ViewHierarchyWindow = {
1414

1515
export type ViewHierarchyData = {
1616
rendering_system: string;
17+
positioning?: 'absolute' | 'relative';
1718
windows: ViewHierarchyWindow[];
1819
};

packages/core/src/utils/browser.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,14 @@ export function getLocationHref(): string {
145145
*
146146
* @returns a string representation of the component for the provided DOM element, or `null` if not found
147147
*/
148-
export function getComponentName(elem: unknown): string | null {
148+
export function getComponentName(elem: unknown, maxTraverseHeight: number = 5): string | null {
149149
// @ts-expect-error WINDOW has HTMLElement
150150
if (!WINDOW.HTMLElement) {
151151
return null;
152152
}
153153

154154
let currentElem = elem as SimpleNode;
155-
const MAX_TRAVERSE_HEIGHT = 5;
156-
for (let i = 0; i < MAX_TRAVERSE_HEIGHT; i++) {
155+
for (let i = 0; i < maxTraverseHeight; i++) {
157156
if (!currentElem) {
158157
return null;
159158
}

0 commit comments

Comments
 (0)