From 72784386fda9177e5b170f6ad0c2c1bd653c4efa Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 26 May 2026 14:51:16 +0200 Subject: [PATCH 1/6] WIP WIP --- .../browser-utils/src/htmlTreeAsString.ts | 124 ++++++++++++++++++ packages/browser-utils/src/index.ts | 4 + .../src/metrics/browserMetrics.ts | 2 +- packages/browser-utils/src/metrics/cls.ts | 2 +- packages/browser-utils/src/metrics/inp.ts | 2 +- packages/browser-utils/src/metrics/lcp.ts | 2 +- .../src/metrics/webVitalSpans.ts | 2 +- packages/browser-utils/src/object.ts | 86 ++++++++++++ .../test/htmlTreeAsString.test.ts | 51 +++++++ packages/browser-utils/test/object.test.ts | 88 +++++++++++++ packages/browser/src/eventbuilder.ts | 2 +- .../browser/src/integrations/breadcrumbs.ts | 2 +- packages/core/src/browser-exports.ts | 7 +- packages/core/src/utils/browser.ts | 2 + packages/core/src/utils/normalize.ts | 31 ++++- packages/core/src/utils/object.ts | 50 ++----- .../src/coreHandlers/handleDom.ts | 2 +- .../src/coreHandlers/handleKeyboardEvent.ts | 2 +- 18 files changed, 406 insertions(+), 55 deletions(-) create mode 100644 packages/browser-utils/src/htmlTreeAsString.ts create mode 100644 packages/browser-utils/src/object.ts create mode 100644 packages/browser-utils/test/htmlTreeAsString.test.ts create mode 100644 packages/browser-utils/test/object.test.ts 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..77348d7cb4d4 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 { convertToPlainObject, extractExceptionKeysForMessage } from './object'; + +export { htmlTreeAsString } from './htmlTreeAsString'; + export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; 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..ebe1dc7198ad 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, @@ -13,6 +12,7 @@ import { startInactiveSpan, timestampInSeconds, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; diff --git a/packages/browser-utils/src/object.ts b/packages/browser-utils/src/object.ts new file mode 100644 index 000000000000..90d419d5c900 --- /dev/null +++ b/packages/browser-utils/src/object.ts @@ -0,0 +1,86 @@ +import { isElement, isError, isEvent, isInstanceOf } from '@sentry/core'; +import { htmlTreeAsString } from './htmlTreeAsString'; + +/** + * Transforms any `Error` or `Event` into a plain object with all of their enumerable properties, and some of their + * non-enumerable properties attached. + * + * @param value Initial source that we have to transform in order for it to be usable by the serializer + * @returns An Event or Error turned into an object - or the value argument itself, when value is neither an Event nor + * an Error. + */ +export function convertToPlainObject(value: V): + | { + [ownProps: string]: unknown; + type: string; + target: string; + currentTarget: string; + detail?: unknown; + } + | { + [ownProps: string]: unknown; + message: string; + name: string; + stack?: string; + } + | V { + if (isError(value)) { + return { + message: value.message, + name: value.name, + stack: value.stack, + ...getOwnProperties(value), + }; + } else if (isEvent(value)) { + const newObj: { + [ownProps: string]: unknown; + type: string; + target: string; + currentTarget: string; + detail?: unknown; + } = { + type: value.type, + target: serializeEventTarget(value.target), + currentTarget: serializeEventTarget(value.currentTarget), + ...getOwnProperties(value), + }; + + if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) { + newObj.detail = value.detail; + } + + return newObj; + } else { + return value; + } +} + +function serializeEventTarget(target: unknown): string { + try { + return isElement(target) ? htmlTreeAsString(target) : Object.prototype.toString.call(target); + } catch { + return ''; + } +} + +function getOwnProperties(obj: unknown): { [key: string]: unknown } { + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries(Object.entries(obj)); + } + return {}; +} + +/** + * Given any captured exception, extract its keys and create a sorted + * and truncated list that will be used inside the event message. + * eg. `Non-error exception captured with keys: foo, bar, baz` + * + * The browser variant produces richer output for `Event` exceptions + * (e.g. extracting `type`, `target`, `currentTarget`). + */ +export function extractExceptionKeysForMessage(exception: Record): string { + const keys = Object.keys(convertToPlainObject(exception)); + keys.sort(); + + return !keys[0] ? '[object has no keys]' : keys.join(', '); +} diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts new file mode 100644 index 000000000000..d4690547a5c9 --- /dev/null +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, test } from 'vitest'; +import { htmlTreeAsString } from '../src/htmlTreeAsString'; + +describe('htmlTreeAsString()', () => { + test('returns for null/undefined', () => { + expect(htmlTreeAsString(null)).toBe(''); + expect(htmlTreeAsString(undefined)).toBe(''); + }); + + test('builds a query-selector style path for nested elements', () => { + document.body.innerHTML = '
'; + + const button = document.querySelector('button')!; + const result = htmlTreeAsString(button); + + // Includes the tag, attribute matches, and ancestors. + expect(result).toContain('button'); + expect(result).toContain('section.content'); + expect(result).toContain('div#root'); + expect(result.split(' > ').length).toBeGreaterThanOrEqual(2); + }); + + test('honors data-sentry-component', () => { + document.body.innerHTML = '
inner
'; + const span = document.querySelector('span')!; + const result = htmlTreeAsString(span); + + expect(result).toContain('MyComponent'); + }); + + test('respects keyAttrs option', () => { + document.body.innerHTML = ''; + const button = document.querySelector('button')!; + + const result = htmlTreeAsString(button, { keyAttrs: ['data-test-id'] }); + expect(result).toBe('button[data-test-id="submit"]'); + }); + + test('truncates output by maxStringLength', () => { + document.body.innerHTML = `
x
`; + const span = document.querySelector('span')!; + const result = htmlTreeAsString(span, { maxStringLength: 20 }); + + // The full traversal would be much longer; the limit forces an early bail-out. + expect(result.length).toBeLessThanOrEqual(120); // truncation is best-effort + }); +}); diff --git a/packages/browser-utils/test/object.test.ts b/packages/browser-utils/test/object.test.ts new file mode 100644 index 000000000000..8b44c107718e --- /dev/null +++ b/packages/browser-utils/test/object.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, test } from 'vitest'; +import { convertToPlainObject, extractExceptionKeysForMessage } from '../src/object'; + +describe('convertToPlainObject()', () => { + test('returns Error fields with own properties', () => { + const err = new Error('boom') as Error & { extra?: string }; + err.extra = 'value'; + err.stack = 'stack-trace'; + + expect(convertToPlainObject(err)).toEqual({ + message: 'boom', + name: 'Error', + stack: 'stack-trace', + extra: 'value', + }); + }); + + test('extracts type/target/currentTarget from a DOM Event using htmlTreeAsString', () => { + const root = document.createElement('div'); + root.id = 'root'; + const child = document.createElement('span'); + child.className = 'leaf'; + root.appendChild(child); + document.body.appendChild(root); + + const evt = new Event('click'); + Object.defineProperty(evt, 'target', { value: child }); + Object.defineProperty(evt, 'currentTarget', { value: root }); + + const result = convertToPlainObject(evt) as Record; + expect(result.type).toBe('click'); + // htmlTreeAsString walks ancestors, so we expect the leaf element path. + expect(result.target).toBe('span.leaf'); + expect(result.currentTarget).toBe('div#root'); + }); + + test('falls back to toString for non-Element targets', () => { + const evt = new Event('foo'); + Object.defineProperty(evt, 'target', { value: { not: 'an element' } }); + Object.defineProperty(evt, 'currentTarget', { value: 42 }); + + const result = convertToPlainObject(evt) as Record; + expect(result.target).toBe('[object Object]'); + expect(result.currentTarget).toBe('[object Number]'); + }); + + test('extracts detail from CustomEvent', () => { + const evt = new CustomEvent('bar', { detail: { payload: 1 } }); + + const result = convertToPlainObject(evt) as Record; + expect(result.type).toBe('bar'); + expect(result.detail).toEqual({ payload: 1 }); + }); + + test('passes other values through untouched', () => { + expect(convertToPlainObject('string')).toBe('string'); + expect(convertToPlainObject(42)).toBe(42); + expect(convertToPlainObject(null)).toBe(null); + expect(convertToPlainObject({ foo: 'bar' })).toEqual({ foo: 'bar' }); + }); +}); + +describe('extractExceptionKeysForMessage()', () => { + test('returns placeholder for empty objects', () => { + expect(extractExceptionKeysForMessage({})).toBe('[object has no keys]'); + }); + + test('returns sorted joined keys for plain objects', () => { + expect(extractExceptionKeysForMessage({ b: 1, a: 2, c: 3 })).toBe('a, b, c'); + }); + + test('includes Event-specific keys for DOM Events', () => { + const evt = new Event('foo'); + Object.defineProperty(evt, 'target', { value: null }); + Object.defineProperty(evt, 'currentTarget', { value: null }); + + // Because convertToPlainObject (browser variant) extracts type/target/currentTarget, + // we get those keys instead of just whatever's enumerable on Event. + const keys = extractExceptionKeysForMessage(evt as unknown as Record); + expect(keys).toContain('type'); + expect(keys).toContain('target'); + expect(keys).toContain('currentTarget'); + }); +}); diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index cb489980527f..751187880242 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -1,3 +1,4 @@ +import { extractExceptionKeysForMessage } from '@sentry-internal/browser-utils'; import type { Event, EventHint, @@ -11,7 +12,6 @@ import { _INTERNAL_enhanceErrorWithSentryInfo, addExceptionMechanism, addExceptionTypeValue, - extractExceptionKeysForMessage, getClient, isDOMError, isDOMException, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 7378ffc7c377..e8488d8b83c7 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -25,7 +25,6 @@ import { getClient, getComponentName, getEventDescription, - htmlTreeAsString, parseUrl, safeJoin, severityLevelFromString, @@ -35,6 +34,7 @@ import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler, addXhrInstrumentationHandler, + htmlTreeAsString, SENTRY_XHR_DATA_KEY, } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; diff --git a/packages/core/src/browser-exports.ts b/packages/core/src/browser-exports.ts index 42cf373a125a..4b013834edec 100644 --- a/packages/core/src/browser-exports.ts +++ b/packages/core/src/browser-exports.ts @@ -3,7 +3,12 @@ * * @module */ -export { getComponentName, getLocationHref, htmlTreeAsString } from './utils/browser'; +export { + getComponentName, + getLocationHref, + // eslint-disable-next-line deprecation/deprecation + htmlTreeAsString, +} from './utils/browser'; export { supportsDOMError, supportsHistory, supportsNativeFetch, supportsReportingObserver } from './utils/supports'; export type { XhrBreadcrumbData, XhrBreadcrumbHint } from './types/breadcrumb'; export type { diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts index 9237af237ba2..e3d88c0393f4 100644 --- a/packages/core/src/utils/browser.ts +++ b/packages/core/src/utils/browser.ts @@ -14,6 +14,8 @@ type SimpleNode = { * and its ancestors * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] * @returns generated DOM path + * @deprecated This is browser-specific and will be removed from `@sentry/core` in a future major version. + * Import `htmlTreeAsString` from `@sentry-internal/browser-utils` instead. */ export function htmlTreeAsString( elem: unknown, diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 8b012a8563cb..66bb6b139090 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -141,9 +141,9 @@ function visit( const normalized = (Array.isArray(value) ? [] : {}) as ObjOrArray; let numAdded = 0; - // Before we begin, convert`Error` and`Event` instances into plain objects, since some of each of their relevant - // properties are non-enumerable and otherwise would get missed. - const visitable = convertToPlainObject(value as ObjOrArray); + // Before we begin, convert `Error` and (browser) `Event` instances into plain objects, since some of each of + // their relevant properties are non-enumerable and otherwise would get missed. + const visitable = unpackEventForNormalize(value) ?? (convertToPlainObject(value) as ObjOrArray); for (const visitKey in visitable) { // Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration. @@ -322,3 +322,28 @@ function memoBuilder(): MemoFunc { } return [memoize, unmemoize]; } + +/** + * Unpacks a DOM `Event` (when present) into a plain object so its non-enumerable getters + * (`type`, `target`, `currentTarget`, `detail`) reach the normalized output. + * + * No DOM-walking helpers are used here — for richer browser representations (e.g. element + * paths via `htmlTreeAsString`) callers should use `convertToPlainObject` from + * `@sentry-internal/browser-utils`. But here, we cannot use this because we need to be able to handle both browser and Node.js events. + * As a middle ground, this is a minimal implementation that is runtime agnostic. + */ +function unpackEventForNormalize(value: unknown): ObjOrArray | undefined { + if (typeof Event === 'undefined' || !(value instanceof Event)) { + return undefined; + } + const result: ObjOrArray = { + type: value.type, + target: Object.prototype.toString.call(value.target), + currentTarget: Object.prototype.toString.call(value.currentTarget), + ...Object.fromEntries(Object.entries(value)), + }; + if (typeof CustomEvent !== 'undefined' && value instanceof CustomEvent) { + result.detail = value.detail; + } + return result; +} diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index e20a9d07818b..fae4876f251d 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { DEBUG_BUILD } from '../debug-build'; import type { WrappedFunction } from '../types/wrappedfunction'; -import { htmlTreeAsString } from './browser'; import { debug } from './debug-logger'; -import { isElement, isError, isEvent, isInstanceOf, isPrimitive } from './is'; +import { isError, isPrimitive } from './is'; /** * Replace a method in an object with a wrapped version of itself. @@ -125,21 +124,17 @@ export function getOriginalFunction(func: WrappedFunction } /** - * Transforms any `Error` or `Event` into a plain object with all of their enumerable properties, and some of their + * Transforms any `Error` into a plain object with all of its enumerable properties, and some of its * non-enumerable properties attached. * + * Note: This is the runtime-agnostic variant. For browser-specific handling that also unpacks DOM `Event` + * instances (extracting `type`/`target`/`currentTarget`), use `convertToPlainObject` from + * `@sentry-internal/browser-utils` instead. + * * @param value Initial source that we have to transform in order for it to be usable by the serializer - * @returns An Event or Error turned into an object - or the value argument itself, when value is neither an Event nor - * an Error. + * @returns An Error turned into an object - or the value argument itself, when value is not an Error. */ export function convertToPlainObject(value: V): - | { - [ownProps: string]: unknown; - type: string; - target: string; - currentTarget: string; - detail?: unknown; - } | { [ownProps: string]: unknown; message: string; @@ -154,37 +149,8 @@ export function convertToPlainObject(value: V): stack: value.stack, ...getOwnProperties(value), }; - } else if (isEvent(value)) { - const newObj: { - [ownProps: string]: unknown; - type: string; - target: string; - currentTarget: string; - detail?: unknown; - } = { - type: value.type, - target: serializeEventTarget(value.target), - currentTarget: serializeEventTarget(value.currentTarget), - ...getOwnProperties(value), - }; - - if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) { - newObj.detail = value.detail; - } - - return newObj; - } else { - return value; - } -} - -/** Creates a string representation of the target of an `Event` object */ -function serializeEventTarget(target: unknown): string { - try { - return isElement(target) ? htmlTreeAsString(target) : Object.prototype.toString.call(target); - } catch { - return ''; } + return value; } /** Filters out all but an object's own properties */ diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index ffe5dbc9096f..f362d94b2e42 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -1,5 +1,5 @@ import type { Breadcrumb, HandlerDataDom } from '@sentry/core'; -import { htmlTreeAsString } from '@sentry/core'; +import { htmlTreeAsString } from '@sentry-internal/browser-utils'; import { record } from '@sentry-internal/rrweb'; import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry-internal/rrweb-snapshot'; import { NodeType } from '@sentry-internal/rrweb-snapshot'; diff --git a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts index d2969b10a333..08a6d7458938 100644 --- a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts @@ -1,5 +1,5 @@ import type { Breadcrumb } from '@sentry/core'; -import { htmlTreeAsString } from '@sentry/core'; +import { htmlTreeAsString } from '@sentry-internal/browser-utils'; import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { getBaseDomBreadcrumb } from './handleDom'; From d937a16fd5a3d2a1f4e99a7aa3bc3d8d4d94e382 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 26 May 2026 15:25:32 +0200 Subject: [PATCH 2/6] WIP more changes --- .../test/htmlTreeAsString.test.ts | 5 ++- .../browser-utils/test/metrics/cls.test.ts | 12 ++++-- .../browser-utils/test/metrics/lcp.test.ts | 8 +++- .../test/metrics/webVitalSpans.test.ts | 14 ++++--- packages/browser-utils/test/object.test.ts | 6 +-- .../core/test/lib/utils/normalize.test.ts | 41 ++++++++++++++----- 6 files changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts index d4690547a5c9..458ef61080a5 100644 --- a/packages/browser-utils/test/htmlTreeAsString.test.ts +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -12,7 +12,8 @@ describe('htmlTreeAsString()', () => { }); test('builds a query-selector style path for nested elements', () => { - document.body.innerHTML = '
'; + document.body.innerHTML = + '
'; const button = document.querySelector('button')!; const result = htmlTreeAsString(button); @@ -37,7 +38,7 @@ describe('htmlTreeAsString()', () => { const button = document.querySelector('button')!; const result = htmlTreeAsString(button, { keyAttrs: ['data-test-id'] }); - expect(result).toBe('button[data-test-id="submit"]'); + expect(result).toContain('button[data-test-id="submit"]'); }); test('truncates output by maxStringLength', () => { 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('
'; + const button = document.querySelector('button')!; + const root = document.querySelector('#root')!; + + const evt = new Event('click'); + Object.defineProperty(evt, 'target', { value: button }); + Object.defineProperty(evt, 'currentTarget', { value: root }); + + const result = normalize(evt); + expect(result.type).toBe('click'); + // htmlTreeAsString output — selector-style path, not `[object HTMLButtonElement]`. + expect(result.target).toContain('button.action'); + expect(result.currentTarget).toContain('div#root'); + }); + + test('falls back to `[object …]` for non-Element targets', () => { + const evt = new Event('foo'); + Object.defineProperty(evt, 'target', { value: { not: 'an element' } }); + Object.defineProperty(evt, 'currentTarget', { value: null }); + + const result = normalize(evt); + expect(result.target).toBe('[object Object]'); + expect(result.currentTarget).toBe('[object Null]'); + }); + + test('extracts detail from CustomEvent', () => { + const evt = new CustomEvent('bar', { detail: { payload: 42 } }); + + const result = normalize(evt); + expect(result.type).toBe('bar'); + expect(result.detail).toEqual({ payload: 42 }); + }); + + test('preserves enumerable own properties on the Event', () => { + const evt = new Event('foo') as Event & { extraInfo: string }; + evt.extraInfo = 'meta'; + + const result = normalize(evt); + expect(result.type).toBe('foo'); + expect(result.extraInfo).toBe('meta'); + }); + + test('does not interfere with non-Event values', () => { + expect(normalize({ foo: 'bar' })).toEqual({ foo: 'bar' }); + expect(normalize('string')).toBe('string'); + expect(normalize(42)).toBe(42); + expect(normalize(null)).toBe(null); + }); + + test('is idempotent — registering twice still registers a single handler', () => { + // beforeEach already registered once; register again. + const secondUnregister = registerDomEventNormalizer(); + + const result = normalize(new Event('foo')); + expect(result.type).toBe('foo'); + + // Now unregister once via the returned function; the handler should fully detach. + secondUnregister(); + const after = normalize(new Event('foo')); + // Without the handler, normalize falls back to default Event behavior (just enumerable getters). + expect(after.type).toBeUndefined(); + + // Restore for afterEach cleanup symmetry — register again so the outer unregister has work to do. + unregister = registerDomEventNormalizer(); + }); +}); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 8d0242d82cbf..99b5ff1f4495 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -1,3 +1,4 @@ +import { registerDomEventNormalizer } from '@sentry-internal/browser-utils'; import type { BrowserClientProfilingOptions, BrowserClientReplayOptions, @@ -100,6 +101,12 @@ export class BrowserClient extends Client { }; } + // Plug a DOM-aware unpacker into core's `normalize()` so DOM Events captured as + // extras / contexts / `__serialized__` keep `target`/`currentTarget` represented as + // CSS-selector-style paths instead of `[object HTMLButtonElement]`. + // Idempotent — calling more than once is a no-op. + registerDomEventNormalizer(); + super(opts); const { diff --git a/packages/browser/test/client.test.ts b/packages/browser/test/client.test.ts index 9861c9e428ec..852ec7fc7613 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -77,6 +77,18 @@ describe('BrowserClient', () => { expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client); }); }); + + describe('normalize() DOM event unpacker', () => { + it('is registered when a BrowserClient is constructed', () => { + // Before constructing a client, normalize() does not special-case DOM Events. + // After constructing one, the browser-utils unpacker is in place and the Event's + // `type` flows through normalize. + new BrowserClient(getDefaultBrowserClientOptions({})); + + const normalized = sentryCore.normalize(new Event('click')); + expect(normalized.type).toBe('click'); + }); + }); }); describe('applyDefaultOptions', () => { diff --git a/packages/browser/test/eventbuilder.test.ts b/packages/browser/test/eventbuilder.test.ts index 7b5df4fb1c19..7f72b457b6cc 100644 --- a/packages/browser/test/eventbuilder.test.ts +++ b/packages/browser/test/eventbuilder.test.ts @@ -2,11 +2,22 @@ * @vitest-environment jsdom */ +import { registerDomEventNormalizer } from '@sentry-internal/browser-utils'; import { addNonEnumerableProperty } from '@sentry/core/browser'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { defaultStackParser } from '../src'; import { eventFromMessage, eventFromUnknownInput, extractMessage, extractType } from '../src/eventbuilder'; +// BrowserClient normally registers this — these tests use eventFromUnknownInput directly so we +// mirror that setup explicitly to exercise the full DOM-aware normalization path. +let unregisterDomEventNormalizer: () => void; +beforeAll(() => { + unregisterDomEventNormalizer = registerDomEventNormalizer(); +}); +afterAll(() => { + unregisterDomEventNormalizer(); +}); + vi.mock('@sentry/core/browser', async requireActual => { return { ...((await requireActual()) as any), diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 7d36db3dd168..e46dca554ded 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -247,7 +247,8 @@ export { parseSemver, uuid4, } from './utils/misc'; -export { normalize, normalizeToSize, normalizeUrlToBase } from './utils/normalize'; +export { addNormalizeUnpacker, normalize, normalizeToSize, normalizeUrlToBase } from './utils/normalize'; +export type { NormalizeUnpacker } from './utils/normalize'; export { setNormalizationDepthOverrideHint, setSkipNormalizationHint } from './utils/normalizationHints'; export { addNonEnumerableProperty, diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 66bb6b139090..c19208d484d3 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -141,9 +141,11 @@ function visit( const normalized = (Array.isArray(value) ? [] : {}) as ObjOrArray; let numAdded = 0; - // Before we begin, convert `Error` and (browser) `Event` instances into plain objects, since some of each of - // their relevant properties are non-enumerable and otherwise would get missed. - const visitable = unpackEventForNormalize(value) ?? (convertToPlainObject(value) as ObjOrArray); + // Before we begin, convert `Error` instances into plain objects so their non-enumerable + // properties (`message`, `name`, `stack`) reach the normalized output. Other instance types + // can be unpacked here too via registered unpackers (see `addNormalizeUnpacker`) — e.g. + // browser SDKs register a DOM `Event` unpacker so `type`/`target`/`currentTarget` survive. + const visitable = runNormalizeUnpackers(value) ?? (convertToPlainObject(value) as ObjOrArray); for (const visitKey in visitable) { // Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration. @@ -324,26 +326,44 @@ function memoBuilder(): MemoFunc { } /** - * Unpacks a DOM `Event` (when present) into a plain object so its non-enumerable getters - * (`type`, `target`, `currentTarget`, `detail`) reach the normalized output. + * An unpacker takes an arbitrary value and either returns a plain object + * representation (whose keys will be visited recursively during normalization) + * or `undefined` to defer to the next unpacker / default behavior. + */ +export type NormalizeUnpacker = (value: unknown) => Record | undefined; + +const normalizeUnpackers: NormalizeUnpacker[] = []; + +/** + * Register an unpacker that `normalize()` will consult for every visited object value. + * + * The first registered unpacker that returns a non-`undefined` object wins, and the + * returned object replaces the original value as the visited target — so its keys + * (including non-enumerable getters extracted by the unpacker) flow into the + * normalized output. + * + * This is the mechanism browser SDKs use to register a DOM `Event` unpacker that + * extracts `type`/`target`/`currentTarget`/`detail` with full fidelity (via + * `htmlTreeAsString`) — keeping core itself free of any DOM dependencies. * - * No DOM-walking helpers are used here — for richer browser representations (e.g. element - * paths via `htmlTreeAsString`) callers should use `convertToPlainObject` from - * `@sentry-internal/browser-utils`. But here, we cannot use this because we need to be able to handle both browser and Node.js events. - * As a middle ground, this is a minimal implementation that is runtime agnostic. + * Returns a function that unregisters the unpacker when called. */ -function unpackEventForNormalize(value: unknown): ObjOrArray | undefined { - if (typeof Event === 'undefined' || !(value instanceof Event)) { - return undefined; - } - const result: ObjOrArray = { - type: value.type, - target: Object.prototype.toString.call(value.target), - currentTarget: Object.prototype.toString.call(value.currentTarget), - ...Object.fromEntries(Object.entries(value)), +export function addNormalizeUnpacker(unpacker: NormalizeUnpacker): () => void { + normalizeUnpackers.push(unpacker); + return () => { + const index = normalizeUnpackers.indexOf(unpacker); + if (index !== -1) { + normalizeUnpackers.splice(index, 1); + } }; - if (typeof CustomEvent !== 'undefined' && value instanceof CustomEvent) { - result.detail = value.detail; +} + +function runNormalizeUnpackers(value: unknown): Record | undefined { + for (const unpacker of normalizeUnpackers) { + const result = unpacker(value); + if (result !== undefined) { + return result; + } } - return result; + return undefined; } diff --git a/packages/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index c566e3d2ec4b..0db6a2901f91 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -2,8 +2,13 @@ * @vitest-environment jsdom */ -import { describe, expect, test, vi } from 'vitest'; -import { normalize, setNormalizationDepthOverrideHint, setSkipNormalizationHint } from '../../../src'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + addNormalizeUnpacker, + normalize, + setNormalizationDepthOverrideHint, + setSkipNormalizationHint, +} from '../../../src'; import * as stacktraceModule from '../../../src/utils/stacktrace'; describe('normalize()', () => { @@ -48,50 +53,70 @@ describe('normalize()', () => { }); }); - test('extracts data from `Event` objects', () => { - // Note: normalize unpacks the basic Event fields (type/target/currentTarget) but does *not* - // walk the DOM tree — that richer representation is provided by - // `@sentry-internal/browser-utils`'s `convertToPlainObject`. - const parkElement = { tagName: 'PARK' }; - const squirrelElement = { tagName: 'SQUIRREL', parentNode: { tagName: 'TREE', parentNode: parkElement } }; + test('does not unpack DOM Event instances by default', () => { + // Core stays runtime-agnostic — it does not special-case DOM `Event` objects. + // Browser SDKs register a `domEventNormalizer` to extract type/target/currentTarget + // with full fidelity; without that registration, normalize just walks whatever + // enumerable properties the Event happens to expose. + const evt = new Event('chase'); + expect(normalize(evt)).toEqual({ isTrusted: false }); + }); + }); - const chaseEvent = new Event('chase'); - Object.defineProperty(chaseEvent, 'target', { value: squirrelElement }); - Object.defineProperty(chaseEvent, 'currentTarget', { value: parkElement }); - Object.defineProperty(chaseEvent, 'wagging', { value: true, enumerable: false }); + describe('addNormalizeUnpacker()', () => { + afterEach(() => { + // Each test registers its own unpacker and tears it down via the returned unregister. + }); - expect(normalize(chaseEvent)).toEqual({ - currentTarget: '[object Object]', - isTrusted: false, - target: '[object Object]', - type: 'chase', - // notice that `wagging` isn't included because it's not enumerable and not one of the ones we specifically extract - }); + test('lets a registered unpacker replace the visited object', () => { + const unregister = addNormalizeUnpacker(value => + value instanceof Event ? { kind: 'event', type: value.type } : undefined, + ); + try { + expect(normalize(new Event('click'))).toEqual({ kind: 'event', type: 'click' }); + } finally { + unregister(); + } }); - test('preserves enumerable own properties on `Event` objects', () => { - const evt = new Event('custom-event') as Event & { extraInfo: string }; - evt.extraInfo = 'meta'; + test('skips the unpacker when it returns undefined', () => { + const unregister = addNormalizeUnpacker(() => undefined); + try { + expect(normalize({ foo: 'bar' })).toEqual({ foo: 'bar' }); + } finally { + unregister(); + } + }); - expect(normalize(evt)).toEqual({ - currentTarget: '[object Null]', - isTrusted: false, - target: '[object Null]', - type: 'custom-event', - extraInfo: 'meta', - }); + test('first registered unpacker wins for a given value', () => { + const unregisterA = addNormalizeUnpacker(value => (value instanceof Event ? { from: 'A' } : undefined)); + const unregisterB = addNormalizeUnpacker(value => (value instanceof Event ? { from: 'B' } : undefined)); + try { + expect(normalize(new Event('foo'))).toEqual({ from: 'A' }); + } finally { + unregisterA(); + unregisterB(); + } }); - test('extracts `detail` from `CustomEvent` instances', () => { - const evt = new CustomEvent('foo', { detail: { payload: 42 } }); + test('removing the winning unpacker falls back to the next one', () => { + const unregisterA = addNormalizeUnpacker(value => (value instanceof Event ? { from: 'A' } : undefined)); + const unregisterB = addNormalizeUnpacker(value => (value instanceof Event ? { from: 'B' } : undefined)); + try { + expect(normalize(new Event('foo'))).toEqual({ from: 'A' }); + unregisterA(); + expect(normalize(new Event('foo'))).toEqual({ from: 'B' }); + } finally { + unregisterB(); + } + }); - expect(normalize(evt)).toEqual({ - currentTarget: '[object Null]', - detail: { payload: 42 }, - isTrusted: false, - target: '[object Null]', - type: 'foo', - }); + test('unregister removes only the specified unpacker', () => { + const unregister = addNormalizeUnpacker(value => (value instanceof Event ? { from: 'A' } : undefined)); + expect(normalize(new Event('foo'))).toEqual({ from: 'A' }); + unregister(); + // Default behavior restored. + expect(normalize(new Event('foo'))).toEqual({ isTrusted: false }); }); }); From 236d061298c32ef2c477f9ddcae436417dc4bdd3 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 26 May 2026 15:45:06 +0200 Subject: [PATCH 4/6] small dedupe --- packages/browser-utils/src/domEventNormalizer.ts | 12 ++---------- packages/browser-utils/src/object.ts | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/browser-utils/src/domEventNormalizer.ts b/packages/browser-utils/src/domEventNormalizer.ts index 6feec469e22b..a64d8eb496cc 100644 --- a/packages/browser-utils/src/domEventNormalizer.ts +++ b/packages/browser-utils/src/domEventNormalizer.ts @@ -1,5 +1,5 @@ -import { addNormalizeUnpacker, isElement, isInstanceOf } from '@sentry/core'; -import { htmlTreeAsString } from './htmlTreeAsString'; +import { addNormalizeUnpacker, isInstanceOf } from '@sentry/core'; +import { serializeEventTarget } from './object'; let unregister: (() => void) | undefined; @@ -39,11 +39,3 @@ export function registerDomEventNormalizer(): () => void { unregister = undefined; }; } - -function serializeEventTarget(target: unknown): string { - try { - return isElement(target) ? htmlTreeAsString(target) : Object.prototype.toString.call(target); - } catch { - return ''; - } -} diff --git a/packages/browser-utils/src/object.ts b/packages/browser-utils/src/object.ts index 90d419d5c900..625db28559cd 100644 --- a/packages/browser-utils/src/object.ts +++ b/packages/browser-utils/src/object.ts @@ -55,7 +55,7 @@ export function convertToPlainObject(value: V): } } -function serializeEventTarget(target: unknown): string { +export function serializeEventTarget(target: unknown): string { try { return isElement(target) ? htmlTreeAsString(target) : Object.prototype.toString.call(target); } catch { From f3118c4d0831251cf26a98ba79e54866e715ea7f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 26 May 2026 15:47:48 +0200 Subject: [PATCH 5/6] small cleanup --- packages/core/src/utils/normalize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index c19208d484d3..412b9578b232 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -361,7 +361,7 @@ export function addNormalizeUnpacker(unpacker: NormalizeUnpacker): () => void { function runNormalizeUnpackers(value: unknown): Record | undefined { for (const unpacker of normalizeUnpackers) { const result = unpacker(value); - if (result !== undefined) { + if (result) { return result; } } From 3ed69bb24c3b5af11c5ed386aa3940191f7c3217 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 26 May 2026 15:50:09 +0200 Subject: [PATCH 6/6] use core implementation --- packages/browser-utils/src/object.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/browser-utils/src/object.ts b/packages/browser-utils/src/object.ts index 625db28559cd..b9f2bcf53a36 100644 --- a/packages/browser-utils/src/object.ts +++ b/packages/browser-utils/src/object.ts @@ -1,4 +1,4 @@ -import { isElement, isError, isEvent, isInstanceOf } from '@sentry/core'; +import { isElement, isEvent, isInstanceOf, convertToPlainObject as originalConvertToPlainObject } from '@sentry/core'; import { htmlTreeAsString } from './htmlTreeAsString'; /** @@ -24,14 +24,7 @@ export function convertToPlainObject(value: V): stack?: string; } | V { - if (isError(value)) { - return { - message: value.message, - name: value.name, - stack: value.stack, - ...getOwnProperties(value), - }; - } else if (isEvent(value)) { + if (isEvent(value)) { const newObj: { [ownProps: string]: unknown; type: string; @@ -50,9 +43,9 @@ export function convertToPlainObject(value: V): } return newObj; - } else { - return value; } + + return originalConvertToPlainObject(value); } export function serializeEventTarget(target: unknown): string {