From 1c0b9cead6d77875a25e98982f66931bc65d6e29 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Sat, 11 Jan 2025 19:26:42 +0100 Subject: [PATCH 1/2] feat(browser): Add browser View Hierarchy integration --- packages/browser/src/index.ts | 1 + .../src/integrations/view-hierarchy.ts | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 packages/browser/src/integrations/view-hierarchy.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 56c7dd449602..8d2cef356e3c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -69,3 +69,4 @@ export { } from './integrations/featureFlags'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; +export { viewHierarchyIntegration } from './integrations/view-hierarchy'; diff --git a/packages/browser/src/integrations/view-hierarchy.ts b/packages/browser/src/integrations/view-hierarchy.ts new file mode 100644 index 000000000000..36ad716fdb87 --- /dev/null +++ b/packages/browser/src/integrations/view-hierarchy.ts @@ -0,0 +1,115 @@ +import type { Attachment, Event, ViewHierarchyData, ViewHierarchyWindow } from '@sentry/core'; +import { defineIntegration, dropUndefinedKeys, 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; +} + +interface Options { + /** + * Whether to attach the view hierarchy to the event. + */ + shouldAttach?: (event: Event) => boolean; + + /** + * 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: { children: HTMLCollection }, windows: ViewHierarchyWindow[]): void { + for (const child of element.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + + const componentName = getComponentName(child) || undefined; + const tagName = child.tagName.toLowerCase(); + const result = options.onElement?.({ element: child, componentName, tagName }) || {}; + + // Skip this element and its children + if (skipHtmlTags.includes(tagName) || result === 'skip') { + continue; + } + + // Skip this element but include its children + if (result === 'children') { + walk('shadowRoot' in child && child.shadowRoot ? child.shadowRoot : child, windows); + continue; + } + + const childRect = child.getBoundingClientRect(); + + const window: ViewHierarchyWindow = dropUndefinedKeys({ + identifier: (child.id || undefined) as string, + type: componentName || tagName, + visible: true, + alpha: 1, + height: childRect.height, + width: childRect.width, + x: childRect.x, + y: childRect.y, + ...result, + }); + + const children: ViewHierarchyWindow[] = []; + window.children = children; + + // Recursively walk the children + walk('shadowRoot' in child && child.shadowRoot ? child.shadowRoot : child, window.children); + + windows.push(window); + } + } + + return { + name: 'ViewHierarchy', + processEvent: (event, hint) => { + if (options.shouldAttach && options.shouldAttach(event) === false) { + return event; + } + + const root: ViewHierarchyData = { + rendering_system: 'DOM', + windows: [], + }; + + walk(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; + }, + }; +}); From 16d91537dd06425333bd88a41bbf3f694704b519 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 11 Feb 2026 16:05:01 +0100 Subject: [PATCH 2/2] Changes after rcf merge --- packages/browser/src/index.ts | 1 + .../src/integrations/view-hierarchy.ts | 38 ++++++++++++------- .../core/src/types-hoist/view-hierarchy.ts | 1 + 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..a8038ae17869 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 index 36ad716fdb87..3ca8a0122fa5 100644 --- a/packages/browser/src/integrations/view-hierarchy.ts +++ b/packages/browser/src/integrations/view-hierarchy.ts @@ -1,5 +1,5 @@ import type { Attachment, Event, ViewHierarchyData, ViewHierarchyWindow } from '@sentry/core'; -import { defineIntegration, dropUndefinedKeys, getComponentName } from '@sentry/core'; +import { defineIntegration, getComponentName } from '@sentry/core'; import { WINDOW } from '../helpers'; interface OnElementArgs { @@ -20,6 +20,8 @@ interface OnElementArgs { interface Options { /** * Whether to attach the view hierarchy to the event. + * + * Default: Always attach. */ shouldAttach?: (event: Event) => boolean; @@ -40,46 +42,53 @@ export const viewHierarchyIntegration = defineIntegration((options: Options = {} const skipHtmlTags = ['script']; /** Walk an element */ - function walk(element: { children: HTMLCollection }, windows: ViewHierarchyWindow[]): void { - for (const child of element.children) { + function walk(element: HTMLElement, windows: ViewHierarchyWindow[]): void { + // With Web Components, we need 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) || undefined; const tagName = child.tagName.toLowerCase(); + + if (skipHtmlTags.includes(tagName)) { + continue; + } + const result = options.onElement?.({ element: child, componentName, tagName }) || {}; - // Skip this element and its children - if (skipHtmlTags.includes(tagName) || result === 'skip') { + if (result === 'skip') { continue; } // Skip this element but include its children if (result === 'children') { - walk('shadowRoot' in child && child.shadowRoot ? child.shadowRoot : child, windows); + walk(child, windows); continue; } - const childRect = child.getBoundingClientRect(); + const { x, y, width, height } = child.getBoundingClientRect(); - const window: ViewHierarchyWindow = dropUndefinedKeys({ + const window: ViewHierarchyWindow = { identifier: (child.id || undefined) as string, type: componentName || tagName, visible: true, alpha: 1, - height: childRect.height, - width: childRect.width, - x: childRect.x, - y: childRect.y, + height, + width, + x, + y, ...result, - }); + }; const children: ViewHierarchyWindow[] = []; window.children = children; // Recursively walk the children - walk('shadowRoot' in child && child.shadowRoot ? child.shadowRoot : child, window.children); + walk(child, window.children); windows.push(window); } @@ -94,6 +103,7 @@ export const viewHierarchyIntegration = defineIntegration((options: Options = {} const root: ViewHierarchyData = { rendering_system: 'DOM', + positioning: 'absolute', windows: [], }; 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[]; };