diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js
new file mode 100644
index 000000000000..16e92edb9230
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js
@@ -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()],
+});
diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js
new file mode 100644
index 000000000000..f7060a33f05c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js
@@ -0,0 +1 @@
+throw new Error('Some error');
diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html
new file mode 100644
index 000000000000..9e600d2a7e60
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Some title
+ Some text
+
+
diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts
new file mode 100644
index 000000000000..d3caf6ff9b3e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts
@@ -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(
+ 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);
+});
diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts
index 06c79931b880..28737c16a448 100644
--- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts
@@ -5,7 +5,7 @@ interface Env {
}
const myWorker = {
- async fetch(request: Request) {
+ async fetch(_: Request) {
return new Response('Hello from another worker!');
},
};
diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts
index 73415b509414..409695a05edd 100644
--- a/packages/browser/src/index.ts
+++ b/packages/browser/src/index.ts
@@ -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,
diff --git a/packages/browser/src/integrations/view-hierarchy.ts b/packages/browser/src/integrations/view-hierarchy.ts
new file mode 100644
index 000000000000..fa35ad7e00a2
--- /dev/null
+++ b/packages/browser/src/integrations/view-hierarchy.ts
@@ -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 | '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;
+ },
+ };
+});
diff --git a/packages/core/src/types-hoist/view-hierarchy.ts b/packages/core/src/types-hoist/view-hierarchy.ts
index a066bfbe42e6..453f8c7daca8 100644
--- a/packages/core/src/types-hoist/view-hierarchy.ts
+++ b/packages/core/src/types-hoist/view-hierarchy.ts
@@ -14,5 +14,6 @@ export type ViewHierarchyWindow = {
export type ViewHierarchyData = {
rendering_system: string;
+ positioning?: 'absolute' | 'relative';
windows: ViewHierarchyWindow[];
};
diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts
index 6c062f8f6f60..9237af237ba2 100644
--- a/packages/core/src/utils/browser.ts
+++ b/packages/core/src/utils/browser.ts
@@ -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;
}