From 1c0b9cead6d77875a25e98982f66931bc65d6e29 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Sat, 11 Jan 2025 19:26:42 +0100 Subject: [PATCH] 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; + }, + }; +});