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; }