Skip to content

Commit 02cd482

Browse files
mydeaclaude
andauthored
ref(browser): Extract browser-specific normalize code out of core (#21172)
## Summary Today, `@sentry/core` carries browser-specific code that runs (or just ships) in every runtime: `window` / `document` / `HTMLElement` collapsing inside `normalize()`, Vue ViewModel detection, React SyntheticEvent detection, and `htmlTreeAsString` (DOM walk). This PR moves all of that into the packages that actually need it, leaving `@sentry/core` more runtime-agnostic (there are still a bunch of other things, but step by step...), and exposes a single new hook for SDKs to plug runtime-specific rendering into `normalize()`. ## Mechanism `packages/core/src/utils/normalize.ts` now exposes: ```ts export function setNormalizeStringifier(fn: ((value) => string | undefined) | undefined): void ``` `normalize()`'s internal `stringifyValue` consults the registered function before its runtime-agnostic fallbacks (NaN / function / symbol / bigint / `[object ConstructorName]`). Returning a string short-circuits; returning `undefined` falls through. Default state is no stringifier — server-only consumers never reach this code path. ## What moved | Symbol | New home | Core export | |---|---|---| | `htmlTreeAsString` | `@sentry-internal/browser-utils` | `@deprecated` | | `isElement` | `@sentry-internal/browser-utils` | `@deprecated` | | `isSyntheticEvent` | `@sentry/react` (internal) | `@deprecated` | | `isVueViewModel` | `@sentry/vue` (internal) | `@deprecated` | | `getVueInternalName` | `@sentry/vue` (internal) | `@deprecated` | `@sentry-internal/browser-utils` also gains a new `normalizeStringifyValue` that handles `window` → `[Window]`, `document` → `[Document]`, and `HTMLElement` instances → `[HTMLElement: <css-selector-path>]` (via `htmlTreeAsString`). It explicitly does **not** handle Vue or React values — those are added by their respective SDKs. ## SDK wiring - `@sentry/browser` `init()` calls `setNormalizeStringifier(normalizeStringifyValue)` (the browser-utils variant). - `@sentry/vue` `init()` runs after `browserInit`, then registers a wrapper that checks `isVueViewModel` and otherwise delegates to browser-utils `normalizeStringifyValue`. - `@sentry/react` `init()` does the same for `isSyntheticEvent`. ## Other adjustments - `safeJoin` in core now uses `stringifyValue` from normalize instead of having special handling for vue, this should streamline things a bit. as a sideeffect, we also stringify certain things better now, e.g. html elements etc. get the nice tree format in browser ## Tests - `packages/core/test/lib/utils/normalize.test.ts` — covers registry semantics: stub stringifier is consulted for every visited node, falls back to defaults when it returns undefined, behaves correctly with no stringifier registered. - `packages/core/test/lib/utils/string.test.ts` — new `safeJoin()` block covers primitive/non-primitive routing, default delimiter, non-array input, Error rendering, and interaction with `setNormalizeStringifier`. - `packages/browser-utils/test/normalizeStringifyValue.test.ts` — direct and integration tests for window / document / HTMLElement, plus confirmation that Vue/React values are *not* intercepted. - `packages/vue/test/integration/normalize.test.ts` — verifies Vue init's wrapper collapses Vue 2/3 ViewModels and VNodes while still delegating HTMLElement / `document` to the browser variant underneath. - `packages/react/test/normalize.test.ts` — same for React's SyntheticEvent wrap. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5bd7ea2 commit 02cd482

37 files changed

Lines changed: 740 additions & 210 deletions

File tree

dev-packages/browser-integration-tests/suites/public-api/setContext/non_serializable_context/test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ sentryTest('should normalize non-serializable context', async ({ getLocalTestUrl
88

99
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
1010

11-
expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: HTMLBodyElement]');
11+
expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: body]');
1212
expect(eventData.message).toBe('non_serializable');
1313
});

