diff --git a/dev-packages/browser-integration-tests/suites/public-api/setContext/non_serializable_context/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setContext/non_serializable_context/test.ts index a622dacbbe5d..87bc8e128475 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setContext/non_serializable_context/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setContext/non_serializable_context/test.ts @@ -8,6 +8,6 @@ sentryTest('should normalize non-serializable context', async ({ getLocalTestUrl const eventData = await getFirstSentryEnvelopeRequest(page, url); - expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: HTMLBodyElement]'); + expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: body]'); expect(eventData.message).toBe('non_serializable'); }); diff --git a/dev-packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts index 90612770360b..a03cae493326 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts @@ -37,9 +37,9 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestUrl timestamp: expect.any(Number), type: 'default', category: 'console', - data: { arguments: ['Test log', '[HTMLElement: HTMLBodyElement]'], logger: 'console' }, + data: { arguments: ['Test log', '[HTMLElement: body]'], logger: 'console' }, level: 'log', - message: 'Test log [object HTMLBodyElement]', + message: 'Test log [HTMLElement: body]', }, ]), ); diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts new file mode 100644 index 000000000000..0eca02e60985 --- /dev/null +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -0,0 +1,124 @@ +import { isString } from '@sentry/core'; + +const DEFAULT_MAX_STRING_LENGTH = 80; + +type SimpleNode = { + parentNode: SimpleNode; +} | null; + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @returns generated DOM path + */ +export function htmlTreeAsString( + elem: unknown, + options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, +): string { + if (!elem) { + return ''; + } + + // try/catch both: + // - accessing event.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // - can throw an exception in some circumstances. + try { + let currentElem = elem as SimpleNode; + const MAX_TRAVERSE_HEIGHT = 5; + const out = []; + let height = 0; + let len = 0; + const separator = ' > '; + const sepLength = separator.length; + let nextStr; + const keyAttrs = Array.isArray(options) ? options : options.keyAttrs; + const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH; + + while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = _htmlElementAsString(currentElem, keyAttrs); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds maxStringLength + // (ignore this limit if we are on the first iteration) + if (nextStr === 'html' || (height > 1 && len + out.length * sepLength + nextStr.length >= maxStringLength)) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + currentElem = currentElem.parentNode; + } + + return out.reverse().join(separator); + } catch { + return ''; + } +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @returns generated DOM path + */ +function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { + const elem = el as { + tagName?: string; + id?: string; + className?: string; + getAttribute(key: string): string; + }; + + const out = []; + + if (!elem?.tagName) { + return ''; + } + + if (typeof HTMLElement !== 'undefined') { + // If using the component name annotation plugin, this value may be available on the DOM node + if (elem instanceof HTMLElement && elem.dataset) { + if (elem.dataset['sentryComponent']) { + return elem.dataset['sentryComponent']; + } + if (elem.dataset['sentryElement']) { + return elem.dataset['sentryElement']; + } + } + } + + out.push(elem.tagName.toLowerCase()); + + // Pairs of attribute keys defined in `serializeAttribute` and their values on element. + const keyAttrPairs = keyAttrs?.length + ? keyAttrs.filter(keyAttr => elem.getAttribute(keyAttr)).map(keyAttr => [keyAttr, elem.getAttribute(keyAttr)]) + : null; + + if (keyAttrPairs?.length) { + keyAttrPairs.forEach(keyAttrPair => { + out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`); + }); + } else { + if (elem.id) { + out.push(`#${elem.id}`); + } + + const className = elem.className; + if (className && isString(className)) { + const classes = className.split(/\s+/); + for (const c of classes) { + out.push(`.${c}`); + } + } + } + for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { + const attr = elem.getAttribute(k); + if (attr) { + out.push(`[${k}="${attr}"]`); + } + } + + return out.join(''); +} diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 888524ed7c21..d960109c42c8 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -34,4 +34,8 @@ export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrRespo export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; +export { htmlTreeAsString } from './htmlTreeAsString'; + +export { isElement } from './is'; + export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/is.ts b/packages/browser-utils/src/is.ts new file mode 100644 index 000000000000..38c25b72c83e --- /dev/null +++ b/packages/browser-utils/src/is.ts @@ -0,0 +1,15 @@ +/** + * Checks whether given value's type is an Element instance. + * + * Returns false if `Element` is not available in the current runtime. + */ +export function isElement(wat: unknown): boolean { + if (typeof Element === 'undefined') { + return false; + } + try { + return wat instanceof Element; + } catch { + return false; + } +} diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 76e853eef5d3..7fa40ee101cb 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -5,7 +5,6 @@ import { debug, getActiveSpan, getComponentName, - htmlTreeAsString, isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -13,6 +12,7 @@ import { spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import { trackClsAsStandaloneSpan } from './cls'; import { diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index d836ff315c06..4c09dde19c74 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -3,7 +3,6 @@ import { browserPerformanceTimeOrigin, debug, getCurrentScope, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, @@ -12,6 +11,7 @@ import { timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { addClsInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 3eb0b2920a75..158d4b4212c2 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -4,7 +4,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - htmlTreeAsString, isBrowser, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, @@ -13,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import type { InstrumentationHandlerCallback } from './instrument'; import { diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index c11f6bd63cbc..bcd065e94cf0 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -3,7 +3,6 @@ import { browserPerformanceTimeOrigin, debug, getCurrentScope, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, @@ -11,6 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { addLcpInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 6f6d8de3901e..1cb4854a3c18 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -5,7 +5,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -14,6 +13,7 @@ import { timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; import type { InstrumentationHandlerCallback } from './instrument'; diff --git a/packages/browser-utils/test/metrics/cls.test.ts b/packages/browser-utils/test/metrics/cls.test.ts index 55550d02f546..9a2c94da04d2 100644 --- a/packages/browser-utils/test/metrics/cls.test.ts +++ b/packages/browser-utils/test/metrics/cls.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import { _sendStandaloneClsSpan } from '../../src/metrics/cls'; import * as WebVitalUtils from '../../src/metrics/utils'; @@ -11,10 +12,13 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + describe('_sendStandaloneClsSpan', () => { const mockSpan = { addEvent: vi.fn(), @@ -35,7 +39,7 @@ describe('_sendStandaloneClsSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); }); @@ -136,14 +140,14 @@ describe('_sendStandaloneClsSpan', () => { }; const pageloadSpanId = '789'; - vi.mocked(SentryCore.htmlTreeAsString) + vi.mocked(htmlTreeAsString) .mockReturnValueOnce('
') // for the name .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); - expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3); + expect(htmlTreeAsString).toHaveBeenCalledTimes(3); expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ name: '
', transaction: 'test-transaction', diff --git a/packages/browser-utils/test/metrics/lcp.test.ts b/packages/browser-utils/test/metrics/lcp.test.ts index 634b9652f816..baa7cd5de052 100644 --- a/packages/browser-utils/test/metrics/lcp.test.ts +++ b/packages/browser-utils/test/metrics/lcp.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import { _sendStandaloneLcpSpan, isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; import * as WebVitalUtils from '../../src/metrics/utils'; @@ -9,10 +10,13 @@ vi.mock('@sentry/core', async () => { ...actual, browserPerformanceTimeOrigin: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + describe('isValidLcpMetric', () => { it('returns true for plausible lcp values', () => { expect(isValidLcpMetric(1)).toBe(true); @@ -43,7 +47,7 @@ describe('_sendStandaloneLcpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); }); diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 8b9325895e85..640a2ee95f71 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import * as inpModule from '../../src/metrics/inp'; import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; @@ -11,7 +12,6 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), startInactiveSpan: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), @@ -20,6 +20,10 @@ vi.mock('@sentry/core', async () => { }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + // Mock WINDOW vi.mock('../../src/types', () => ({ WINDOW: { @@ -210,7 +214,7 @@ describe('_sendLcpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, @@ -296,7 +300,7 @@ describe('_sendClsSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, @@ -324,7 +328,7 @@ describe('_sendClsSpan', () => { toJSON: vi.fn(), }; - vi.mocked(SentryCore.htmlTreeAsString) + vi.mocked(htmlTreeAsString) .mockReturnValueOnce('
') // for the name .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 @@ -377,7 +381,7 @@ describe('_sendInpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('