Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/browser';
import { viewHierarchyIntegration } from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [viewHierarchyIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('Some error');
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<h1>Some title</h1>
<p>Some text</p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect } from '@playwright/test';
import type { ViewHierarchyData } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests, envelopeParser } from '../../../utils/helpers';

sentryTest('Captures view hierarchy as attachment', async ({ getLocalTestUrl, page }) => {
const bundle = process.env.PW_BUNDLE;
if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });

const [, events] = await Promise.all([
page.goto(url),
getMultipleSentryEnvelopeRequests<ViewHierarchyData>(
page,
1,
{},
req => envelopeParser(req)?.[4] as ViewHierarchyData,
),
]);

expect(events).toHaveLength(1);
const event: ViewHierarchyData = events[0];

expect(event.rendering_system).toBe('DOM');
expect(event.positioning).toBe('absolute');
expect(event.windows).toHaveLength(2);
expect(event.windows[0].type).toBe('h1');
expect(event.windows[0].visible).toBe(true);
expect(event.windows[0].alpha).toBe(1);
expect(event.windows[0].children).toHaveLength(0);

expect(event.windows[1].type).toBe('p');
expect(event.windows[1].visible).toBe(true);
expect(event.windows[1].alpha).toBe(1);
expect(event.windows[1].children).toHaveLength(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ interface Env {
}

const myWorker = {
async fetch(request: Request) {
async fetch(_: Request) {
return new Response('Hello from another worker!');
},
};
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { reportingObserverIntegration } from './integrations/reportingobserver';
export { httpClientIntegration } from './integrations/httpclient';
export { contextLinesIntegration } from './integrations/contextlines';
export { graphqlClientIntegration } from './integrations/graphqlClient';
export { viewHierarchyIntegration } from './integrations/view-hierarchy';

export {
captureConsoleIntegration,
Expand Down
144 changes: 144 additions & 0 deletions packages/browser/src/integrations/view-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { Attachment, Event, EventHint, ViewHierarchyData, ViewHierarchyWindow } from '@sentry/core';
import { defineIntegration, getComponentName } from '@sentry/core';
import { WINDOW } from '../helpers';

interface OnElementArgs {
/**
* The element being processed.
*/
element: HTMLElement;
/**
* Lowercase tag name of the element.
*/
tagName: string;
/**
* The component name of the element.
*/
componentName?: string;

/**
* The current depth of the element in the view hierarchy. The root element will have a depth of 0.
*
* This allows you to limit the traversal depth for large DOM trees.
*/
depth?: number;
}

interface Options {
/**
* Whether to attach the view hierarchy to the event.
*
* Default: Always attach.
*/
shouldAttach?: (event: Event, hint: EventHint) => boolean;

/**
* A function that returns the root element to start walking the DOM from.
*
* Default: `window.document.body`
*/
rootElement?: () => HTMLElement | undefined;

/**
* Called for each HTMLElement as we walk the DOM.
*
* Return an object to include the element with any additional properties.
* Return `skip` to exclude the element and its children.
* Return `children` to skip the element but include its children.
*/
onElement?: (prop: OnElementArgs) => Record<string, string | number | boolean> | 'skip' | 'children';
}

/**
* An integration to include a view hierarchy attachment which contains the DOM.
*/
export const viewHierarchyIntegration = defineIntegration((options: Options = {}) => {
const skipHtmlTags = ['script'];

/** Walk an element */
function walk(element: HTMLElement, windows: ViewHierarchyWindow[], depth = 0): void {
if (!element) {
return;
}

// With Web Components, we need to walk into shadow DOMs
const children = 'shadowRoot' in element && element.shadowRoot ? element.shadowRoot.children : element.children;

for (const child of children) {
if (!(child instanceof HTMLElement)) {
continue;
}

const componentName = getComponentName(child, 1) || undefined;
const tagName = child.tagName.toLowerCase();

if (skipHtmlTags.includes(tagName)) {
continue;
}

const result = options.onElement?.({ element: child, componentName, tagName, depth }) || {};

if (result === 'skip') {
continue;
}

// Skip this element but include its children
if (result === 'children') {
walk(child, windows, depth + 1);
continue;
}

const { x, y, width, height } = child.getBoundingClientRect();

const window: ViewHierarchyWindow = {
identifier: (child.id || undefined) as string,
type: componentName || tagName,
visible: true,
alpha: 1,
height,
width,
x,
y,
...result,
};

const children: ViewHierarchyWindow[] = [];
window.children = children;

// Recursively walk the children
walk(child, window.children, depth + 1);

windows.push(window);
}
}

return {
name: 'ViewHierarchy',
processEvent: (event, hint) => {
// only capture for error events
if (event.type !== undefined || options.shouldAttach?.(event, hint) === false) {
return event;
}

const root: ViewHierarchyData = {
rendering_system: 'DOM',
positioning: 'absolute',
windows: [],
};

walk(options.rootElement?.() || WINDOW.document.body, root.windows);

const attachment: Attachment = {
filename: 'view-hierarchy.json',
attachmentType: 'event.view_hierarchy',
contentType: 'application/json',
data: JSON.stringify(root),
};

hint.attachments = hint.attachments || [];
hint.attachments.push(attachment);

return event;
},
};
});
1 change: 1 addition & 0 deletions packages/core/src/types-hoist/view-hierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export type ViewHierarchyWindow = {

export type ViewHierarchyData = {
rendering_system: string;
positioning?: 'absolute' | 'relative';
windows: ViewHierarchyWindow[];
};
5 changes: 2 additions & 3 deletions packages/core/src/utils/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,14 @@ export function getLocationHref(): string {
*
* @returns a string representation of the component for the provided DOM element, or `null` if not found
*/
export function getComponentName(elem: unknown): string | null {
export function getComponentName(elem: unknown, maxTraverseHeight: number = 5): string | null {
// @ts-expect-error WINDOW has HTMLElement
if (!WINDOW.HTMLElement) {
return null;
}

let currentElem = elem as SimpleNode;
const MAX_TRAVERSE_HEIGHT = 5;
for (let i = 0; i < MAX_TRAVERSE_HEIGHT; i++) {
for (let i = 0; i < maxTraverseHeight; i++) {
if (!currentElem) {
return null;
}
Expand Down
Loading