dev-packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestUrl
3737
timestamp: expect.any(Number),
3838
type: 'default',
3939
category: 'console',
40-
data: { arguments: ['Test log', '[HTMLElement: HTMLBodyElement]'], logger: 'console' },
40+
data: { arguments: ['Test log', '[HTMLElement: body]'], logger: 'console' },
4141
level: 'log',
42-
message: 'Test log [object HTMLBodyElement]',
42+
message: 'Test log [HTMLElement: body]',
4343
},
4444
]),
4545
);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { isString } from '@sentry/core';
2+
3+
const DEFAULT_MAX_STRING_LENGTH = 80;
4+
5+
type SimpleNode = {
6+
parentNode: SimpleNode;
7+
} | null;
8+
9+
/**
10+
* Given a child DOM element, returns a query-selector statement describing that
11+
* and its ancestors
12+
* e.g. [HTMLElement] => body > div > input#foo.btn[name=baz]
13+
* @returns generated DOM path
14+
*/
15+
export function htmlTreeAsString(
16+
elem: unknown,
17+
options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {},
18+
): string {
19+
if (!elem) {
20+
return '<unknown>';
21+
}
22+
23+
// try/catch both:
24+
// - accessing event.target (see getsentry/raven-js#838, #768)
25+
// - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly
26+
// - can throw an exception in some circumstances.
27+
try {
28+
let currentElem = elem as SimpleNode;
29+
const MAX_TRAVERSE_HEIGHT = 5;
30+
const out = [];
31+
let height = 0;
32+
let len = 0;
33+
const separator = ' > ';
34+
const sepLength = separator.length;
35+
let nextStr;
36+
const keyAttrs = Array.isArray(options) ? options : options.keyAttrs;
37+
const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH;
38+
39+
while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) {
40+
nextStr = _htmlElementAsString(currentElem, keyAttrs);
41+
// bail out if
42+
// - nextStr is the 'html' element
43+
// - the length of the string that would be created exceeds maxStringLength
44+
// (ignore this limit if we are on the first iteration)
45+
if (nextStr === 'html' || (height > 1 && len + out.length * sepLength + nextStr.length >= maxStringLength)) {
46+
break;
47+
}
48+
49+
out.push(nextStr);
50+
51+
len += nextStr.length;
52+
currentElem = currentElem.parentNode;
53+
}
54+
55+
return out.reverse().join(separator);
56+
} catch {
57+
return '<unknown>';
58+
}
59+
}
60+
61+
/**
62+
* Returns a simple, query-selector representation of a DOM element
63+
* e.g. [HTMLElement] => input#foo.btn[name=baz]
64+
* @returns generated DOM path
65+
*/
66+
function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
67+
const elem = el as {
68+
tagName?: string;
69+
id?: string;
70+
className?: string;
71+
getAttribute(key: string): string;
72+
};
73+
74+
const out = [];
75+
76+
if (!elem?.tagName) {
77+
return '';
78+
}
79+
80+
if (typeof HTMLElement !== 'undefined') {
81+
// If using the component name annotation plugin, this value may be available on the DOM node
82+
if (elem instanceof HTMLElement && elem.dataset) {
83+
if (elem.dataset['sentryComponent']) {
84+
return elem.dataset['sentryComponent'];
85+
}
86+
if (elem.dataset['sentryElement']) {
87+
return elem.dataset['sentryElement'];
88+
}
89+
}
90+
}
91+
92+
out.push(elem.tagName.toLowerCase());
93+
94+
// Pairs of attribute keys defined in `serializeAttribute` and their values on element.
95+
const keyAttrPairs = keyAttrs?.length
96+
? keyAttrs.filter(keyAttr => elem.getAttribute(keyAttr)).map(keyAttr => [keyAttr, elem.getAttribute(keyAttr)])
97+
: null;
98+
99+
if (keyAttrPairs?.length) {
100+
keyAttrPairs.forEach(keyAttrPair => {
101+
out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`);
102+
});
103+
} else {
104+
if (elem.id) {
105+
out.push(`#${elem.id}`);
106+
}
107+
108+
const className = elem.className;
109+
if (className && isString(className)) {
110+
const classes = className.split(/\s+/);
111+
for (const c of classes) {
112+
out.push(`.${c}`);
113+
}
114+
}
115+
}
116+
for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) {
117+
const attr = elem.getAttribute(k);
118+
if (attr) {
119+
out.push(`[${k}="${attr}"]`);
120+
}
121+
}
122+
123+
return out.join('');
124+
}

