Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ sentryTest('should normalize non-serializable context', async ({ getLocalTestUrl

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

expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: HTMLBodyElement]');
expect(eventData.contexts?.non_serializable).toEqual('[HTMLElement: body]');
expect(eventData.message).toBe('non_serializable');
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestUrl
timestamp: expect.any(Number),
type: 'default',
category: 'console',
data: { arguments: ['Test log', '[HTMLElement: HTMLBodyElement]'], logger: 'console' },
data: { arguments: ['Test log', '[HTMLElement: body]'], logger: 'console' },
level: 'log',
message: 'Test log [object HTMLBodyElement]',
message: 'Test log [HTMLElement: body]',
},
]),
);
Expand Down
124 changes: 124 additions & 0 deletions packages/browser-utils/src/htmlTreeAsString.ts
Original file line number Diff line number Diff line change
@@ -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 '<unknown>';
}

// 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 '<unknown>';
}
}

/**
* 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('');
}
4 changes: 4 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrRespo

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

export { htmlTreeAsString } from './htmlTreeAsString';

export { isElement } from './is';

export type { FetchHint, NetworkMetaWarning, XhrHint } from './types';
15 changes: 15 additions & 0 deletions packages/browser-utils/src/is.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import {
debug,
getActiveSpan,
getComponentName,
htmlTreeAsString,
isPrimitive,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setMeasurement,
spanToJSON,
stringMatchesSomePattern,
} from '@sentry/core';
import { htmlTreeAsString } from '../htmlTreeAsString';
import { WINDOW } from '../types';
import { trackClsAsStandaloneSpan } from './cls';
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/cls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
browserPerformanceTimeOrigin,
debug,
getCurrentScope,
htmlTreeAsString,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
Expand All @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
getActiveSpan,
getCurrentScope,
getRootSpan,
htmlTreeAsString,
isBrowser,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/lcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {
browserPerformanceTimeOrigin,
debug,
getCurrentScope,
htmlTreeAsString,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
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';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/webVitalSpans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
getActiveSpan,
getCurrentScope,
getRootSpan,
htmlTreeAsString,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All @@ -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';
Expand Down
12 changes: 8 additions & 4 deletions packages/browser-utils/test/metrics/cls.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(),
Expand All @@ -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);
});

Expand Down Expand Up @@ -136,14 +140,14 @@ describe('_sendStandaloneClsSpan', () => {
};
const pageloadSpanId = '789';

vi.mocked(SentryCore.htmlTreeAsString)
vi.mocked(htmlTreeAsString)
.mockReturnValueOnce('<div>') // for the name
.mockReturnValueOnce('<div>') // for source 1
.mockReturnValueOnce('<span>'); // for source 2

_sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation');

expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3);
expect(htmlTreeAsString).toHaveBeenCalledTimes(3);
expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({
name: '<div>',
transaction: 'test-transaction',
Expand Down
8 changes: 6 additions & 2 deletions packages/browser-utils/test/metrics/lcp.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand Down Expand Up @@ -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);
});

Expand Down
14 changes: 9 additions & 5 deletions packages/browser-utils/test/metrics/webVitalSpans.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand All @@ -20,6 +20,10 @@ vi.mock('@sentry/core', async () => {
};
});

vi.mock('../../src/htmlTreeAsString', () => ({
htmlTreeAsString: vi.fn(),
}));

// Mock WINDOW
vi.mock('../../src/types', () => ({
WINDOW: {
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -324,7 +328,7 @@ describe('_sendClsSpan', () => {
toJSON: vi.fn(),
};

vi.mocked(SentryCore.htmlTreeAsString)
vi.mocked(htmlTreeAsString)
.mockReturnValueOnce('<div>') // for the name
.mockReturnValueOnce('<div>') // for source 1
.mockReturnValueOnce('<span>'); // for source 2
Expand Down Expand Up @@ -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('<button>');
vi.mocked(htmlTreeAsString).mockReturnValue('<button>');
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any);
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export {
startBrowserTracingPageLoadSpan,
} from './tracing/browserTracingIntegration';
export { elementTimingIntegration } from '@sentry-internal/browser-utils';
export { normalizeStringifyValue } from './normalizeStringifyValue';
export { reportPageLoaded } from './tracing/reportPageLoaded';
export { setActiveSpanInBrowser } from './tracing/setActiveSpan';
export { spanStreamingIntegration } from './integrations/spanstreaming';
Expand Down
Loading
Loading