From 65175e3c8c8f4e8ee922c09ecf2432f0c124393d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 09:05:28 +0200 Subject: [PATCH 01/19] ref 1 --- packages/core/src/utils/normalize.ts | 7 ++- packages/core/src/utils/object.ts | 57 +++++-------------- .../core/test/lib/utils/normalize.test.ts | 33 ++++++++--- 3 files changed, 42 insertions(+), 55 deletions(-) diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 853afc7df753..65add9ae18a1 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,5 +1,6 @@ import type { Primitive } from '../types/misc'; -import { isSyntheticEvent, isVueViewModel } from './is'; +import { htmlTreeAsString } from './browser'; +import { isElement, isSyntheticEvent, isVueViewModel } from './is'; import { getNormalizationDepthOverrideHint, hasSkipNormalizationHint } from './normalizationHints'; import { convertToPlainObject } from './object'; import { getFunctionName, getVueInternalName } from './stacktrace'; @@ -237,8 +238,8 @@ function stringifyValue( const objName = getConstructorName(value); // Handle HTML Elements - if (/^HTML(\w*)Element$/.test(objName)) { - return `[HTMLElement: ${objName}]`; + if (isElement(value) && /^HTML(\w*)Element$/.test(objName)) { + return `[HTMLElement: ${htmlTreeAsString(value)}]`; } return `[object ${objName}]`; diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index e20a9d07818b..2f324097ac57 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, isEvent, isPrimitive } from './is'; /** * Replace a method in an object with a wrapped version of itself. @@ -132,21 +131,7 @@ export function getOriginalFunction(func: WrappedFunction * @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 { +export function convertToPlainObject(value: V): Record | V { if (isError(value)) { return { message: value.message, @@ -154,37 +139,21 @@ 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), + } + + // This handles browser events specifically, where certain properties are non-enumerable and need to be unpacked. + if (isEvent(value)) { + const { type, target, currentTarget, detail } = value; + return { + type, + target, + currentTarget, + ...(detail ? { detail } : {}), ...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/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index b296a8766f4f..6ec635997e60 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -63,10 +63,24 @@ describe('normalize()', () => { Object.defineProperty(chaseEvent, 'wagging', { value: true, enumerable: false }); expect(normalize(chaseEvent)).toEqual({ - currentTarget: 'park', - isTrusted: false, - target: 'park > tree > squirrel', type: 'chase', + target: { + tagName: 'SQUIRREL', + parentNode: { + tagName: 'TREE', + parentNode: { + tagName: 'PARK', + getAttribute: '[Function: getAttribute]', + }, + getAttribute: '[Function: getAttribute]', + }, + getAttribute: '[Function: getAttribute]', + }, + currentTarget: { + tagName: 'PARK', + getAttribute: '[Function: getAttribute]', + }, + isTrusted: false, // notice that `wagging` isn't included because it's not enumerable and not one of the ones we specifically extract }); @@ -323,14 +337,17 @@ describe('normalize()', () => { describe('handles HTML elements', () => { test('HTMLDivElement', () => { + const div2 = document.createElement('div'); + div2.setAttribute('data-test-id', 'div2'); + div2.classList.add('container'); expect( normalize({ div: document.createElement('div'), - div2: document.createElement('div'), + div2, }), ).toEqual({ - div: '[HTMLElement: HTMLDivElement]', - div2: '[HTMLElement: HTMLDivElement]', + div: '[HTMLElement: div]', + div2: '[HTMLElement: div.container]', }); }); @@ -341,8 +358,8 @@ describe('normalize()', () => { select: document.createElement('select'), }), ).toEqual({ - input: '[HTMLElement: HTMLInputElement]', - select: '[HTMLElement: HTMLSelectElement]', + input: '[HTMLElement: input]', + select: '[HTMLElement: select]', }); }); }); From d7487ec41d159e8a39a8bf8b737c757cb8f32dd6 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 10:18:59 +0200 Subject: [PATCH 02/19] move stuff to async context --- packages/browser-utils/src/index.ts | 2 + .../src/normalizeStringifyValue.ts | 44 ++++++ .../test/normalizeStringifyValue.test.ts | 80 ++++++++++ packages/browser/src/sdk.ts | 9 ++ packages/browser/test/eventbuilder.test.ts | 8 +- packages/core/src/asyncContext/types.ts | 12 ++ packages/core/src/shared-exports.ts | 3 +- packages/core/src/utils/normalize.ts | 57 +++---- .../core/test/lib/utils/normalize.test.ts | 144 +++++++----------- packages/react/src/sdk.ts | 25 ++- packages/react/test/normalize.test.ts | 61 ++++++++ packages/vue/src/sdk.ts | 25 ++- .../vue/test/integration/normalize.test.ts | 59 +++++++ 13 files changed, 402 insertions(+), 127 deletions(-) create mode 100644 packages/browser-utils/src/normalizeStringifyValue.ts create mode 100644 packages/browser-utils/test/normalizeStringifyValue.test.ts create mode 100644 packages/react/test/normalize.test.ts create mode 100644 packages/vue/test/integration/normalize.test.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 888524ed7c21..6cd357b02d4f 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -34,4 +34,6 @@ export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrRespo export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; +export { normalizeStringifyValue } from './normalizeStringifyValue'; + export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/normalizeStringifyValue.ts b/packages/browser-utils/src/normalizeStringifyValue.ts new file mode 100644 index 000000000000..74388446c970 --- /dev/null +++ b/packages/browser-utils/src/normalizeStringifyValue.ts @@ -0,0 +1,44 @@ +import { htmlTreeAsString, isElement } from '@sentry/core'; + +type Prototype = { constructor?: (...args: unknown[]) => unknown }; + +const HTML_ELEMENT_CONSTRUCTOR_NAME_REGEX = /^HTML(\w*)Element$/; + +/** + * Browser-specific contributions to `normalize()`'s `stringifyValue`. Plug into the + * async-context strategy from the browser SDK so DOM values get a useful string + * representation without forcing core to carry the DOM-specific code. + * + * Handles: + * - `window` → `[Window]` + * - `document` → `[Document]` + * - `HTMLElement` subclasses → `[HTMLElement: ]` (via `htmlTreeAsString`) + * + * Vue ViewModels and React SyntheticEvents are not handled here — the Vue and React + * SDKs wrap this function in their `init` and add their own checks on top. + */ +export function normalizeStringifyValue(value: Exclude): string | undefined { + // oxlint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined' && value === window) { + return '[Window]'; + } + // oxlint-disable-next-line no-restricted-globals + if (typeof document !== 'undefined' && value === document) { + return '[Document]'; + } + + if (isElement(value)) { + const objName = getConstructorName(value); + if (HTML_ELEMENT_CONSTRUCTOR_NAME_REGEX.test(objName)) { + return `[HTMLElement: ${htmlTreeAsString(value)}]`; + } + } + + return undefined; +} + +function getConstructorName(value: unknown): string { + const prototype: Prototype | null = Object.getPrototypeOf(value); + + return prototype?.constructor ? prototype.constructor.name : 'null prototype'; +} diff --git a/packages/browser-utils/test/normalizeStringifyValue.test.ts b/packages/browser-utils/test/normalizeStringifyValue.test.ts new file mode 100644 index 000000000000..d44ad703b196 --- /dev/null +++ b/packages/browser-utils/test/normalizeStringifyValue.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment jsdom + */ +import { getStackAsyncContextStrategy, normalize, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { normalizeStringifyValue } from '../src/normalizeStringifyValue'; + +// Each test installs the browser stringifier on the async-context strategy and tears +// it down afterwards so unrelated tests don't see a leftover hook on the global carrier. +describe('normalizeStringifyValue (registered via async-context strategy)', () => { + beforeEach(() => { + setAsyncContextStrategy({ + ...getStackAsyncContextStrategy(), + normalizeStringifyValue, + }); + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + }); + + test('collapses `window` to `[Window]` (called directly)', () => { + // In a real browser `window` and `global` are distinct; in jsdom they're aliased, + // so an integration test through `normalize` would hit core's `[Global]` branch + // first. Calling the stringifier directly verifies the browser-specific check in + // isolation. + // eslint-disable-next-line no-restricted-globals + expect(normalizeStringifyValue(window)).toBe('[Window]'); + }); + + test('collapses `document` to `[Document]`', () => { + expect(normalize({ d: document })).toEqual({ d: '[Document]' }); + }); + + test('renders HTMLDivElement with selector-style path via htmlTreeAsString', () => { + const div = document.createElement('div'); + div.setAttribute('data-test-id', 'd2'); + div.classList.add('container'); + expect(normalize({ div: document.createElement('div'), div2: div })).toEqual({ + div: '[HTMLElement: div]', + div2: '[HTMLElement: div.container]', + }); + }); + + test('renders input/select elements with their tag names', () => { + expect( + normalize({ + input: document.createElement('input'), + select: document.createElement('select'), + }), + ).toEqual({ + input: '[HTMLElement: input]', + select: '[HTMLElement: select]', + }); + }); + + test('falls through to default normalization for non-DOM values', () => { + // Strings/numbers/regular objects must not be intercepted by the browser stringifier. + expect(normalize({ a: 'string', b: 42, c: { nested: true } })).toEqual({ + a: 'string', + b: 42, + c: { nested: true }, + }); + }); + + test('does not handle Vue or React framework values (those belong to vue/react SDKs)', () => { + // A Vue-like object passes through unchanged; only Vue SDK's wrap would collapse it. + expect(normalize({ vm: { _isVue: true, foo: 'bar' } })).toEqual({ + vm: { _isVue: true, foo: 'bar' }, + }); + // A SyntheticEvent-like plain object likewise; only React SDK's wrap would collapse it. + expect( + normalize({ + e: { nativeEvent: 'x', preventDefault: 'fn', stopPropagation: 'fn' }, + }), + ).toEqual({ + e: { nativeEvent: 'x', preventDefault: 'fn', stopPropagation: 'fn' }, + }); + }); +}); diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index dc1be3382664..ed0140c3cdc0 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -6,6 +6,8 @@ import { getIntegrationsToSetup, inboundFiltersIntegration, initAndBind, + getStackAsyncContextStrategy, + setAsyncContextStrategy, stackParserFromStackParserOptions, } from '@sentry/core/browser'; import type { BrowserClientOptions, BrowserOptions } from './client'; @@ -21,6 +23,7 @@ import { spotlightBrowserIntegration } from './integrations/spotlight'; import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; +import { normalizeStringifyValue } from '@sentry-internal/browser-utils'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -118,6 +121,12 @@ export function init(options: BrowserOptions = {}): Client | undefined { }), transport: options.transport || makeFetchTransport, }; + + setAsyncContextStrategy({ + ...getStackAsyncContextStrategy(), + normalizeStringifyValue, + }); + return initAndBind(BrowserClient, clientOptions); } diff --git a/packages/browser/test/eventbuilder.test.ts b/packages/browser/test/eventbuilder.test.ts index 7b5df4fb1c19..bdf5127243b2 100644 --- a/packages/browser/test/eventbuilder.test.ts +++ b/packages/browser/test/eventbuilder.test.ts @@ -82,9 +82,9 @@ describe('eventFromUnknownInput', () => { 'Event', new Event('custom'), { - currentTarget: '[object Null]', + currentTarget: null, isTrusted: false, - target: '[object Null]', + target: null, type: 'custom', }, 'Event `Event` (type=custom) captured as exception', @@ -93,9 +93,9 @@ describe('eventFromUnknownInput', () => { 'MouseEvent', new MouseEvent('click'), { - currentTarget: '[object Null]', + currentTarget: null, isTrusted: false, - target: '[object Null]', + target: null, type: 'click', }, 'Event `MouseEvent` (type=click) captured as exception', diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index be1ea92a7736..7cc31d9d5ff2 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -80,4 +80,16 @@ export interface AsyncContextStrategy { /** Start a new trace, ensuring all spans in the callback share the same traceId. */ startNewTrace?: typeof startNewTrace; + + /** + * Runtime-specific stringification for values that `normalize()` walks. Called from + * `stringifyValue` *before* the runtime-agnostic defaults; if it returns a string, + * that string is used and the defaults are skipped. Returning `undefined` falls + * through to the default behavior. + * + * Browser SDKs register this to handle DOM-specific values + * (`window` / `document` / `HTMLElement`s, Vue ViewModels, React SyntheticEvents) + * without forcing core itself to carry that code. + */ + normalizeStringifyValue?: (value: Exclude) => string | undefined; } diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 7d36db3dd168..2f4880c5f004 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -47,7 +47,8 @@ export { hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; -export { setAsyncContextStrategy } from './asyncContext'; +export { getAsyncContextStrategy, setAsyncContextStrategy } from './asyncContext'; +export { getStackAsyncContextStrategy } from './asyncContext/stackStrategy'; export { getGlobalSingleton, getMainCarrier } from './carrier'; export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 65add9ae18a1..976fa5d0f431 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,9 +1,12 @@ +import { getAsyncContextStrategy } from '../asyncContext'; +import type { AsyncContextStrategy } from '../asyncContext/types'; +import { getMainCarrier } from '../carrier'; import type { Primitive } from '../types/misc'; -import { htmlTreeAsString } from './browser'; -import { isElement, isSyntheticEvent, isVueViewModel } from './is'; import { getNormalizationDepthOverrideHint, hasSkipNormalizationHint } from './normalizationHints'; import { convertToPlainObject } from './object'; -import { getFunctionName, getVueInternalName } from './stacktrace'; +import { getFunctionName } from './stacktrace'; + +type Stringifier = AsyncContextStrategy['normalizeStringifyValue']; type Prototype = { constructor?: (...args: unknown[]) => unknown }; // This is a hack to placate TS, relying on the fact that technically, arrays are objects with integer keys. Normally we @@ -41,8 +44,13 @@ type MemoFunc = [ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function normalize(input: unknown, depth: number = 100, maxProperties: number = +Infinity): any { try { + // Runtime-specific stringification (e.g. window/document/HTMLElement/VueViewModel/SyntheticEvent) + // is contributed by SDKs via the async-context strategy. We resolve it once here and thread the + // function down through `visit()` / `stringifyValue()` to avoid repeating the carrier lookup + // for every visited node. + const stringifier = getAsyncContextStrategy(getMainCarrier()).normalizeStringifyValue; // since we're at the outermost level, we don't provide a key - return visit('', input, depth, maxProperties); + return visit('', input, depth, maxProperties, undefined, stringifier); } catch (err) { return { ERROR: `**non-serializable** (${err})` }; } @@ -81,6 +89,7 @@ function visit( depth: number = +Infinity, maxProperties: number = +Infinity, memo = memoBuilder(), + stringifier?: Stringifier, ): Primitive | ObjOrArray { const [memoize, unmemoize] = memo; @@ -93,7 +102,7 @@ function visit( return value as Primitive; } - const stringified = stringifyValue(key, value); + const stringified = stringifyValue(key, value, stringifier); // Anything we could potentially dig into more (objects or arrays) will have come back as `"[object XXXX]"`. // Everything else will have already been serialized, so if we don't see that pattern, we're done. @@ -130,7 +139,7 @@ function visit( try { const jsonValue = valueWithToJSON.toJSON(); // We need to normalize the return value of `.toJSON()` in case it has circular references - return visit('', jsonValue, remainingDepth - 1, maxProperties, memo); + return visit('', jsonValue, remainingDepth - 1, maxProperties, memo, stringifier); } catch { // pass (The built-in `toJSON` failed, but we can still try to do it ourselves) } @@ -159,7 +168,7 @@ function visit( // Recursively visit all the child nodes const visitValue = visitable[visitKey]; - normalized[visitKey] = visit(visitKey, visitValue, remainingDepth - 1, maxProperties, memo); + normalized[visitKey] = visit(visitKey, visitValue, remainingDepth - 1, maxProperties, memo, stringifier); numAdded++; } @@ -186,32 +195,21 @@ function stringifyValue( // this type is a tiny bit of a cheat, since this function does handle NaN (which is technically a number), but for // our internal use, it'll do value: Exclude, + stringifier?: Stringifier, ): string { try { - // It's safe to use `global`, `window`, and `document` here in this manner, as we are asserting using `typeof` first - // which won't throw if they are not present. - if (typeof global !== 'undefined' && value === global) { return '[Global]'; } - // eslint-disable-next-line no-restricted-globals - if (typeof window !== 'undefined' && value === window) { - return '[Window]'; - } - - // eslint-disable-next-line no-restricted-globals - if (typeof document !== 'undefined' && value === document) { - return '[Document]'; - } - - if (isVueViewModel(value)) { - return getVueInternalName(value); - } - - // React's SyntheticEvent thingy - if (isSyntheticEvent(value)) { - return '[SyntheticEvent]'; + // Runtime-specific stringifications (browser, framework integrations) are registered on the + // async-context strategy. Consult them before the universal fallbacks below. + if (stringifier) { + const stringified = stringifier(value); + // Safe to ignore empty strings here as well, we wont stringify to this + if (stringified) { + return stringified; + } } if (typeof value === 'number' && !Number.isFinite(value)) { @@ -237,11 +235,6 @@ function stringifyValue( // we can make sure that only plain objects come out that way. const objName = getConstructorName(value); - // Handle HTML Elements - if (isElement(value) && /^HTML(\w*)Element$/.test(objName)) { - return `[HTMLElement: ${htmlTreeAsString(value)}]`; - } - return `[object ${objName}]`; } catch (err) { return `**non-serializable** (${err})`; diff --git a/packages/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index 6ec635997e60..a62962fce8a2 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -3,7 +3,13 @@ */ import { describe, expect, test, vi } from 'vitest'; -import { normalize, setNormalizationDepthOverrideHint, setSkipNormalizationHint } from '../../../src'; +import { + getStackAsyncContextStrategy, + normalize, + setAsyncContextStrategy, + setNormalizationDepthOverrideHint, + setSkipNormalizationHint, +} from '../../../src'; import * as isModule from '../../../src/utils/is'; import * as stacktraceModule from '../../../src/utils/stacktrace'; @@ -335,34 +341,9 @@ describe('normalize()', () => { }); }); - describe('handles HTML elements', () => { - test('HTMLDivElement', () => { - const div2 = document.createElement('div'); - div2.setAttribute('data-test-id', 'div2'); - div2.classList.add('container'); - expect( - normalize({ - div: document.createElement('div'), - div2, - }), - ).toEqual({ - div: '[HTMLElement: div]', - div2: '[HTMLElement: div.container]', - }); - }); - - test('input elements', () => { - expect( - normalize({ - input: document.createElement('input'), - select: document.createElement('select'), - }), - ).toEqual({ - input: '[HTMLElement: input]', - select: '[HTMLElement: select]', - }); - }); - }); + // HTMLElement / SyntheticEvent / VueViewModel rendering is now contributed by SDKs (browser-utils, + // react, vue) via the async-context strategy's `normalizeStringifyValue` hook — these cases are + // covered in those packages' tests. Here we just verify the hook plumbing. describe('calls toJSON if implemented', () => { test('primitive values', () => { @@ -478,30 +459,6 @@ describe('normalize()', () => { }); }); - test("known classes like React's `SyntheticEvent`", () => { - const obj = { - foo: { - nativeEvent: 'wat', - preventDefault: 'wat', - stopPropagation: 'wat', - }, - }; - expect(normalize(obj)).toEqual({ - foo: '[SyntheticEvent]', - }); - }); - - test('known classes like `VueViewModel`', () => { - const obj = { - foo: { - _isVue: true, - }, - }; - expect(normalize(obj)).toEqual({ - foo: '[VueViewModel]', - }); - }); - test('null prototype', () => { const obj = Object.create(null); expect(normalize(obj, 0)).toEqual('[null prototype]'); @@ -634,42 +591,57 @@ describe('normalize()', () => { }); }); - test("normalizes value on every iteration of decycle and takes care of things like React's `SyntheticEvent`", () => { - const obj = { - foo: { - nativeEvent: 'wat', - preventDefault: 'wat', - stopPropagation: 'wat', - }, - baz: NaN, - qux: function qux(): void { - /* no-empty */ - }, - }; - const result = normalize(obj); - expect(result).toEqual({ - foo: '[SyntheticEvent]', - baz: '[NaN]', - qux: '[Function: qux]', + test('runs registered `normalizeStringifyValue` for each visited value', () => { + // Plug a stub stringifier into the async-context strategy and verify normalize + // consults it on every visited object — including iterating through decycle. + const stub = vi.fn((value: unknown): string | undefined => { + if (typeof value === 'object' && value !== null && (value as { foo?: unknown }).foo === 'mark-me') { + return '[StubMarker]'; + } + return undefined; }); + setAsyncContextStrategy({ ...getStackAsyncContextStrategy(), normalizeStringifyValue: stub }); + try { + const obj = { + marker: { foo: 'mark-me' }, + nested: { inner: { foo: 'mark-me' } }, + baz: NaN, + qux: function qux(): void { + /* no-empty */ + }, + }; + const result = normalize(obj); + expect(result).toEqual({ + marker: '[StubMarker]', + nested: { inner: '[StubMarker]' }, + baz: '[NaN]', + qux: '[Function: qux]', + }); + // Stub is consulted on every non-primitive value visited. + expect(stub).toHaveBeenCalled(); + } finally { + setAsyncContextStrategy(undefined); + } }); - test('normalizes value on every iteration of decycle and takes care of things like `VueViewModel`', () => { - const obj = { - foo: { - _isVue: true, - }, - baz: NaN, - qux: function qux(): void { - /* no-empty */ - }, - }; - const result = normalize(obj); - expect(result).toEqual({ - foo: '[VueViewModel]', - baz: '[NaN]', - qux: '[Function: qux]', - }); + test('falls back to default representation when the registered stringifier returns undefined', () => { + const stub = vi.fn(() => undefined); + setAsyncContextStrategy({ ...getStackAsyncContextStrategy(), normalizeStringifyValue: stub }); + try { + expect(normalize({ a: 1, b: NaN })).toEqual({ a: 1, b: '[NaN]' }); + expect(stub).toHaveBeenCalled(); + } finally { + setAsyncContextStrategy(undefined); + } + }); + + test('works without any registered stringifier (default ACS)', () => { + // Sanity: with no ACS hook configured, runtime-specific objects are walked as plain + // objects rather than collapsed to a runtime-specific string. The browser SDK + // installs a stringifier that turns `HTMLDivElement` into `[HTMLElement: div]`; + // here in core, with no stringifier installed, we just get the element's enumerable + // own properties (typically none). + expect(normalize({ d: document.createElement('div') })).toEqual({ d: {} }); }); describe('regression: JSON cannot spoof skip-normalization via string keys', () => { diff --git a/packages/react/src/sdk.ts b/packages/react/src/sdk.ts index 03981effc147..8408189ffde8 100644 --- a/packages/react/src/sdk.ts +++ b/packages/react/src/sdk.ts @@ -1,7 +1,13 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit, setContext } from '@sentry/browser'; import type { Client } from '@sentry/core/browser'; -import { applySdkMetadata } from '@sentry/core/browser'; +import { + applySdkMetadata, + getStackAsyncContextStrategy, + isSyntheticEvent, + setAsyncContextStrategy, +} from '@sentry/core/browser'; +import { normalizeStringifyValue as browserNormalizeStringifyValue } from '@sentry-internal/browser-utils'; import { version } from 'react'; /** @@ -14,5 +20,20 @@ export function init(options: BrowserOptions): Client | undefined { applySdkMetadata(opts, 'react'); setContext('react', { version }); - return browserInit(opts); + const client = browserInit(opts); + + // Add react-specific stringification + setAsyncContextStrategy({ + ...getStackAsyncContextStrategy(), + normalizeStringifyValue, + }); + + return client; +} + +function normalizeStringifyValue(value: Exclude): string | undefined { + if (isSyntheticEvent(value)) { + return '[SyntheticEvent]'; + } + return browserNormalizeStringifyValue(value); } diff --git a/packages/react/test/normalize.test.ts b/packages/react/test/normalize.test.ts new file mode 100644 index 000000000000..6bd00ee58df2 --- /dev/null +++ b/packages/react/test/normalize.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment jsdom + */ + +import { normalize, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { init } from '../src/sdk'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +// The React SDK's `init()` wraps the browser-side `normalizeStringifyValue` on the +// async-context strategy with a SyntheticEvent check on top. These tests exercise the +// composition end-to-end via `normalize()` (React-shaped values are collapsed here; +// non-React values fall through to the browser variant and finally to core). +describe('@sentry/react init() normalize stringifier', () => { + beforeEach(() => { + init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + integrations: [], + }); + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + }); + + it("collapses React SyntheticEvent-like objects to '[SyntheticEvent]'", () => { + const synthetic = { + nativeEvent: 'wat', + preventDefault: 'wat', + stopPropagation: 'wat', + }; + expect(normalize({ e: synthetic })).toEqual({ e: '[SyntheticEvent]' }); + }); + + it('still delegates HTMLElement rendering to the browser stringifier underneath', () => { + const button = document.createElement('button'); + button.id = 'submit'; + expect(normalize({ el: button })).toEqual({ el: '[HTMLElement: button#submit]' }); + }); + + it('still delegates `document` to the browser stringifier underneath', () => { + expect(normalize({ d: document })).toEqual({ d: '[Document]' }); + }); + + it('does not collapse plain objects that lack the SyntheticEvent shape', () => { + expect(normalize({ o: { nativeEvent: 'x' } })).toEqual({ o: { nativeEvent: 'x' } }); + expect(normalize({ o: { preventDefault: 'fn', stopPropagation: 'fn' } })).toEqual({ + o: { preventDefault: 'fn', stopPropagation: 'fn' }, + }); + }); + + it('does not intercept primitives or regular objects', () => { + expect(normalize({ s: 'string', n: 42, o: { foo: 'bar' } })).toEqual({ + s: 'string', + n: 42, + o: { foo: 'bar' }, + }); + }); +}); diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index 689a17dacbc4..2ddd08d65b03 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,8 +1,14 @@ import { getDefaultIntegrations, init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { + applySdkMetadata, + getStackAsyncContextStrategy, + isVueViewModel, + setAsyncContextStrategy, +} from '@sentry/core/browser'; import { vueIntegration } from './integration'; import type { Options } from './types'; +import { normalizeStringifyValue as browserNormalizeStringifyValue } from '@sentry-internal/browser-utils'; /** * Inits the Vue SDK @@ -15,5 +21,20 @@ export function init(options: Partial> = {}): Cl applySdkMetadata(opts, 'vue'); - return browserInit(opts); + const client = browserInit(opts); + + // Add vue-specific stringification + setAsyncContextStrategy({ + ...getStackAsyncContextStrategy(), + normalizeStringifyValue, + }); + + return client; +} + +function normalizeStringifyValue(value: Exclude): string | undefined { + if (isVueViewModel(value)) { + return (value as { __v_isVNode?: boolean }).__v_isVNode ? '[VueVNode]' : '[VueViewModel]'; + } + return browserNormalizeStringifyValue(value); } diff --git a/packages/vue/test/integration/normalize.test.ts b/packages/vue/test/integration/normalize.test.ts new file mode 100644 index 000000000000..824d3e84d5b6 --- /dev/null +++ b/packages/vue/test/integration/normalize.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment jsdom + */ + +import { normalize, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../src'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +// The Vue SDK's `init()` wraps the browser-side `normalizeStringifyValue` on the +// async-context strategy with a Vue check on top. These tests exercise the wrapped +// stringifier end-to-end through `normalize()` so we catch any breakage in the +// composition (Vue value handled here; non-Vue values fall through to the browser +// variant; non-DOM/non-Vue values fall through to core's defaults). +describe('@sentry/vue init() normalize stringifier', () => { + beforeEach(() => { + Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + integrations: [], + }); + }); + + afterEach(() => { + // Reset the async-context strategy so subsequent test files start clean. + setAsyncContextStrategy(undefined); + }); + + it("collapses Vue 3 component instances (`__isVue`) to '[VueViewModel]'", () => { + expect(normalize({ vm: { __isVue: true, $el: {}, $data: {} } })).toEqual({ vm: '[VueViewModel]' }); + }); + + it("collapses Vue 2 component instances (`_isVue`) to '[VueViewModel]'", () => { + expect(normalize({ vm: { _isVue: true, $el: {}, $data: {} } })).toEqual({ vm: '[VueViewModel]' }); + }); + + it("collapses Vue 3 VNodes (`__v_isVNode`) to '[VueVNode]'", () => { + expect(normalize({ node: { __v_isVNode: true, type: {}, props: {} } })).toEqual({ node: '[VueVNode]' }); + }); + + it('still delegates HTMLElement rendering to the browser stringifier underneath', () => { + const div = document.createElement('div'); + div.classList.add('content'); + expect(normalize({ el: div })).toEqual({ el: '[HTMLElement: div.content]' }); + }); + + it('still delegates `document` to the browser stringifier underneath', () => { + expect(normalize({ d: document })).toEqual({ d: '[Document]' }); + }); + + it('does not intercept plain objects or primitives', () => { + expect(normalize({ s: 'string', n: 42, o: { foo: 'bar' } })).toEqual({ + s: 'string', + n: 42, + o: { foo: 'bar' }, + }); + }); +}); From 309febc9aea077f22d21f6347d161792b851d469 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 10:23:41 +0200 Subject: [PATCH 03/19] just use normalize for safe join --- packages/core/src/utils/string.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 77e295b4237d..427cdc0caad0 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -1,5 +1,5 @@ -import { isRegExp, isString, isVueViewModel } from './is'; -import { getVueInternalName } from './stacktrace'; +import { isRegExp, isString } from './is'; +import { normalize } from './normalize'; export { escapeStringForRegex } from '../vendor/escapeStringForRegex'; @@ -75,20 +75,7 @@ export function safeJoin(input: unknown[], delimiter?: string): string { // eslint-disable-next-line typescript/prefer-for-of for (let i = 0; i < input.length; i++) { const value = input[i]; - try { - // This is a hack to fix a Vue3-specific bug that causes an infinite loop of - // console warnings. This happens when a Vue template is rendered with - // an undeclared variable, which we try to stringify, ultimately causing - // Vue to issue another warning which repeats indefinitely. - // see: https://github.com/getsentry/sentry-javascript/pull/8981 - if (isVueViewModel(value)) { - output.push(getVueInternalName(value)); - } else { - output.push(String(value)); - } - } catch { - output.push('[value cannot be serialized]'); - } + output.push(normalize(value)); } return output.join(delimiter); From 5ba19bd58b07b755e8d0ae6495c04448323c1b07 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 10:27:17 +0200 Subject: [PATCH 04/19] move things to their respective packages, deprecate from core --- .../browser-utils/src/htmlTreeAsString.ts | 124 ++++++++++++++++++ packages/browser-utils/src/index.ts | 4 + packages/browser-utils/src/is.ts | 15 +++ .../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 +- .../src/normalizeStringifyValue.ts | 3 +- packages/react/src/isSyntheticEvent.ts | 13 ++ packages/react/src/sdk.ts | 8 +- packages/vue/src/isVueViewModel.ts | 27 ++++ packages/vue/src/sdk.ts | 10 +- 13 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 packages/browser-utils/src/htmlTreeAsString.ts create mode 100644 packages/browser-utils/src/is.ts create mode 100644 packages/react/src/isSyntheticEvent.ts create mode 100644 packages/vue/src/isVueViewModel.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 6cd357b02d4f..fedafa617b58 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -36,4 +36,8 @@ export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; export { normalizeStringifyValue } from './normalizeStringifyValue'; +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/src/normalizeStringifyValue.ts b/packages/browser-utils/src/normalizeStringifyValue.ts index 74388446c970..31618d3e44d1 100644 --- a/packages/browser-utils/src/normalizeStringifyValue.ts +++ b/packages/browser-utils/src/normalizeStringifyValue.ts @@ -1,4 +1,5 @@ -import { htmlTreeAsString, isElement } from '@sentry/core'; +import { htmlTreeAsString } from './htmlTreeAsString'; +import { isElement } from './is'; type Prototype = { constructor?: (...args: unknown[]) => unknown }; diff --git a/packages/react/src/isSyntheticEvent.ts b/packages/react/src/isSyntheticEvent.ts new file mode 100644 index 000000000000..1241844a065f --- /dev/null +++ b/packages/react/src/isSyntheticEvent.ts @@ -0,0 +1,13 @@ +import { isPlainObject } from '@sentry/core'; + +/** + * Checks whether given value's type is a React `SyntheticEvent`. + * + * The check is structural: SyntheticEvent doesn't expose a stable constructor we can + * use across React versions / module duplicates, so we look for the small surface + * (`nativeEvent`, `preventDefault`, `stopPropagation`) that every SyntheticEvent has + * and that's unusual for plain objects to all have at once. + */ +export function isSyntheticEvent(wat: unknown): boolean { + return isPlainObject(wat) && 'nativeEvent' in wat && 'preventDefault' in wat && 'stopPropagation' in wat; +} diff --git a/packages/react/src/sdk.ts b/packages/react/src/sdk.ts index 8408189ffde8..f3087e586588 100644 --- a/packages/react/src/sdk.ts +++ b/packages/react/src/sdk.ts @@ -1,14 +1,10 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit, setContext } from '@sentry/browser'; import type { Client } from '@sentry/core/browser'; -import { - applySdkMetadata, - getStackAsyncContextStrategy, - isSyntheticEvent, - setAsyncContextStrategy, -} from '@sentry/core/browser'; +import { applySdkMetadata, getStackAsyncContextStrategy, setAsyncContextStrategy } from '@sentry/core/browser'; import { normalizeStringifyValue as browserNormalizeStringifyValue } from '@sentry-internal/browser-utils'; import { version } from 'react'; +import { isSyntheticEvent } from './isSyntheticEvent'; /** * Inits the React SDK diff --git a/packages/vue/src/isVueViewModel.ts b/packages/vue/src/isVueViewModel.ts new file mode 100644 index 000000000000..8b387fca2cac --- /dev/null +++ b/packages/vue/src/isVueViewModel.ts @@ -0,0 +1,27 @@ +interface VueViewModel { + // Vue3 + __isVue?: boolean; + // Vue2 + _isVue?: boolean; +} + +interface VNode { + // Vue3 — https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts + __v_isVNode?: boolean; +} + +/** + * Checks whether the given value is a Vue ViewModel (Vue 2 / Vue 3 component + * instance) or a Vue 3 `VNode`. + * + * The check is structural (not `instanceof`) because in Vue 3 the toString tag + * would read a custom `Symbol.toStringTag`, and we want a cheap, runtime-safe + * probe that works on either Vue version. + */ +export function isVueViewModel(wat: unknown): wat is VueViewModel | VNode { + return !!( + typeof wat === 'object' && + wat !== null && + ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue || (wat as { __v_isVNode?: boolean }).__v_isVNode) + ); +} diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index 2ddd08d65b03..b7ddf4f19b90 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,14 +1,10 @@ import { getDefaultIntegrations, init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { - applySdkMetadata, - getStackAsyncContextStrategy, - isVueViewModel, - setAsyncContextStrategy, -} from '@sentry/core/browser'; +import { applySdkMetadata, getStackAsyncContextStrategy, setAsyncContextStrategy } from '@sentry/core/browser'; +import { normalizeStringifyValue as browserNormalizeStringifyValue } from '@sentry-internal/browser-utils'; import { vueIntegration } from './integration'; +import { isVueViewModel } from './isVueViewModel'; import type { Options } from './types'; -import { normalizeStringifyValue as browserNormalizeStringifyValue } from '@sentry-internal/browser-utils'; /** * Inits the Vue SDK From af3edb74ac6804c71c997f1832aad5f4f8f9b60e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 10:28:22 +0200 Subject: [PATCH 05/19] deprecate core stuff --- packages/core/src/browser-exports.ts | 7 ++++++- packages/core/src/shared-exports.ts | 3 +++ packages/core/src/utils/browser.ts | 2 ++ packages/core/src/utils/is.ts | 8 +++++++- 4 files changed, 18 insertions(+), 2 deletions(-) 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/shared-exports.ts b/packages/core/src/shared-exports.ts index 2f4880c5f004..2baae795d709 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -221,6 +221,7 @@ export { addHandler, maybeInstrument, resetInstrumentationHandlers, triggerHandl export { isDOMError, isDOMException, + // eslint-disable-next-line deprecation/deprecation isElement, isError, isErrorEvent, @@ -231,8 +232,10 @@ export { isPrimitive, isRegExp, isString, + // eslint-disable-next-line deprecation/deprecation isSyntheticEvent, isThenable, + // eslint-disable-next-line deprecation/deprecation isVueViewModel, } from './utils/is'; export { isBrowser } from './utils/isBrowser'; 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/is.ts b/packages/core/src/utils/is.ts index 326a2006bc4e..98000b309ab3 100644 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -136,6 +136,8 @@ export function isEvent(wat: unknown): wat is PolymorphicEvent { * * @param wat A value to be checked. * @returns A boolean representing the result. + * @deprecated This is browser-specific and will be removed from `@sentry/core` in a future major version. + * Import `isElement` from `@sentry-internal/browser-utils` instead. */ export function isElement(wat: unknown): boolean { return typeof Element !== 'undefined' && isInstanceOf(wat, Element); @@ -162,11 +164,13 @@ export function isThenable(wat: any): wat is PromiseLike { } /** - * Checks whether given value's type is a SyntheticEvent + * Checks whether given value's type is a React SyntheticEvent * {@link isSyntheticEvent}. * * @param wat A value to be checked. * @returns A boolean representing the result. + * @deprecated This is React-specific and will be removed from `@sentry/core` in a future major version. + * Use the equivalent helper that ships with `@sentry/react` instead. */ export function isSyntheticEvent(wat: unknown): boolean { return isPlainObject(wat) && 'nativeEvent' in wat && 'preventDefault' in wat && 'stopPropagation' in wat; @@ -195,6 +199,8 @@ export function isInstanceOf(wat: any, base: any): wat is T { * * @param wat A value to be checked. * @returns A boolean representing the result. + * @deprecated This is Vue-specific and will be removed from `@sentry/core` in a future major version. + * Use the equivalent helper that ships with `@sentry/vue` instead. */ export function isVueViewModel(wat: unknown): wat is VueViewModel | VNode { // Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property. From bd34f3f5f9b7d3166622a7a0885a8329019431d4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 10:59:14 +0200 Subject: [PATCH 06/19] stuff --- packages/browser-utils/test/metrics/cls.test.ts | 12 ++++++++---- packages/browser-utils/test/metrics/lcp.test.ts | 8 ++++++-- .../test/metrics/webVitalSpans.test.ts | 14 +++++++++----- packages/browser/src/integrations/breadcrumbs.ts | 2 +- .../replay-internal/src/coreHandlers/handleDom.ts | 2 +- .../src/coreHandlers/handleKeyboardEvent.ts | 2 +- 6 files changed, 26 insertions(+), 14 deletions(-) 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('