packages/browser-utils/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrRespo
3434

3535
export { resourceTimingToSpanAttributes } from './metrics/resourceTiming';
3636

37+
export { htmlTreeAsString } from './htmlTreeAsString';
38+
39+
export { isElement } from './is';
40+
3741
export type { FetchHint, NetworkMetaWarning, XhrHint } from './types';

packages/browser-utils/src/is.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Checks whether given value's type is an Element instance.
3+
*
4+
* Returns false if `Element` is not available in the current runtime.
5+
*/
6+
export function isElement(wat: unknown): boolean {
7+
if (typeof Element === 'undefined') {
8+
return false;
9+
}
10+
try {
11+
return wat instanceof Element;
12+
} catch {
13+
return false;
14+
}
15+
}

packages/browser-utils/src/metrics/browserMetrics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import {
55
debug,
66
getActiveSpan,
77
getComponentName,
8-
htmlTreeAsString,
98
isPrimitive,
109
parseUrl,
1110
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1211
setMeasurement,
1312
spanToJSON,
1413
stringMatchesSomePattern,
1514
} from '@sentry/core';
15+
import { htmlTreeAsString } from '../htmlTreeAsString';
1616
import { WINDOW } from '../types';
1717
import { trackClsAsStandaloneSpan } from './cls';
1818
import {

packages/browser-utils/src/metrics/cls.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
browserPerformanceTimeOrigin,
44
debug,
55
getCurrentScope,
6-
htmlTreeAsString,
76
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
87
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
98
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
@@ -12,6 +11,7 @@ import {
1211
timestampInSeconds,
1312
} from '@sentry/core';
1413
import { DEBUG_BUILD } from '../debug-build';
14+
import { htmlTreeAsString } from '../htmlTreeAsString';
1515
import { addClsInstrumentationHandler } from './instrument';
1616
import type { WebVitalReportEvent } from './utils';
1717
import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils';

packages/browser-utils/src/metrics/inp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
getActiveSpan,
55
getCurrentScope,
66
getRootSpan,
7-
htmlTreeAsString,
87
isBrowser,
98
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
109
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
@@ -13,6 +12,7 @@ import {
1312
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1413
spanToJSON,
1514
} from '@sentry/core';
15+
import { htmlTreeAsString } from '../htmlTreeAsString';
1616
import { WINDOW } from '../types';
1717
import type { InstrumentationHandlerCallback } from './instrument';
1818
import {

packages/browser-utils/src/metrics/lcp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import {
33
browserPerformanceTimeOrigin,
44
debug,
55
getCurrentScope,
6-
htmlTreeAsString,
76
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
87
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
98
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
109
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1110
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1211
} from '@sentry/core';
1312
import { DEBUG_BUILD } from '../debug-build';
13+
import { htmlTreeAsString } from '../htmlTreeAsString';
1414
import { addLcpInstrumentationHandler } from './instrument';
1515
import type { WebVitalReportEvent } from './utils';
1616
import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils';

packages/browser-utils/src/metrics/webVitalSpans.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
getActiveSpan,
66
getCurrentScope,
77
getRootSpan,
8-
htmlTreeAsString,
98
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
109
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1110
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -14,6 +13,7 @@ import {
1413
timestampInSeconds,
1514
} from '@sentry/core';
1615
import { DEBUG_BUILD } from '../debug-build';
16+
import { htmlTreeAsString } from '../htmlTreeAsString';
1717
import { WINDOW } from '../types';
1818
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
1919
import type { InstrumentationHandlerCallback } from './instrument';

0 commit comments

Comments
 (0)