diff --git a/package.json b/package.json index 52d0d958ff..a38a2a74b4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:e2e:init": "yarn build && yarn build:apps && yarn playwright install chromium --with-deps", "test:e2e": "playwright test --config test/e2e/playwright.local.config.ts --project chromium", "test:e2e:bs": "node --env-file-if-exists=.env ./scripts/test/bs-wrapper.ts playwright test --config test/e2e/playwright.bs.config.ts", + "test:e2e:salesforce": "playwright test --config test/e2e/playwright.salesforce.config.ts", "test:e2e:ci": "yarn test:e2e:init && yarn test:e2e", "test:e2e:ci:bs": "yarn build && yarn build:apps && yarn test:e2e:bs", "test:compat:tsc": "node scripts/check-typescript-compatibility.ts", diff --git a/packages/core/src/browser/addEventListener.spec.ts b/packages/core/src/browser/addEventListener.spec.ts index dc59626848..e7abeb784c 100644 --- a/packages/core/src/browser/addEventListener.spec.ts +++ b/packages/core/src/browser/addEventListener.spec.ts @@ -87,6 +87,31 @@ describe('addEventListener', () => { expect(customEventTarget.removeEventListener).toHaveBeenCalled() }) + it('does not break stop() when removeEventListener is missing', () => { + const addEventListenerSpy = jasmine.createSpy() + const customEventTarget = { + addEventListener: addEventListenerSpy, + } as unknown as HTMLElement + + const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', noop) + + expect(addEventListenerSpy).toHaveBeenCalled() + expect(stop).not.toThrow() + }) + + it('skips registration when addEventListener is missing', () => { + const listener = jasmine.createSpy() + const removeEventListenerSpy = jasmine.createSpy() + const customEventTarget = { + removeEventListener: removeEventListenerSpy, + } as unknown as HTMLElement + + const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', listener) + + expect(stop).not.toThrow() + expect(removeEventListenerSpy).not.toHaveBeenCalled() + }) + describe('Untrusted event', () => { beforeEach(() => { configuration = { allowUntrustedEvents: false } as Configuration diff --git a/packages/core/src/browser/addEventListener.ts b/packages/core/src/browser/addEventListener.ts index 6fb2c43ba5..2ef7b0bf30 100644 --- a/packages/core/src/browser/addEventListener.ts +++ b/packages/core/src/browser/addEventListener.ts @@ -1,5 +1,6 @@ import { monitor } from '../tools/monitor' import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue' +import { noop } from '../tools/utils/functionUtils' import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './browser.types' export type TrustableEvent = E & { __ddIsTrusted?: boolean } @@ -132,10 +133,20 @@ export function addEventListeners add.call(eventTarget, eventName, listenerWithMonitor, options)) function stop() { const remove = getZoneJsOriginalValue(listenerTarget, 'removeEventListener') + if (typeof remove !== 'function') { + return + } + eventNames.forEach((eventName) => remove.call(eventTarget, eventName, listenerWithMonitor, options)) } diff --git a/packages/core/src/domain/report/reportObservable.spec.ts b/packages/core/src/domain/report/reportObservable.spec.ts index 2d8c4ecd6b..54ed64249a 100644 --- a/packages/core/src/domain/report/reportObservable.spec.ts +++ b/packages/core/src/domain/report/reportObservable.spec.ts @@ -68,4 +68,18 @@ describe('report observable', () => { csp: { disposition: 'enforce' }, }) }) + + it(`should ignore ${RawReportType.cspViolation} when the environment rejects the event listener`, () => { + ;(EventTarget.prototype.addEventListener as jasmine.Spy).and.callFake((type: string) => { + if (type === 'securitypolicyviolation') { + throw new Error('unsupported event listener') + } + }) + + expect(() => { + consoleSubscription = initReportObservable(configuration, [RawReportType.cspViolation]).subscribe(notifyReport) + }).not.toThrow() + + expect(notifyReport).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/src/domain/report/reportObservable.ts b/packages/core/src/domain/report/reportObservable.ts index 282c5fc935..566eccee4d 100644 --- a/packages/core/src/domain/report/reportObservable.ts +++ b/packages/core/src/domain/report/reportObservable.ts @@ -60,11 +60,15 @@ function createReportObservable(reportTypes: ReportType[]) { function createCspViolationReportObservable(configuration: Configuration) { return new Observable((observable) => { - const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => { - observable.notify(buildRawReportErrorFromCspViolation(event)) - }) + try { + const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => { + observable.notify(buildRawReportErrorFromCspViolation(event)) + }) - return stop + return stop + } catch { + return + } }) } diff --git a/packages/core/src/tools/globalObject.spec.ts b/packages/core/src/tools/globalObject.spec.ts new file mode 100644 index 0000000000..edc4c8b37a --- /dev/null +++ b/packages/core/src/tools/globalObject.spec.ts @@ -0,0 +1,70 @@ +import { getGlobalObject } from './globalObject' + +describe('getGlobalObject', () => { + it('returns self when globalThis is unavailable', () => { + const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis') + const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self') + + if (!globalThisDescriptor?.configurable) { + pending('globalThis descriptor is not configurable in this environment') + } + if (!selfDescriptor?.configurable) { + pending('self descriptor is not configurable in this environment') + } + + const fakeSelf = { dd: 'sandbox-global' } + + Object.defineProperty(window, 'globalThis', { + value: undefined, + configurable: true, + writable: true, + }) + Object.defineProperty(window, 'self', { + value: fakeSelf, + configurable: true, + writable: true, + }) + + try { + expect(getGlobalObject()).toBe(fakeSelf) + } finally { + Object.defineProperty(window, 'globalThis', globalThisDescriptor!) + Object.defineProperty(window, 'self', selfDescriptor!) + } + }) + + it('returns self without relying on the Object.prototype fallback when globalThis is unavailable', () => { + const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis') + const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self') + + if (!globalThisDescriptor?.configurable) { + pending('globalThis descriptor is not configurable in this environment') + } + if (!selfDescriptor?.configurable) { + pending('self descriptor is not configurable in this environment') + } + + const fakeSelf = { dd: 'sandbox-global' } + + Object.defineProperty(window, 'globalThis', { + value: undefined, + configurable: true, + writable: true, + }) + Object.defineProperty(window, 'self', { + value: fakeSelf, + configurable: true, + writable: true, + }) + + const definePropertySpy = spyOn(Object, 'defineProperty').and.callThrough() + + try { + expect(getGlobalObject()).toBe(fakeSelf) + expect(definePropertySpy).not.toHaveBeenCalledWith(Object.prototype, '_dd_temp_', jasmine.any(Object)) + } finally { + Object.defineProperty(window, 'globalThis', globalThisDescriptor!) + Object.defineProperty(window, 'self', selfDescriptor!) + } + }) +}) diff --git a/packages/core/src/tools/globalObject.ts b/packages/core/src/tools/globalObject.ts index ce9b637c02..fe97bc8ca0 100644 --- a/packages/core/src/tools/globalObject.ts +++ b/packages/core/src/tools/globalObject.ts @@ -17,27 +17,39 @@ export function getGlobalObject(): T { if (typeof globalThis === 'object') { return globalThis as unknown as T } - Object.defineProperty(Object.prototype, '_dd_temp_', { - get() { - return this as object - }, - configurable: true, - }) - // @ts-ignore _dd_temp is defined using defineProperty - let globalObject: unknown = _dd_temp_ - // @ts-ignore _dd_temp is defined using defineProperty - delete Object.prototype._dd_temp_ + + // Under Lightning Web Security, third-party code should rely on `self` to + // access the sandbox global object. The Object.prototype fallback below can + // also fail there because Object.prototype is sealed. + if (typeof self === 'object') { + return self as unknown as T + } + + if (typeof window === 'object') { + return window as unknown as T + } + + let globalObject: unknown + + try { + Object.defineProperty(Object.prototype, '_dd_temp_', { + get() { + return this as object + }, + configurable: true, + }) + // @ts-ignore _dd_temp is defined using defineProperty + globalObject = _dd_temp_ + // @ts-ignore _dd_temp is defined using defineProperty + delete Object.prototype._dd_temp_ + } catch { + globalObject = {} + } + if (typeof globalObject !== 'object') { - // on safari _dd_temp_ is available on window but not globally - // fallback on other browser globals check - if (typeof self === 'object') { - globalObject = self - } else if (typeof window === 'object') { - globalObject = window - } else { - globalObject = {} - } + globalObject = {} } + return globalObject as T } diff --git a/packages/core/src/tools/instrumentMethod.spec.ts b/packages/core/src/tools/instrumentMethod.spec.ts index 097f665bc4..826076f328 100644 --- a/packages/core/src/tools/instrumentMethod.spec.ts +++ b/packages/core/src/tools/instrumentMethod.spec.ts @@ -57,6 +57,43 @@ describe('instrumentMethod', () => { expect('onevent' in object).toBeFalse() }) + it('skips instrumentation on readonly methods', () => { + const originalMethod = () => 1 + const object = {} as { method: () => number } + Object.defineProperty(object, 'method', { + value: originalMethod, + writable: false, + configurable: true, + }) + + const instrumentationSpy = jasmine.createSpy() + const { stop } = instrumentMethod(object, 'method', instrumentationSpy) + + expect(object.method).toBe(originalMethod) + expect(object.method()).toBe(1) + expect(instrumentationSpy).not.toHaveBeenCalled() + expect(stop).not.toThrow() + }) + + it('skips instrumentation on readonly methods defined on the prototype chain', () => { + const originalMethod = jasmine.createSpy().and.returnValue(1) + const prototype = {} as { method: () => number } + Object.defineProperty(prototype, 'method', { + value: originalMethod, + writable: false, + configurable: true, + }) + const object = Object.create(prototype) as { method: () => number } + + const instrumentationSpy = jasmine.createSpy() + const { stop } = instrumentMethod(object, 'method', instrumentationSpy) + + expect(object.method()).toBe(1) + expect(instrumentationSpy).not.toHaveBeenCalled() + expect(Object.prototype.hasOwnProperty.call(object, 'method')).toBeFalse() + expect(stop).not.toThrow() + }) + it('calls the instrumentation with method target and parameters', () => { const object = { method: (a: number, b: number) => a + b } const instrumentationSpy = jasmine.createSpy<(call: InstrumentedMethodCall) => void>() diff --git a/packages/core/src/tools/instrumentMethod.ts b/packages/core/src/tools/instrumentMethod.ts index 5d5a398824..92641af433 100644 --- a/packages/core/src/tools/instrumentMethod.ts +++ b/packages/core/src/tools/instrumentMethod.ts @@ -73,6 +73,11 @@ export function instrumentMethod) => void, { computeHandlingStack }: { computeHandlingStack?: boolean } = {} ) { + const methodDescriptor = findDescriptorInPrototypeChain(targetPrototype, method) + if (methodDescriptor && !canAssignDescriptor(methodDescriptor)) { + return { stop: noop } + } + let original = targetPrototype[method] if (typeof original !== 'function') { @@ -117,14 +122,22 @@ export function instrumentMethod { stopped = true // If the instrumentation has been removed by a third party, keep the last one if (targetPrototype[method] === instrumentation) { - targetPrototype[method] = original + try { + targetPrototype[method] = original + } catch { + // Ignore restore failures on readonly properties. + } } }, } @@ -168,3 +181,25 @@ export function instrumentSetter { expect(cookieChanges).toEqual(['foo', 'bar']) }) + + it('should fallback to polling when cookieStore rejects change listeners', () => { + Object.defineProperty(window, 'cookieStore', { + configurable: true, + get: () => ({ + addEventListener: () => { + throw new Error("Lightning Web Security: Cannot add 'change' event listener to CookieStore object.") + }, + removeEventListener: () => undefined, + }), + }) + const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME) + + let cookieChange: string | undefined + expect(() => { + subscription = observable.subscribe((change) => (cookieChange = change)) + }).not.toThrow() + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + expect(cookieChange).toEqual('foo') + }) }) diff --git a/packages/rum-core/src/browser/cookieObservable.ts b/packages/rum-core/src/browser/cookieObservable.ts index 3bb3080d19..128a5eea0e 100644 --- a/packages/rum-core/src/browser/cookieObservable.ts +++ b/packages/rum-core/src/browser/cookieObservable.ts @@ -27,22 +27,27 @@ export function createCookieObservable(configuration: Configuration, cookieName: function listenToCookieStoreChange(configuration: Configuration) { return (cookieName: string, callback: (event: string | undefined) => void) => { - const listener = addEventListener( - configuration, - (window as CookieStoreWindow).cookieStore!, - DOM_EVENT.CHANGE, - (event) => { - // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. - // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 - const changeEvent = - event.changed.find((event) => event.name === cookieName) || - event.deleted.find((event) => event.name === cookieName) - if (changeEvent) { - callback(changeEvent.value) + try { + const listener = addEventListener( + configuration, + (window as CookieStoreWindow).cookieStore!, + DOM_EVENT.CHANGE, + (event) => { + // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. + // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 + const changeEvent = + event.changed.find((event) => event.name === cookieName) || + event.deleted.find((event) => event.name === cookieName) + if (changeEvent) { + callback(changeEvent.value) + } } - } - ) - return listener.stop + ) + return listener.stop + } catch { + // Some runtimes expose cookieStore but reject event listeners (for example under sandboxed security layers). + return watchCookieFallback(cookieName, callback) + } } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index af841f65ac..1383f53412 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -284,6 +284,55 @@ describe('trackClickActions', () => { expect(events.length).toBe(1) }) + describe('compatibility fallbacks', () => { + it('collects click actions when composedPath is unavailable', () => { + startClickActionsTracking() + + emulateClick({ + activity: {}, + eventProperty: { + composedPath: undefined, + }, + }) + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].target?.selector).toBe('#button') + expect(events[0].target?.composedPathSelector).toBeUndefined() + }) + + it('collects click actions when composedPath throws', () => { + startClickActionsTracking() + + emulateClick({ + activity: {}, + eventProperty: { + composedPath: () => { + throw new Error('composedPath is unavailable') + }, + }, + }) + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].target?.selector).toBe('#button') + expect(events[0].target?.composedPathSelector).toBeUndefined() + }) + + it('collects click actions without selector data when CSS.escape is unavailable', () => { + startClickActionsTracking() + spyOn(CSS, 'escape').and.throwError('CSS.escape is unavailable') + + emulateClick({ activity: {} }) + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Click me') + expect(events[0].target?.selector).toBeUndefined() + expect(events[0].target?.composedPathSelector).toBeUndefined() + }) + }) + describe('with enablePrivacyForActionName false', () => { it('extracts action name when default privacy level is mask', () => { startClickActionsTracking({ diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 082bb2d7d3..f10bd18d84 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -237,8 +237,10 @@ function computeClickActionBase( const rect = target.getBoundingClientRect() const selector = getSelectorFromElement(target, configuration.actionNameAttribute) - - const composedPathSelector = getComposedPathSelector(event.composedPath(), configuration.actionNameAttribute) + const composedPath = getEventComposedPath(event) + const composedPathSelector = composedPath + ? getComposedPathSelector(composedPath, configuration.actionNameAttribute) + : '' if (selector) { updateInteractionSelector(event.timeStamp, selector) @@ -265,15 +267,27 @@ function computeClickActionBase( } function getEventTarget(event: MouseEventOnElement): Element { - if (event.composed && isNodeShadowHost(event.target) && typeof event.composedPath === 'function') { - const composedPath = event.composedPath() - if (composedPath.length > 0 && composedPath[0] instanceof Element) { + if (event.composed && isNodeShadowHost(event.target)) { + const composedPath = getEventComposedPath(event) + if (composedPath && composedPath.length > 0 && composedPath[0] instanceof Element) { return composedPath[0] } } return event.target } +function getEventComposedPath(event: MouseEventOnElement) { + if (typeof event.composedPath !== 'function') { + return + } + + try { + return event.composedPath() + } catch { + return + } +} + const enum ClickStatus { // Initial state, the click is still ongoing. ONGOING, diff --git a/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts b/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts new file mode 100644 index 0000000000..6c210148f9 --- /dev/null +++ b/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts @@ -0,0 +1,35 @@ +import { clocksOrigin, Observable } from '@datadog/browser-core' +import { registerCleanupTask, replaceMockable } from '@datadog/browser-core/test' +import type { LocationChange } from '../../browser/locationChangeObservable' +import { LifeCycle, LifeCycleEventType } from '../lifeCycle' +import type { ViewCreatedEvent } from '../view/trackViews' +import { createHooks } from '../hooks' +import { startUrlContexts } from './urlContexts' + +describe('urlContexts LWS compatibility', () => { + it('should use the provided view url when global location is unavailable', () => { + const lifeCycle = new LifeCycle() + const hooks = createHooks() + const locationChangeObservable = new Observable() + const originalLocation = window.location + + replaceMockable(originalLocation, undefined as unknown as Location) + + const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable) + registerCleanupTask(() => { + urlContexts.stop() + }) + + expect(() => { + lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, { + startClocks: clocksOrigin(), + url: 'https://example.com/lightning/page/home', + } as ViewCreatedEvent) + }).not.toThrow() + + expect(urlContexts.findUrl()).toEqual({ + url: 'https://example.com/lightning/page/home', + referrer: document.referrer, + }) + }) +}) diff --git a/packages/rum-core/src/domain/contexts/urlContexts.ts b/packages/rum-core/src/domain/contexts/urlContexts.ts index 530172a84c..c33875ffed 100644 --- a/packages/rum-core/src/domain/contexts/urlContexts.ts +++ b/packages/rum-core/src/domain/contexts/urlContexts.ts @@ -7,6 +7,7 @@ import { DISCARDED, mockable, buildUrl, + getGlobalObject, } from '@datadog/browser-core' import type { LocationChange } from '../../browser/locationChangeObservable' import type { LifeCycle } from '../lifeCycle' @@ -41,8 +42,11 @@ export function startUrlContexts( let previousViewUrl: string | undefined lifeCycle.subscribe(LifeCycleEventType.BEFORE_VIEW_CREATED, ({ startClocks, url }) => { - const locationHref = mockable(location).href + const locationHref = mockable(getGlobalObject().location)?.href const viewUrl = url !== undefined ? buildUrl(url, locationHref).href : locationHref + if (!viewUrl) { + return + } urlContextHistory.add( buildUrlContext({ url: viewUrl, diff --git a/packages/rum-core/src/domain/getComposedPathSelector.spec.ts b/packages/rum-core/src/domain/getComposedPathSelector.spec.ts index 30fe014f20..81ba2754c9 100644 --- a/packages/rum-core/src/domain/getComposedPathSelector.spec.ts +++ b/packages/rum-core/src/domain/getComposedPathSelector.spec.ts @@ -241,6 +241,15 @@ describe('getSelectorFromComposedPath', () => { // tagName for SVG in HTML document is lowercase expect(result).toBe('svg[data-testid="my-svg"];') }) + + it('returns an empty selector when CSS.escape is unavailable', () => { + spyOn(CSS, 'escape').and.throwError('CSS.escape is unavailable') + + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined) + + expect(result).toBe('') + }) }) }) }) diff --git a/packages/rum-core/src/domain/getComposedPathSelector.ts b/packages/rum-core/src/domain/getComposedPathSelector.ts index 5c055c8a92..93dccdfac1 100644 --- a/packages/rum-core/src/domain/getComposedPathSelector.ts +++ b/packages/rum-core/src/domain/getComposedPathSelector.ts @@ -3,6 +3,7 @@ import type { MatchOption } from '@datadog/browser-core' import { STABLE_ATTRIBUTES, isGeneratedValue, + escapeCssValue, getIDSelector, getTagNameSelector, getNthOfTypeSelector, @@ -76,6 +77,9 @@ export function getComposedPathSelector(composedPath: EventTarget[], actionNameA */ function getSelectorStringFromElement(element: Element, allowedAttributes: MatchOption[]): string { const tagName = getTagNameSelector(element) + if (!tagName) { + return '' + } const id = getIDSelector(element) const classes = getElementClassesString(element) const attributes = extractSafeAttributesString(element, allowedAttributes) @@ -88,7 +92,9 @@ function getElementClassesString(element: Element): string { return Array.from(element.classList) .filter((c) => !isGeneratedValue(c)) .sort() - .map((c) => `.${CSS.escape(c)}`) + .map((c) => escapeCssValue(c)) + .filter((c): c is string => c !== undefined) + .map((c) => `.${c}`) .join('') } @@ -123,7 +129,10 @@ function extractSafeAttributesString(element: Element, allowedAttributes: MatchO const attributes = Array.from(element.attributes) for (const attribute of attributes) { if (allowedAttributes.includes(attribute.name)) { - result.push(getAttributeValueSelector(attribute.name, attribute.value)) + const attributeSelector = getAttributeValueSelector(attribute.name, attribute.value) + if (attributeSelector) { + result.push(attributeSelector) + } } } return result.sort().join('') diff --git a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts index e5ea417c8a..cee639eaf4 100644 --- a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts +++ b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts @@ -113,6 +113,12 @@ describe('getSelectorFromElement', () => { it('on tag name', () => { expect(getSelector('>')).toBe('BODY>DIV\\&NBSP\\;SPAN') }) + + it('returns undefined when CSS.escape is unavailable', () => { + spyOn(CSS, 'escape').and.throwError('CSS.escape is unavailable') + + expect(getSelector('
')).toBeUndefined() + }) }) describe('attribute selector', () => { diff --git a/packages/rum-core/src/domain/getSelectorFromElement.ts b/packages/rum-core/src/domain/getSelectorFromElement.ts index 931b31f766..0ef92e79a6 100644 --- a/packages/rum-core/src/domain/getSelectorFromElement.ts +++ b/packages/rum-core/src/domain/getSelectorFromElement.ts @@ -52,6 +52,18 @@ interface SubtreeTarget { target: Element } +export function escapeCssValue(value: string) { + if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') { + return + } + + try { + return CSS.escape(value) + } catch { + return + } +} + export function getSelectorFromElement( targetElement: Element, actionNameAttribute: string | undefined @@ -131,6 +143,9 @@ function getSelectorFromElementWithinSubtree( ) const elementSelector = uniqueSelectorAmongChildren || getPositionSelector(currentElement) + if (!elementSelector) { + return undefined + } currentSelector = combineSelector(elementSelector, currentSelector) currentElement = currentElement.parentElement @@ -152,7 +167,10 @@ export function isGeneratedValue(value: string) { export function getIDSelector(element: Element): string | undefined { if (element.id && !isGeneratedValue(element.id)) { - return `#${CSS.escape(element.id)}` + const escapedId = escapeCssValue(element.id) + if (escapedId) { + return `#${escapedId}` + } } } @@ -167,12 +185,16 @@ function getClassSelector(element: Element): string | undefined { continue } - return `${CSS.escape(element.tagName)}.${CSS.escape(className)}` + const escapedTagName = escapeCssValue(element.tagName) + const escapedClassName = escapeCssValue(className) + if (escapedTagName && escapedClassName) { + return `${escapedTagName}.${escapedClassName}` + } } } -export function getTagNameSelector(element: Element): string { - return CSS.escape(element.tagName) +export function getTagNameSelector(element: Element): string | undefined { + return escapeCssValue(element.tagName) } function getStableAttributeSelector(element: Element, actionNameAttribute: string | undefined): string | undefined { @@ -192,19 +214,27 @@ function getStableAttributeSelector(element: Element, actionNameAttribute: strin function getAttributeSelector(attributeName: string) { if (element.hasAttribute(attributeName)) { - return `${CSS.escape(element.tagName)}${getAttributeValueSelector(attributeName, element.getAttribute(attributeName)!)}` + const escapedTagName = escapeCssValue(element.tagName) + const attributeValueSelector = getAttributeValueSelector(attributeName, element.getAttribute(attributeName)!) + if (escapedTagName && attributeValueSelector) { + return `${escapedTagName}${attributeValueSelector}` + } } } } export function getAttributeValueSelector(attributeName: string, attributeValue: string) { - return `[${attributeName}="${CSS.escape(attributeValue)}"]` + const escapedAttributeValue = escapeCssValue(attributeValue) + if (escapedAttributeValue) { + return `[${attributeName}="${escapedAttributeValue}"]` + } } -function getPositionSelector(element: Element): string { +function getPositionSelector(element: Element): string | undefined { const nthOfType = getNthOfTypeSelector(element) - return `${CSS.escape(element.tagName)}:nth-of-type(${nthOfType})` + const escapedTagName = escapeCssValue(element.tagName) + return escapedTagName ? `${escapedTagName}:nth-of-type(${nthOfType})` : undefined } export function getNthOfTypeSelector(element: Element): number { diff --git a/packages/rum/package.json b/packages/rum/package.json index f0689c7ee1..a693d74f0f 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -18,6 +18,7 @@ "scripts": { "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-rum.js", "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-rum.js", + "build:salesforce": "node ../../scripts/build/build-package.ts --bundle datadog-rum-salesforce.js --entry ./src/entries/salesforce.ts", "prepack": "yarn build" }, "dependencies": { diff --git a/packages/rum/src/domain/salesforce/initConfiguration.spec.ts b/packages/rum/src/domain/salesforce/initConfiguration.spec.ts new file mode 100644 index 0000000000..26e082cd30 --- /dev/null +++ b/packages/rum/src/domain/salesforce/initConfiguration.spec.ts @@ -0,0 +1,65 @@ +import type { RumInitConfiguration } from '@datadog/browser-rum-core' +import { buildSalesforceInitConfiguration } from './initConfiguration' + +describe('salesforce init configuration', () => { + it('forces manual view tracking', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + trackViewsManually: false, + } as RumInitConfiguration) + + expect(initConfiguration.trackViewsManually).toBeTrue() + }) + + it('preserves customer configuration unrelated to view tracking mode', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + service: 'browser-sdk-sandbox', + env: 'dev', + site: 'datadoghq.com', + trackResources: false, + trackUserInteractions: false, + trackLongTasks: false, + sessionReplaySampleRate: 0, + profilingSampleRate: 0, + } as RumInitConfiguration) + + expect(initConfiguration).toEqual( + jasmine.objectContaining({ + applicationId: 'app-id', + clientToken: 'client-token', + service: 'browser-sdk-sandbox', + env: 'dev', + site: 'datadoghq.com', + trackResources: false, + trackUserInteractions: false, + trackLongTasks: false, + sessionReplaySampleRate: 0, + profilingSampleRate: 0, + }) + ) + }) + + it('disables core resource tracking and injects a Salesforce resource plugin by default', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + } as RumInitConfiguration) + + expect(initConfiguration.trackResources).toBeFalse() + expect(initConfiguration.plugins?.map((plugin) => plugin.name)).toContain('salesforce-resource-polling') + }) + + it('does not inject the Salesforce resource plugin when the customer disables resources explicitly', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + trackResources: false, + } as RumInitConfiguration) + + expect(initConfiguration.trackResources).toBeFalse() + expect(initConfiguration.plugins).toBeUndefined() + }) +}) diff --git a/packages/rum/src/domain/salesforce/initConfiguration.ts b/packages/rum/src/domain/salesforce/initConfiguration.ts new file mode 100644 index 0000000000..382d2c509c --- /dev/null +++ b/packages/rum/src/domain/salesforce/initConfiguration.ts @@ -0,0 +1,19 @@ +import type { RumInitConfiguration } from '@datadog/browser-rum-core' +import { createSalesforceResourcePlugin } from './resourcePlugin' + +const SALESFORCE_INIT_DEFAULTS: Pick = { + trackViewsManually: true, +} + +export function buildSalesforceInitConfiguration(initConfiguration: RumInitConfiguration): RumInitConfiguration { + const shouldInjectResourcePlugin = initConfiguration.trackResources !== false + + return { + ...initConfiguration, + ...SALESFORCE_INIT_DEFAULTS, + ...(shouldInjectResourcePlugin && { + trackResources: false, + plugins: [...(initConfiguration.plugins || []), createSalesforceResourcePlugin(initConfiguration)], + }), + } +} diff --git a/packages/rum/src/domain/salesforce/resourcePlugin.spec.ts b/packages/rum/src/domain/salesforce/resourcePlugin.spec.ts new file mode 100644 index 0000000000..643cc4ba49 --- /dev/null +++ b/packages/rum/src/domain/salesforce/resourcePlugin.spec.ts @@ -0,0 +1,233 @@ +import { ResourceType } from '@datadog/browser-core' +import type { RelativeTime } from '@datadog/browser-core' +import { RumEventType } from '@datadog/browser-rum-core' +import { mockDocumentReadyState } from '../../../../rum-core/test' +import type { SalesforceResourcePoll } from './resourcePollChannel' +import { createSalesforceResourcePlugin, startSalesforceResourceTracking } from './resourcePlugin' + +describe('salesforce resource plugin', () => { + it('emits resource events from Salesforce view poll notifications', () => { + const addEvent = jasmine.createSpy() + const pollDriver = createResourcePollDriver() + const entries = [createResourceEntry({ initiatorType: 'fetch', name: 'https://example.com/api/users' })] + + const tracking = startSalesforceResourceTracking({ + addEvent, + configuration: {}, + getNavigationEntry: () => undefined, + subscribeToResourcePolls: pollDriver.subscribe, + }) + + pollDriver.notify({ + currentView: { startRelativeTime: 200 as RelativeTime }, + resourceEntries: entries, + }) + + expect(addEvent).toHaveBeenCalledOnceWith( + 300 as RelativeTime, + jasmine.objectContaining({ + type: RumEventType.RESOURCE, + resource: jasmine.objectContaining({ + type: ResourceType.FETCH, + url: 'https://example.com/api/users', + }), + }), + jasmine.objectContaining({ + performanceEntry: entries[0], + }), + 100 + ) + + pollDriver.notify({ + currentView: { startRelativeTime: 200 as RelativeTime }, + resourceEntries: entries, + }) + + expect(addEvent).toHaveBeenCalledTimes(1) + + pollDriver.notify({ + currentView: { startRelativeTime: 200 as RelativeTime }, + resourceEntries: [entries[0], createResourceEntry({ initiatorType: 'xmlhttprequest', name: 'https://example.com/xhr' })], + }) + + expect(addEvent).toHaveBeenCalledTimes(2) + expect(addEvent.calls.argsFor(1)[1]).toEqual( + jasmine.objectContaining({ + resource: jasmine.objectContaining({ + type: ResourceType.XHR, + url: 'https://example.com/xhr', + }), + }) + ) + + tracking.stop() + }) + + it('waits for a Salesforce view before emitting resources', () => { + const addEvent = jasmine.createSpy() + const pollDriver = createResourcePollDriver() + + startSalesforceResourceTracking({ + addEvent, + configuration: {}, + getNavigationEntry: () => undefined, + subscribeToResourcePolls: pollDriver.subscribe, + }) + + pollDriver.notify({ + currentView: undefined, + resourceEntries: [createResourceEntry({ initiatorType: 'fetch' })], + }) + + expect(addEvent).not.toHaveBeenCalled() + }) + + it('emits the initial document resource when the document becomes interactive after the Salesforce view starts', () => { + const { triggerOnDomLoaded } = mockDocumentReadyState() + const addEvent = jasmine.createSpy() + const pollDriver = createResourcePollDriver() + + startSalesforceResourceTracking({ + addEvent, + configuration: {}, + getNavigationEntry: () => ({ + name: 'https://example.com/', + startTime: 0 as RelativeTime, + responseEnd: 42 as RelativeTime, + fetchStart: 0 as RelativeTime, + workerStart: 0 as RelativeTime, + domainLookupStart: 0 as RelativeTime, + domainLookupEnd: 0 as RelativeTime, + connectStart: 0 as RelativeTime, + secureConnectionStart: 0 as RelativeTime, + connectEnd: 0 as RelativeTime, + requestStart: 0 as RelativeTime, + responseStart: 12 as RelativeTime, + redirectStart: 0 as RelativeTime, + redirectEnd: 0 as RelativeTime, + }), + subscribeToResourcePolls: pollDriver.subscribe, + }) + + pollDriver.notify({ + currentView: { startRelativeTime: 0 as RelativeTime }, + resourceEntries: [], + }) + triggerOnDomLoaded() + + expect(addEvent).toHaveBeenCalledOnceWith( + 42 as RelativeTime, + jasmine.objectContaining({ + resource: jasmine.objectContaining({ + type: ResourceType.DOCUMENT, + url: 'https://example.com/', + }), + }), + jasmine.any(Object), + 42 + ) + }) + + it('does not emit intake requests or invalid timings', () => { + const addEvent = jasmine.createSpy() + const pollDriver = createResourcePollDriver() + + startSalesforceResourceTracking({ + addEvent, + configuration: {}, + getNavigationEntry: () => undefined, + subscribeToResourcePolls: pollDriver.subscribe, + }) + + pollDriver.notify({ + currentView: { startRelativeTime: 0 as RelativeTime }, + resourceEntries: [ + createResourceEntry({ + name: 'https://rum-http-intake.logs.datadoghq.com/api/v2/rum?ddsource=browser&dd-api-key=test&dd-request-id=test', + }), + createResourceEntry({ responseStart: 350 as RelativeTime, responseEnd: 250 as RelativeTime }), + ], + }) + + expect(addEvent).not.toHaveBeenCalled() + }) + + it('injects a Salesforce plugin that starts listening on rum start', () => { + const addEvent = jasmine.createSpy() + const pollDriver = createResourcePollDriver() + const plugin = createSalesforceResourcePlugin( + {}, + { + getNavigationEntry: () => undefined, + subscribeToResourcePolls: pollDriver.subscribe, + } + ) + + plugin.onRumStart!({ addEvent }) + pollDriver.notify({ + currentView: { startRelativeTime: 0 as RelativeTime }, + resourceEntries: [createResourceEntry({ initiatorType: 'fetch' })], + }) + + expect(addEvent).toHaveBeenCalled() + }) +}) + +function createResourcePollDriver() { + let callback: ((poll: SalesforceResourcePoll) => void) | undefined + + return { + subscribe(nextCallback: (poll: SalesforceResourcePoll) => void) { + callback = nextCallback + return { + unsubscribe() { + callback = undefined + }, + } + }, + notify(poll: SalesforceResourcePoll) { + callback?.(poll) + }, + } +} + +function createResourceEntry(overrides: Partial> = {}) { + const entry = { + ...baseResourceEntry(), + ...overrides, + } + + return { + ...entry, + toJSON: () => ({ ...entry }), + } +} + +function baseResourceEntry() { + return { + entryType: 'resource' as const, + initiatorType: 'img', + name: 'https://example.com/image.png', + startTime: 200 as RelativeTime, + duration: 100, + fetchStart: 200 as RelativeTime, + workerStart: 0 as RelativeTime, + domainLookupStart: 200 as RelativeTime, + domainLookupEnd: 200 as RelativeTime, + connectStart: 200 as RelativeTime, + secureConnectionStart: 0 as RelativeTime, + connectEnd: 200 as RelativeTime, + requestStart: 200 as RelativeTime, + responseStart: 250 as RelativeTime, + responseEnd: 300 as RelativeTime, + redirectStart: 0 as RelativeTime, + redirectEnd: 0 as RelativeTime, + decodedBodySize: 10, + encodedBodySize: 10, + transferSize: 10, + responseStatus: 200, + nextHopProtocol: 'h2', + renderBlockingStatus: 'non-blocking', + deliveryType: '' as const, + } +} diff --git a/packages/rum/src/domain/salesforce/resourcePlugin.ts b/packages/rum/src/domain/salesforce/resourcePlugin.ts new file mode 100644 index 0000000000..1b257296d0 --- /dev/null +++ b/packages/rum/src/domain/salesforce/resourcePlugin.ts @@ -0,0 +1,507 @@ +import type { + RumInitConfiguration, + RumPlugin, + OnRumStartOptions, + AllowedRawRumEvent, +} from '@datadog/browser-rum-core' +import { RumEventType } from '@datadog/browser-rum-core' +import type { RelativeTime, TimeStamp, Duration, Subscription } from '@datadog/browser-core' +import { + addEventListener, + buildUrl, + DOM_EVENT, + generateUUID, + getTimeStamp, + isIntakeUrl, + ResourceType, + toServerDuration, +} from '@datadog/browser-core' +import { subscribeToSalesforceResourcePoll } from './resourcePollChannel' +import type { SalesforceResourcePoll, SalesforceResourcePollView } from './resourcePollChannel' + +const MAX_SEEN_RESOURCE_KEYS = 1000 +const INITIAL_DOCUMENT_INITIATOR_TYPE = 'initial_document' +const NOOP_STOP_CALLBACK = () => undefined + +type AddEvent = NonNullable + +interface SalesforceResourceTrackingOptions { + addEvent: AddEvent + configuration: Pick + getNavigationEntry?: () => RumPerformanceNavigationTimingLike | undefined + subscribeToResourcePolls?: (callback: (poll: SalesforceResourcePoll) => void) => Subscription +} + +interface RumPerformanceNavigationTimingLike { + name: string + startTime: RelativeTime + responseEnd: RelativeTime + fetchStart: RelativeTime + workerStart: RelativeTime + domainLookupStart: RelativeTime + domainLookupEnd: RelativeTime + connectStart: RelativeTime + secureConnectionStart: RelativeTime + connectEnd: RelativeTime + requestStart: RelativeTime + responseStart: RelativeTime + redirectStart: RelativeTime + redirectEnd: RelativeTime + decodedBodySize?: number + encodedBodySize?: number + transferSize?: number + nextHopProtocol?: string + renderBlockingStatus?: string + responseStatus?: number +} + +interface RumPerformanceResourceTiming { + entryType: 'resource' + initiatorType: string + responseStatus?: number + name: string + startTime: RelativeTime + duration: Duration + fetchStart: RelativeTime + workerStart: RelativeTime + domainLookupStart: RelativeTime + domainLookupEnd: RelativeTime + connectStart: RelativeTime + secureConnectionStart: RelativeTime + connectEnd: RelativeTime + requestStart: RelativeTime + responseStart: RelativeTime + responseEnd: RelativeTime + redirectStart: RelativeTime + redirectEnd: RelativeTime + decodedBodySize?: number + encodedBodySize?: number + transferSize?: number + nextHopProtocol?: string + renderBlockingStatus?: string + deliveryType?: 'cache' | 'navigational-prefetch' | '' + contentType?: string + traceId?: string + toJSON(): Omit +} + +export function createSalesforceResourcePlugin( + initConfiguration: Pick, + deps?: Omit +): RumPlugin { + return { + name: 'salesforce-resource-polling', + onRumStart({ addEvent }) { + if (!addEvent) { + return + } + + startSalesforceResourceTracking({ + addEvent, + configuration: initConfiguration, + ...deps, + }) + }, + } +} + +export function startSalesforceResourceTracking(options: SalesforceResourceTrackingOptions) { + const getNavigationEntry = options.getNavigationEntry ?? getNavigationResourceEntry + const subscribeToResourcePolls = options.subscribeToResourcePolls ?? subscribeToSalesforceResourcePoll + const seenResourceEntryKeys = createBoundedKeyStore() + let currentView: SalesforceResourcePollView | undefined + + const readyStateSubscription = runOnInteractiveReadyState(options.configuration, () => { + if (!currentView) { + return + } + + const initialDocumentEntry = getInitialDocumentResourceTiming(getNavigationEntry()) + if (initialDocumentEntry && initialDocumentEntry.responseEnd >= currentView.startRelativeTime) { + emitResourceEntry(initialDocumentEntry) + } + }) + + const resourcePollSubscription = subscribeToResourcePolls(({ currentView: trackedView, resourceEntries }) => { + currentView = trackedView + + if (!currentView) { + return + } + + for (const entry of resourceEntries || []) { + if (hasResponseEndedAfterViewStart(entry, currentView.startRelativeTime)) { + emitResourceEntry(entry as RumPerformanceResourceTiming) + } + } + }) + + const resourceTimingBufferFullSubscription = addResourceTimingBufferFullListener(options.configuration, () => { + if (currentView) { + for (const entry of getPerformanceResourceEntries() || []) { + if (hasResponseEndedAfterViewStart(entry, currentView.startRelativeTime)) { + emitResourceEntry(entry) + } + } + } + if ('clearResourceTimings' in performance) { + performance.clearResourceTimings() + } + }) + + function emitResourceEntry(entry: RumPerformanceResourceTiming) { + if (!isAllowedPerformanceResource(entry)) { + return 'invalid' + } + + if (hasSeenResourceEntry(seenResourceEntryKeys, entry)) { + return 'duplicate' + } + + const duration = computeResourceEntryDuration(entry) + const eventTime = entry.responseEnd + const rawEvent: AllowedRawRumEvent = { + date: getTimeStamp(entry.startTime), + type: RumEventType.RESOURCE, + resource: { + id: generateUUID(), + duration: toServerDuration(duration), + type: computeResourceEntryType(entry), + url: entry.name, + status_code: discardZeroStatus(entry.responseStatus), + protocol: computeResourceEntryProtocol(entry), + delivery_type: computeResourceEntryDeliveryType(entry), + render_blocking_status: entry.renderBlockingStatus, + ...computeResourceEntrySize(entry), + ...computeResourceEntryDetails(entry), + }, + _dd: {}, + } + + options.addEvent(eventTime, rawEvent, { performanceEntry: entry }, duration) + rememberResourceEntry(seenResourceEntryKeys, entry) + return 'emitted' + } + + return { + stop() { + readyStateSubscription.stop() + resourcePollSubscription.unsubscribe() + resourceTimingBufferFullSubscription.stop() + }, + } +} + +function addResourceTimingBufferFullListener( + configuration: Pick, + listener: () => void +) { + if (!window.performance || !('addEventListener' in performance)) { + return { stop: NOOP_STOP_CALLBACK } + } + + return addEventListener(configuration, performance, 'resourcetimingbufferfull', listener) +} + +function runOnInteractiveReadyState( + configuration: Pick, + callback: () => void +) { + if (document.readyState === 'interactive' || document.readyState === 'complete') { + callback() + return { stop: NOOP_STOP_CALLBACK } + } + + return addEventListener(configuration, window, DOM_EVENT.DOM_CONTENT_LOADED, callback, { once: true }) +} + +function getPerformanceResourceEntries() { + try { + return performance.getEntriesByType('resource') as RumPerformanceResourceTiming[] + } catch { + return undefined + } +} + +function hasResponseEndedAfterViewStart( + entry: { responseEnd?: number }, + viewStartRelativeTime: RelativeTime +): entry is { responseEnd: RelativeTime } { + return typeof entry.responseEnd === 'number' && entry.responseEnd >= viewStartRelativeTime +} + +function getNavigationResourceEntry(): RumPerformanceNavigationTimingLike | undefined { + if (!window.performance) { + return undefined + } + + const navigationEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + if (navigationEntry) { + return navigationEntry as unknown as RumPerformanceNavigationTimingLike + } + + const timing = performance.timing + if (!timing) { + return undefined + } + + const navigationStart = (timing.navigationStart || performance.timeOrigin) as TimeStamp + const toRelative = (timestamp: number) => + (timestamp === 0 ? 0 : timestamp - navigationStart) as RelativeTime + + return { + name: window.location.href, + startTime: 0 as RelativeTime, + responseEnd: toRelative(timing.responseEnd), + fetchStart: toRelative(timing.fetchStart), + workerStart: 0 as RelativeTime, + domainLookupStart: toRelative(timing.domainLookupStart), + domainLookupEnd: toRelative(timing.domainLookupEnd), + connectStart: toRelative(timing.connectStart), + secureConnectionStart: toRelative(timing.secureConnectionStart), + connectEnd: toRelative(timing.connectEnd), + requestStart: toRelative(timing.requestStart), + responseStart: toRelative(timing.responseStart), + redirectStart: toRelative(timing.redirectStart), + redirectEnd: toRelative(timing.redirectEnd), + decodedBodySize: 0, + encodedBodySize: 0, + transferSize: 0, + } +} + +function getInitialDocumentResourceTiming( + navigationEntry: RumPerformanceNavigationTimingLike | undefined +): RumPerformanceResourceTiming | undefined { + if (!navigationEntry) { + return undefined + } + + const duration = navigationEntry.responseEnd + + return { + ...navigationEntry, + entryType: 'resource', + initiatorType: INITIAL_DOCUMENT_INITIATOR_TYPE, + duration, + toJSON: () => ({ + name: navigationEntry.name, + entryType: 'resource', + startTime: navigationEntry.startTime, + duration, + }), + } +} + +function createBoundedKeyStore() { + return { + keys: new Set(), + queue: [] as string[], + } +} + +function hasSeenResourceEntry(store: ReturnType, entry: RumPerformanceResourceTiming) { + return store.keys.has(getResourceEntryKey(entry)) +} + +function rememberResourceEntry(store: ReturnType, entry: RumPerformanceResourceTiming) { + const key = getResourceEntryKey(entry) + if (store.keys.has(key)) { + return + } + + store.keys.add(key) + store.queue.push(key) + + if (store.queue.length > MAX_SEEN_RESOURCE_KEYS) { + const oldestKey = store.queue.shift() + if (oldestKey) { + store.keys.delete(oldestKey) + } + } +} + +function getResourceEntryKey(entry: RumPerformanceResourceTiming) { + return [entry.name, entry.initiatorType, entry.startTime, entry.responseEnd].join('|') +} + +function isAllowedPerformanceResource(entry: RumPerformanceResourceTiming) { + return isAllowedRequestUrl(entry.name) && hasValidResourceEntryDuration(entry) && hasValidResourceEntryTimings(entry) +} + +function isAllowedRequestUrl(url: string) { + return url && !isIntakeUrl(url) +} + +function discardZeroStatus(statusCode: number | undefined) { + return statusCode === 0 ? undefined : statusCode +} + +function computeResourceEntryType(entry: RumPerformanceResourceTiming) { + if (entry.initiatorType === INITIAL_DOCUMENT_INITIATOR_TYPE) { + return ResourceType.DOCUMENT + } + + if (entry.initiatorType === 'xmlhttprequest') { + return ResourceType.XHR + } + + if (entry.initiatorType === 'fetch') { + return ResourceType.FETCH + } + + if (entry.initiatorType === 'beacon') { + return ResourceType.BEACON + } + + const lowerCasePath = getUrlPath(entry.name)?.toLowerCase() + + if (lowerCasePath?.endsWith('.css')) { + return ResourceType.CSS + } + + if (lowerCasePath?.endsWith('.js')) { + return ResourceType.JS + } + + if (/\.(gif|jpg|jpeg|tiff|png|svg|ico)$/i.test(lowerCasePath || '') || ['image', 'img', 'icon'].includes(entry.initiatorType)) { + return ResourceType.IMAGE + } + + if (/\.(woff|eot|woff2|ttf)$/i.test(lowerCasePath || '')) { + return ResourceType.FONT + } + + if (/\.(mp3|mp4)$/i.test(lowerCasePath || '') || ['audio', 'video'].includes(entry.initiatorType)) { + return ResourceType.MEDIA + } + + return ResourceType.OTHER +} + +function getUrlPath(url: string) { + try { + return buildUrl(url).pathname + } catch { + return undefined + } +} + +function computeResourceEntryDuration(entry: RumPerformanceResourceTiming): Duration { + if (entry.duration === 0 && entry.startTime < entry.responseEnd) { + return (entry.responseEnd - entry.startTime) as Duration + } + + return entry.duration +} + +function computeResourceEntryDetails(entry: RumPerformanceResourceTiming) { + if (!hasValidResourceEntryTimings(entry)) { + return {} + } + + const details = { + download: formatTiming(entry.startTime, entry.responseStart, entry.responseEnd), + first_byte: formatTiming(entry.startTime, entry.requestStart, entry.responseStart), + worker: undefined as ReturnType | undefined, + redirect: undefined as ReturnType | undefined, + dns: undefined as ReturnType | undefined, + connect: undefined as ReturnType | undefined, + ssl: undefined as ReturnType | undefined, + } + + if (0 < entry.workerStart && entry.workerStart < entry.fetchStart) { + details.worker = formatTiming(entry.startTime, entry.workerStart, entry.fetchStart) + } + + if (entry.fetchStart < entry.connectEnd) { + details.connect = formatTiming(entry.startTime, entry.connectStart, entry.connectEnd) + + if (entry.connectStart <= entry.secureConnectionStart && entry.secureConnectionStart <= entry.connectEnd) { + details.ssl = formatTiming(entry.startTime, entry.secureConnectionStart, entry.connectEnd) + } + } + + if (entry.fetchStart < entry.domainLookupEnd) { + details.dns = formatTiming(entry.startTime, entry.domainLookupStart, entry.domainLookupEnd) + } + + if (entry.startTime < entry.redirectEnd) { + details.redirect = formatTiming(entry.startTime, entry.redirectStart, entry.redirectEnd) + } + + return details +} + +function computeResourceEntryProtocol(entry: RumPerformanceResourceTiming) { + return entry.nextHopProtocol === '' ? undefined : entry.nextHopProtocol +} + +function computeResourceEntryDeliveryType(entry: RumPerformanceResourceTiming) { + return entry.deliveryType === '' ? 'other' : entry.deliveryType +} + +function computeResourceEntrySize(entry: RumPerformanceResourceTiming) { + if (entry.startTime < entry.responseStart) { + return { + size: entry.decodedBodySize, + encoded_body_size: entry.encodedBodySize, + decoded_body_size: entry.decodedBodySize, + transfer_size: entry.transferSize, + } + } + + return { + size: undefined, + encoded_body_size: undefined, + decoded_body_size: undefined, + transfer_size: undefined, + } +} + +function hasValidResourceEntryDuration(entry: RumPerformanceResourceTiming) { + return entry.duration >= 0 +} + +function hasValidResourceEntryTimings(entry: RumPerformanceResourceTiming) { + const commonTimings = [ + entry.startTime, + entry.fetchStart, + entry.domainLookupStart, + entry.domainLookupEnd, + entry.connectStart, + entry.connectEnd, + entry.requestStart, + entry.responseStart, + entry.responseEnd, + ] + + if (!areInOrder(commonTimings)) { + return false + } + + if (entry.redirectEnd <= entry.startTime) { + return true + } + + return areInOrder([entry.startTime, entry.redirectStart, entry.redirectEnd, entry.fetchStart]) +} + +function areInOrder(values: number[]) { + for (let index = 1; index < values.length; index += 1) { + if (values[index - 1] > values[index]) { + return false + } + } + + return true +} + +function formatTiming(origin: RelativeTime, start: RelativeTime, end: RelativeTime) { + if (origin <= start && start <= end) { + return { + duration: toServerDuration((end - start) as Duration), + start: toServerDuration((start - origin) as Duration), + } + } +} diff --git a/packages/rum/src/domain/salesforce/resourcePollChannel.spec.ts b/packages/rum/src/domain/salesforce/resourcePollChannel.spec.ts new file mode 100644 index 0000000000..1c7554bfa0 --- /dev/null +++ b/packages/rum/src/domain/salesforce/resourcePollChannel.spec.ts @@ -0,0 +1,20 @@ +import { notifySalesforceResourcePoll, subscribeToSalesforceResourcePoll } from './resourcePollChannel' + +describe('salesforce resource poll channel', () => { + it('notifies subscribers with the latest poll payload', () => { + const callback = jasmine.createSpy() + const subscription = subscribeToSalesforceResourcePoll(callback) + + notifySalesforceResourcePoll({ + currentView: { startRelativeTime: 123 }, + resourceEntries: [{ responseEnd: 456 }], + }) + + expect(callback).toHaveBeenCalledOnceWith({ + currentView: { startRelativeTime: 123 }, + resourceEntries: [{ responseEnd: 456 }], + }) + + subscription.unsubscribe() + }) +}) diff --git a/packages/rum/src/domain/salesforce/resourcePollChannel.ts b/packages/rum/src/domain/salesforce/resourcePollChannel.ts new file mode 100644 index 0000000000..6c4eabb6ac --- /dev/null +++ b/packages/rum/src/domain/salesforce/resourcePollChannel.ts @@ -0,0 +1,25 @@ +import type { RelativeTime, Subscription } from '@datadog/browser-core' +import { Observable } from '@datadog/browser-core' + +export interface SalesforceResourcePollView { + startRelativeTime: RelativeTime +} + +export interface SalesforceResourcePollEntry { + responseEnd?: number +} + +export interface SalesforceResourcePoll { + currentView: SalesforceResourcePollView | undefined + resourceEntries: SalesforceResourcePollEntry[] | undefined +} + +const salesforceResourcePollObservable = new Observable() + +export function notifySalesforceResourcePoll(poll: SalesforceResourcePoll) { + salesforceResourcePollObservable.notify(poll) +} + +export function subscribeToSalesforceResourcePoll(callback: (poll: SalesforceResourcePoll) => void): Subscription { + return salesforceResourcePollObservable.subscribe(callback) +} diff --git a/packages/rum/src/domain/salesforce/viewTracker.spec.ts b/packages/rum/src/domain/salesforce/viewTracker.spec.ts new file mode 100644 index 0000000000..db836f0b09 --- /dev/null +++ b/packages/rum/src/domain/salesforce/viewTracker.spec.ts @@ -0,0 +1,239 @@ +import type { Clock } from '@datadog/browser-core/test' +import { mockClock } from '@datadog/browser-core/test' +import { subscribeToSalesforceResourcePoll } from './resourcePollChannel' +import { startSalesforceViewTracking } from './viewTracker' + +describe('salesforce view tracker', () => { + let clock: Clock + let startView: jasmine.Spy + let setViewLoadingTime: jasmine.Spy + let location: { pathname?: string; href?: string } | undefined + let performanceEntries: Array<{ responseEnd?: number }> + + beforeEach(() => { + clock = mockClock() + startView = jasmine.createSpy() + setViewLoadingTime = jasmine.createSpy() + location = { + pathname: '/lightning/page/home', + href: 'https://example.lightning.force.com/lightning/page/home', + } + performanceEntries = [] + }) + + it('starts the current Lightning view on bootstrap', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + }) + + expect(startView).toHaveBeenCalledOnceWith({ + name: '/lightning/page/home', + url: 'https://example.lightning.force.com/lightning/page/home', + }) + }) + + it('does not duplicate the same pathname when only query string or hash changes', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + location = { + pathname: '/lightning/page/home/', + href: 'https://example.lightning.force.com/lightning/page/home?foo=bar#hash', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(1) + }) + + it('starts a new view when polling detects a Lightning pathname change', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(2) + expect(startView.calls.argsFor(1)).toEqual([ + { + name: '/lightning/n/Product_Explorer', + url: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + }, + ]) + }) + + it('keeps polling when location is temporarily unavailable', () => { + location = undefined + + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + expect(startView).not.toHaveBeenCalled() + + location = { + pathname: '/lightning/page/home', + href: 'https://example.lightning.force.com/lightning/page/home', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledOnceWith({ + name: '/lightning/page/home', + url: 'https://example.lightning.force.com/lightning/page/home', + }) + }) + + it('stops polling', () => { + const subscription = startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + subscription.stop() + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(1) + }) + + it('sets the view loading time after one idle polling interval', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + performanceEntries = [{ responseEnd: 100 }] + clock.tick(500) + + expect(setViewLoadingTime).not.toHaveBeenCalled() + + clock.tick(500) + + expect(setViewLoadingTime).toHaveBeenCalledOnceWith() + }) + + it('replaces the pending loading time candidate when a later resource is detected', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + performanceEntries = [{ responseEnd: 100 }] + clock.tick(500) + + performanceEntries = [{ responseEnd: 100 }, { responseEnd: 700 }] + clock.tick(500) + + expect(setViewLoadingTime).not.toHaveBeenCalled() + + clock.tick(500) + + expect(setViewLoadingTime).toHaveBeenCalledOnceWith() + }) + + it('resets the resource timing state when a new Salesforce view starts', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + performanceEntries = [{ responseEnd: 100 }] + clock.tick(500) + clock.tick(500) + + expect(setViewLoadingTime).toHaveBeenCalledOnceWith() + + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + performanceEntries = [{ responseEnd: 100 }, { responseEnd: 1700 }] + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(2) + expect(setViewLoadingTime).toHaveBeenCalledTimes(1) + + clock.tick(500) + + expect(setViewLoadingTime).toHaveBeenCalledTimes(2) + expect(setViewLoadingTime.calls.argsFor(1)).toEqual([]) + }) + + it('ignores loading time collection when performance entries are unavailable', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => undefined, + pollInterval: 500, + }) + + clock.tick(1000) + + expect(setViewLoadingTime).not.toHaveBeenCalled() + }) + + it('publishes the latest tracked Salesforce view and performance entries on each poll', () => { + const polls: Array<{ + currentView: { startRelativeTime: number } | undefined + resourceEntries: Array<{ responseEnd?: number }> | undefined + }> = [] + const subscription = subscribeToSalesforceResourcePoll((poll) => { + polls.push(poll) + }) + + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView, setViewLoadingTime }), + getLocation: () => location, + getPerformanceEntries: () => performanceEntries, + pollInterval: 500, + }) + + expect(polls[0]).toEqual({ + currentView: { startRelativeTime: jasmine.any(Number) }, + resourceEntries: [], + }) + + const initialViewStart = polls[0].currentView!.startRelativeTime + + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + performanceEntries = [{ responseEnd: 700 }] + clock.tick(500) + + expect(polls[polls.length - 1]).toEqual({ + currentView: { startRelativeTime: jasmine.any(Number) }, + resourceEntries: [{ responseEnd: 700 }], + }) + expect(polls[polls.length - 1].currentView!.startRelativeTime).toBeGreaterThan(initialViewStart) + + subscription.unsubscribe() + }) +}) diff --git a/packages/rum/src/domain/salesforce/viewTracker.ts b/packages/rum/src/domain/salesforce/viewTracker.ts new file mode 100644 index 0000000000..0a1bdb9721 --- /dev/null +++ b/packages/rum/src/domain/salesforce/viewTracker.ts @@ -0,0 +1,217 @@ +import { buildUrl, clearInterval, relativeNow, setInterval } from '@datadog/browser-core' +import type { RelativeTime, TimeoutId } from '@datadog/browser-core' +import type { RumPublicApi, ViewOptions } from '@datadog/browser-rum-core' +import { notifySalesforceResourcePoll } from './resourcePollChannel' + +export interface SalesforceLocation { + pathname?: string + href?: string +} + +export interface SalesforcePerformanceResourceTiming { + responseEnd?: number +} + +interface StartSalesforceViewTrackingOptions { + getRumPublicApi: () => Pick | undefined + getLocation?: () => SalesforceLocation | undefined + getPerformanceEntries?: () => SalesforcePerformanceResourceTiming[] | undefined + getCurrentRelativeTime?: () => RelativeTime + pollInterval?: number +} + +interface SalesforceView { + key: string + url?: string +} + +interface TrackedSalesforceView extends SalesforceView { + startRelativeTime: RelativeTime + latestLoadingTimeResponseEnd?: RelativeTime + isLoadingTimeFinalized: boolean +} + +const DEFAULT_LOCATION_POLL_INTERVAL = 500 + +export function startSalesforceViewTracking(options: StartSalesforceViewTrackingOptions) { + const getLocation = options.getLocation ?? getNavigationLocation + const getPerformanceEntries = options.getPerformanceEntries ?? getResourcePerformanceEntries + const getCurrentRelativeTime = options.getCurrentRelativeTime ?? relativeNow + const pollInterval = options.pollInterval ?? DEFAULT_LOCATION_POLL_INTERVAL + + let lastEmittedRouteKey: string | undefined + let pollIntervalId: TimeoutId | undefined + let trackedView: TrackedSalesforceView | undefined + + trackSalesforceView() + pollIntervalId = setInterval(trackSalesforceView, pollInterval) + + // We currently use the poll completion time as an approximate loading time. + // If we want the exact resource completion timestamp later, we can pass the latest + // `responseEnd` converted to an absolute time to `setViewLoadingTime(time)`. + function trackSalesforceView() { + const performanceEntries = getPerformanceEntries() + const currentView = resolveCurrentView(getLocation()) + + if (currentView && currentView.key !== lastEmittedRouteKey) { + const rumPublicApi = options.getRumPublicApi() + + if (rumPublicApi) { + rumPublicApi.startView(toViewOptions(currentView)) + trackedView = { + ...currentView, + startRelativeTime: getCurrentRelativeTime(), + isLoadingTimeFinalized: false, + } + lastEmittedRouteKey = currentView.key + } + } + + if (!trackedView || trackedView.isLoadingTimeFinalized) { + notifySalesforceResourcePoll({ + currentView: trackedView && { startRelativeTime: trackedView.startRelativeTime }, + resourceEntries: performanceEntries, + }) + return + } + + const latestResponseEnd = getLatestViewResourceResponseEnd(performanceEntries, trackedView.startRelativeTime) + + if ( + latestResponseEnd !== undefined && + (!trackedView.latestLoadingTimeResponseEnd || latestResponseEnd > trackedView.latestLoadingTimeResponseEnd) + ) { + trackedView.latestLoadingTimeResponseEnd = latestResponseEnd + notifySalesforceResourcePoll({ + currentView: { startRelativeTime: trackedView.startRelativeTime }, + resourceEntries: performanceEntries, + }) + return + } + + if (trackedView.latestLoadingTimeResponseEnd !== undefined) { + options.getRumPublicApi()?.setViewLoadingTime() + trackedView.isLoadingTimeFinalized = true + } + + notifySalesforceResourcePoll({ + currentView: { startRelativeTime: trackedView.startRelativeTime }, + resourceEntries: performanceEntries, + }) + } + + return { + stop() { + clearInterval(pollIntervalId) + pollIntervalId = undefined + }, + } +} + +function getNavigationLocation(): SalesforceLocation | undefined { + try { + return { + href: window.location.href, + pathname: window.location.pathname, + } + } catch { + return undefined + } +} + +function getResourcePerformanceEntries(): SalesforcePerformanceResourceTiming[] | undefined { + try { + return window.performance.getEntriesByType('resource') as SalesforcePerformanceResourceTiming[] + } catch { + return undefined + } +} + +function getLatestViewResourceResponseEnd( + entries: SalesforcePerformanceResourceTiming[] | undefined, + viewStartRelativeTime: RelativeTime +) { + if (!entries) { + return undefined + } + + let latestResponseEnd: RelativeTime | undefined + + for (const entry of entries) { + const responseEnd = entry.responseEnd + + if (typeof responseEnd !== 'number' || responseEnd < viewStartRelativeTime) { + continue + } + + if (latestResponseEnd === undefined || responseEnd > latestResponseEnd) { + latestResponseEnd = responseEnd as RelativeTime + } + } + + return latestResponseEnd +} + +function resolveCurrentView(location: SalesforceLocation | undefined): SalesforceView | undefined { + if (!location) { + return undefined + } + + const url = normalizeLocationHref(location.href) + const key = normalizePathname(location.pathname) ?? getPathnameFromHref(url) + + if (!key) { + return undefined + } + + return { + key, + url, + } +} + +function toViewOptions(view: SalesforceView): ViewOptions { + return view.url ? { name: view.key, url: view.url } : { name: view.key } +} + +function getPathnameFromHref(href: string | undefined) { + if (!href) { + return undefined + } + + try { + return normalizePathname(buildUrl(href).pathname) + } catch { + return undefined + } +} + +function normalizePathname(pathname: unknown) { + if (typeof pathname !== 'string' || !pathname.trim()) { + return undefined + } + + let normalizedPathname = pathname.trim() + + if (!normalizedPathname.startsWith('/')) { + normalizedPathname = `/${normalizedPathname}` + } + + if (normalizedPathname.length > 1) { + normalizedPathname = normalizedPathname.replace(/\/+$/, '') + } + + return normalizedPathname || '/' +} + +function normalizeLocationHref(href: unknown) { + if (typeof href !== 'string' || !href.trim()) { + return undefined + } + + try { + return buildUrl(href).href + } catch { + return undefined + } +} diff --git a/packages/rum/src/entries/salesforce.ts b/packages/rum/src/entries/salesforce.ts new file mode 100644 index 0000000000..a0dafca0d7 --- /dev/null +++ b/packages/rum/src/entries/salesforce.ts @@ -0,0 +1,105 @@ +import { defineGlobal, getGlobalObject } from '@datadog/browser-core' +import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core' +import { makeRumPublicApi } from '@datadog/browser-rum-core' +import { makeProfilerApi } from '../boot/profilerApi' +import { makeRecorderApi } from '../boot/recorderApi' +import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate' +import { buildSalesforceInitConfiguration } from '../domain/salesforce/initConfiguration' +import { startSalesforceViewTracking } from '../domain/salesforce/viewTracker' + +export type { + User, + Account, + TraceContextInjection, + SessionPersistence, + TrackingConsent, + MatchOption, + ProxyFn, + Site, + Context, + ContextValue, + ContextArray, + RumInternalContext, +} from '@datadog/browser-core' +export { DefaultPrivacyLevel } from '@datadog/browser-core' + +/** + * @deprecated Use {@link DatadogRum} instead + */ +export type RumGlobal = RumPublicApi + +export type { + RumPublicApi as DatadogRum, + RumInitConfiguration, + RumBeforeSend, + ViewOptions, + StartRecordingOptions, + AddDurationVitalOptions, + DurationVitalOptions, + DurationVitalReference, + TracingOption, + RumPlugin, + OnRumStartOptions, + PropagatorType, + FeatureFlagsForEvents, + MatchHeader, + CommonProperties, + RumEvent, + RumActionEvent, + RumErrorEvent, + RumLongTaskEvent, + RumResourceEvent, + RumViewEvent, + RumVitalEvent, + RumEventDomainContext, + RumViewEventDomainContext, + RumErrorEventDomainContext, + RumActionEventDomainContext, + RumVitalEventDomainContext, + RumFetchResourceEventDomainContext, + RumXhrResourceEventDomainContext, + RumOtherResourceEventDomainContext, + RumLongTaskEventDomainContext, +} from '@datadog/browser-rum-core' + +export { DEFAULT_TRACKED_RESOURCE_HEADERS } from '@datadog/browser-rum-core' + +const salesforceGlobal = getGlobalObject() + +const recorderApi = makeRecorderApi() + +const profilerApi = makeProfilerApi() + +export const datadogRum = createSalesforceDatadogRum( + makeRumPublicApi(recorderApi, profilerApi, { + startDeflateWorker, + createDeflateEncoder, + sdkName: 'rum', + }) +) + +interface BrowserWindow extends Window { + DD_RUM?: RumPublicApi +} + +defineGlobal(salesforceGlobal, 'DD_RUM', datadogRum) + +function createSalesforceDatadogRum(baseRum: RumPublicApi): RumPublicApi { + const baseInit = baseRum.init + let stopSalesforceViewTracking: (() => void) | undefined + + baseRum.init = (initConfiguration: RumInitConfiguration) => { + if (!stopSalesforceViewTracking) { + const salesforceViewTracking = startSalesforceViewTracking({ + getRumPublicApi: () => baseRum, + }) + stopSalesforceViewTracking = () => salesforceViewTracking.stop() + } + + const salesforceInitConfiguration = buildSalesforceInitConfiguration(initConfiguration) + + baseInit(salesforceInitConfiguration) + } + + return baseRum +} diff --git a/rum-events-format b/rum-events-format index 4f4ab2c504..5a80fb9a3c 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 4f4ab2c50456d688ae228904ced4c68aa382ae4c +Subproject commit 5a80fb9a3c054b28fba195fd301cadb094bccef8 diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index a60b5ff78e..d2435bc61c 100644 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -19,6 +19,12 @@ runMain(async () => { bundle: { type: 'string', }, + entry: { + type: 'string', + }, + 'single-bundle': { + type: 'boolean', + }, verbose: { type: 'boolean', default: false, @@ -43,7 +49,9 @@ runMain(async () => { if (values.bundle) { printLog('Building bundle...') await buildBundle({ + entry: values.entry ?? './src/entries/main.ts', filename: values.bundle, + singleBundle: !!values['single-bundle'], verbose: values.verbose, }) } @@ -51,14 +59,25 @@ runMain(async () => { printLog('Done.') }) -async function buildBundle({ filename, verbose }: { filename: string; verbose: boolean }) { +async function buildBundle({ + entry, + filename, + singleBundle, + verbose, +}: { + entry: string + filename: string + singleBundle: boolean + verbose: boolean +}) { await fs.rm('./bundle', { recursive: true, force: true }) return new Promise((resolve, reject) => { webpack( webpackBase({ mode: 'production', - entry: './src/entries/main.ts', + entry, filename, + plugins: singleBundle ? [new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })] : undefined, }), (error, stats) => { if (error) { diff --git a/test/e2e/lib/framework/index.ts b/test/e2e/lib/framework/index.ts index 5e275e1610..d847cf9b08 100644 --- a/test/e2e/lib/framework/index.ts +++ b/test/e2e/lib/framework/index.ts @@ -12,6 +12,7 @@ export { microfrontendSetup, } from './pageSetups' export { IntakeRegistry } from './intakeRegistry' +export { createIntakeProxyMiddleware } from './intakeProxyMiddleware' export { getTestServers, waitForServersIdle } from './httpServers' export { flushEvents } from './flushEvents' export { waitForRequests } from './waitForRequests' diff --git a/test/e2e/lib/framework/intakeProxyMiddleware.ts b/test/e2e/lib/framework/intakeProxyMiddleware.ts index 4cfc0dbf24..83b0a01135 100644 --- a/test/e2e/lib/framework/intakeProxyMiddleware.ts +++ b/test/e2e/lib/framework/intakeProxyMiddleware.ts @@ -62,16 +62,18 @@ interface IntakeRequestInfos { interface IntakeProxyOptions { onRequest?: (request: IntakeRequest) => void + forward?: boolean } export function createIntakeProxyMiddleware(options: IntakeProxyOptions): express.RequestHandler { return async (req, res) => { const infos = computeIntakeRequestInfos(req) + const shouldForward = options.forward ?? true try { const [intakeRequest] = await Promise.all([ readIntakeRequest(req, infos), - !infos.isBridge && forwardIntakeRequestToDatadog(req), + shouldForward && !infos.isBridge && forwardIntakeRequestToDatadog(req), ]) options.onRequest?.(intakeRequest) } catch (error) { diff --git a/test/e2e/playwright.salesforce.config.ts b/test/e2e/playwright.salesforce.config.ts new file mode 100644 index 0000000000..001f82d063 --- /dev/null +++ b/test/e2e/playwright.salesforce.config.ts @@ -0,0 +1,51 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +const lightningStorageState = path.resolve(__dirname, 'test-results/.auth/salesforce-lightning.json') + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + testDir: './salesforce', + testMatch: ['**/*.spec.ts'], + tsconfig: './tsconfig.json', + fullyParallel: false, + timeout: 60_000, + workers: 1, + reporter: [['line'], ['./noticeReporter.ts'], ['html']], + use: { + // So we can send to the intake from the Salesforce Domain. + bypassCSP: true, + // We'll ignore HTTPS errors since we're using self-signed certificates. + ignoreHTTPSErrors: true, + // So we can send to the intake from the Salesforce Domain. + permissions: ['local-network-access'], + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'setup', + testMatch: ['**/auth.setup.ts'], + use: { + ...devices['Desktop Chrome'], + trace: 'off', + screenshot: 'off', + video: 'off', + }, + }, + { + name: 'experience-chromium', + dependencies: ['setup'], + testMatch: ['**/experienceCloud.spec.ts'], + use: devices['Desktop Chrome'], + }, + { + name: 'lightning-chromium', + dependencies: ['setup'], + testMatch: ['**/lightningExperience.spec.ts'], + use: { + ...devices['Desktop Chrome'], + storageState: lightningStorageState, + }, + }, + ], +}) diff --git a/test/e2e/salesforce/auth.setup.ts b/test/e2e/salesforce/auth.setup.ts new file mode 100644 index 0000000000..5193117462 --- /dev/null +++ b/test/e2e/salesforce/auth.setup.ts @@ -0,0 +1,15 @@ +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test as setup } from '@playwright/test' +import { getSalesforceTargets } from './support/salesforceTargets' + +const authDirectory = path.resolve(__dirname, '../test-results/.auth') +const lightningStorageState = path.join(authDirectory, 'salesforce-lightning.json') + +setup('authenticate Lightning Experience via sf org open', async ({ page }) => { + const { loginUrl } = getSalesforceTargets() + await page.goto(loginUrl, { waitUntil: 'commit' }) + await page.waitForURL('**/lightning/page/home', { timeout: 30_000 }) + mkdirSync(authDirectory, { recursive: true }) + await page.context().storageState({ path: lightningStorageState }) +}) diff --git a/test/e2e/salesforce/experienceCloud.spec.ts b/test/e2e/salesforce/experienceCloud.spec.ts new file mode 100644 index 0000000000..ec409f84c2 --- /dev/null +++ b/test/e2e/salesforce/experienceCloud.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { + flushSalesforceRumEvents, + installSalesforceRumProxy, + startSalesforceIntakeProxy, + waitForRumProxyInitialization, +} from './support/salesforceIntakeProxy' +import { getSalesforceTargets } from './support/salesforceTargets' + +test('experience cloud emits an initial home view and a route-change Product Explorer view', async ({ page }) => { + const targets = getSalesforceTargets() + const intakeProxy = await startSalesforceIntakeProxy() + const productExplorerContent = page.getByText('DYNAMO X1') + + try { + await installSalesforceRumProxy(page.context(), intakeProxy.origin) + await page.goto(targets.experienceUrl, { waitUntil: 'domcontentloaded' }) + await waitForRumProxyInitialization(page, intakeProxy.origin) + const productExplorerLink = page.getByRole('link', { name: 'Product Explorer' }) + + await expect(productExplorerLink).toBeVisible() + await expect(page).toHaveURL(/\/ebikes\/s\/?$/) + + await productExplorerLink.click() + await expect(page).toHaveURL(targets.experienceProductExplorerUrl) + await expect(productExplorerContent).toBeVisible() + + await flushSalesforceRumEvents(page) + + await intakeProxy.waitForViews([ + { path: '/ebikes/s', loadingType: 'initial_load' }, + { path: '/ebikes/s/product-explorer', loadingType: 'route_change', requireLoadingTime: true }, + ]) + } finally { + await intakeProxy.stop() + } +}) diff --git a/test/e2e/salesforce/lightningExperience.spec.ts b/test/e2e/salesforce/lightningExperience.spec.ts new file mode 100644 index 0000000000..22a9b51858 --- /dev/null +++ b/test/e2e/salesforce/lightningExperience.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { + flushSalesforceRumEvents, + installSalesforceRumProxy, + startSalesforceIntakeProxy, + waitForRumProxyInitialization, +} from './support/salesforceIntakeProxy' +import { getSalesforceTargets } from './support/salesforceTargets' + +test('lightning experience emits an initial home view and a route-change Product Explorer view', async ({ page }) => { + const targets = getSalesforceTargets() + const intakeProxy = await startSalesforceIntakeProxy() + const productExplorerContent = page.getByText('DYNAMO X1') + + try { + await installSalesforceRumProxy(page.context(), intakeProxy.origin) + await page.goto(targets.lightningHomeUrl, { waitUntil: 'domcontentloaded' }) + await waitForRumProxyInitialization(page, intakeProxy.origin) + const productExplorerLink = page.getByRole('link', { name: 'Product Explorer' }) + + await expect(productExplorerLink).toBeVisible() + await expect(page).toHaveURL(targets.lightningHomeUrl) + + await productExplorerLink.click() + await expect(page).toHaveURL(targets.lightningProductExplorerUrl) + await expect(productExplorerContent).toBeVisible() + + await flushSalesforceRumEvents(page) + + await intakeProxy.waitForViews([ + { path: '/lightning/page/home', loadingType: 'initial_load' }, + { path: '/lightning/n/Product_Explorer', loadingType: 'route_change', requireLoadingTime: true }, + ]) + } finally { + await intakeProxy.stop() + } +}) diff --git a/test/e2e/salesforce/support/salesforceIntakeProxy.ts b/test/e2e/salesforce/support/salesforceIntakeProxy.ts new file mode 100644 index 0000000000..a0130926c7 --- /dev/null +++ b/test/e2e/salesforce/support/salesforceIntakeProxy.ts @@ -0,0 +1,355 @@ +import https from 'node:https' +import type http from 'node:http' +import type { AddressInfo } from 'node:net' +import express from 'express' +import forge from 'node-forge' +import type { BrowserContext, Page } from '@playwright/test' +import { createIntakeProxyMiddleware, IntakeRegistry } from '../../lib/framework' + +const SALESFORCE_INTAKE_PROXY_PORT = 9242 +const SALESFORCE_INTAKE_PROXY_IDLE_DELAY = 200 +const SALESFORCE_INTAKE_PROXY_CLOSE_DELAY = 1_000 + +export interface ExpectedSalesforceRumView { + path: string + loadingType: string + requireLoadingTime?: boolean +} + +export interface SalesforceIntakeProxy { + origin: string + intakeRegistry: IntakeRegistry + waitForViews: (expectedViews: ExpectedSalesforceRumView[], options?: { timeout?: number }) => Promise + waitForIdle: () => Promise + stop: () => Promise +} + +export async function startSalesforceIntakeProxy(): Promise { + const intakeRegistry = new IntakeRegistry() + const waiters = new Set() + const idleWaiter = createIdleWaiter() + const app = express() + + app.use((_req, res, next) => { + idleWaiter.trackResponse(res) + next() + }) + app.use(allowCrossOriginLoopbackRequests()) + app.post( + '/', + createIntakeProxyMiddleware({ + forward: false, + onRequest: (request) => { + intakeRegistry.push(request) + notifyWaiters(waiters, intakeRegistry) + }, + }) + ) + + const server = https.createServer(generateSelfSignedCertificate(), app) + const origin = await listen(server) + + return { + origin, + intakeRegistry, + waitForViews: (expectedViews, options) => waitForViews(waiters, intakeRegistry, expectedViews, options), + waitForIdle: () => idleWaiter.wait(), + stop: () => close(server), + } +} + +function allowCrossOriginLoopbackRequests(): express.RequestHandler { + return (req, res, next) => { + const origin = req.header('origin') + const requestedHeaders = req.header('access-control-request-headers') + + res.header('Access-Control-Allow-Origin', origin || '*') + res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + res.header('Access-Control-Allow-Headers', requestedHeaders || 'content-type') + res.header('Access-Control-Allow-Private-Network', 'true') + res.header('Vary', 'Origin, Access-Control-Request-Headers') + + if (req.method === 'OPTIONS') { + res.sendStatus(204) + return + } + + next() + } +} + +export async function installSalesforceRumProxy(browserContext: BrowserContext, proxyOrigin: string) { + await browserContext.addInitScript((proxy) => { + const wrappedFlag = '__ddSalesforceRumProxyWrapped__' + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null + } + + function hasRumInit( + value: unknown + ): value is Record & { init: (configuration?: unknown, ...args: unknown[]) => unknown } { + return isRecord(value) && typeof value.init === 'function' + } + + function wrapRum(rum: unknown) { + if (!hasRumInit(rum) || rum[wrappedFlag]) { + return rum + } + + const originalInit = rum.init + Object.defineProperty(rum, wrappedFlag, { + configurable: true, + value: true, + }) + rum.init = function (this: unknown, configuration?: unknown, ...args: unknown[]) { + return originalInit.call(this, { ...(isRecord(configuration) ? configuration : {}), proxy }, ...args) + } + return rum + } + + let ddRum = wrapRum(window.DD_RUM) + + try { + Object.defineProperty(window, 'DD_RUM', { + configurable: true, + get() { + return ddRum + }, + set(value) { + ddRum = wrapRum(value) + }, + }) + } catch { + wrapRum(window.DD_RUM) + } + }, proxyOrigin) +} + +export async function waitForRumProxyInitialization(page: Page, proxyOrigin: string) { + await page.waitForFunction( + (expectedProxy) => window.DD_RUM?.getInitConfiguration?.()?.proxy === expectedProxy, + proxyOrigin + ) +} + +export async function flushSalesforceRumEvents(page: Page) { + await page.evaluate(() => { + const beforeUnloadEvent = new Event('beforeunload') as Event & { __ddIsTrusted?: boolean } + beforeUnloadEvent.__ddIsTrusted = true + window.dispatchEvent(beforeUnloadEvent) + }) +} + +interface ViewWaiter { + expectedViews: ExpectedSalesforceRumView[] + timeoutId: NodeJS.Timeout + resolve: () => void + reject: (error: Error) => void +} + +function waitForViews( + waiters: Set, + intakeRegistry: IntakeRegistry, + expectedViews: ExpectedSalesforceRumView[], + { timeout = 10_000 } = {} +) { + return new Promise((resolve, reject) => { + const waiter: ViewWaiter = { + expectedViews, + timeoutId: setTimeout(() => { + waiters.delete(waiter) + reject(createTimeoutError(intakeRegistry, expectedViews)) + }, timeout), + resolve, + reject, + } + + waiters.add(waiter) + notifyWaiters(waiters, intakeRegistry) + }) +} + +function notifyWaiters(waiters: Set, intakeRegistry: IntakeRegistry) { + for (const waiter of waiters) { + if (findMissingViews(intakeRegistry, waiter.expectedViews).length === 0) { + clearTimeout(waiter.timeoutId) + waiters.delete(waiter) + waiter.resolve() + } + } +} + +function findMissingViews(intakeRegistry: IntakeRegistry, expectedViews: ExpectedSalesforceRumView[]) { + return expectedViews.filter( + ({ path, loadingType, requireLoadingTime }) => + !intakeRegistry.rumViewEvents.some( + (event) => + normalizePathname(event.view.url) === normalizePathname(path) && + event.view.loading_type === loadingType && + (!requireLoadingTime || hasLoadingTime(event)) + ) + ) +} + +function createTimeoutError(intakeRegistry: IntakeRegistry, expectedViews: ExpectedSalesforceRumView[]) { + const expected = expectedViews.map(formatExpectedView).join(', ') + const captured = intakeRegistry.rumViewEvents.map(formatCapturedView).join(', ') || 'none' + + return new Error(`Timed out waiting for Salesforce RUM views. Expected: ${expected}. Captured: ${captured}.`) +} + +function formatExpectedView({ path, loadingType, requireLoadingTime }: ExpectedSalesforceRumView) { + return `${path} (${loadingType}${requireLoadingTime ? ', loading_time' : ''})` +} + +function formatCapturedView(event: IntakeRegistry['rumViewEvents'][number]) { + return `${normalizePathname(event.view.url) || 'unknown'} (${event.view.loading_type || 'unknown'}, loading_time=${ + typeof event.view.loading_time === 'number' ? event.view.loading_time : 'missing' + })` +} + +function hasLoadingTime(event: IntakeRegistry['rumViewEvents'][number]) { + return typeof event.view.loading_time === 'number' && event.view.loading_time > 0 +} + +function normalizePathname(candidate: unknown) { + if (typeof candidate !== 'string' || !candidate.trim()) { + return undefined + } + + try { + const pathname = new URL(candidate, 'https://example.org').pathname + return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname + } catch { + return undefined + } +} + +function listen(server: https.Server) { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(SALESFORCE_INTAKE_PROXY_PORT, () => { + server.off('error', reject) + const { port } = server.address() as AddressInfo + resolve(`https://localhost:${port}`) + }) + }).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') { + throw new Error(`Salesforce intake proxy port ${SALESFORCE_INTAKE_PROXY_PORT} is already in use.`) + } + throw error + }) +} + +function close(server: https.Server) { + return new Promise((resolve, reject) => { + const forceCloseTimeoutId = setTimeout(() => { + server.closeAllConnections() + }, SALESFORCE_INTAKE_PROXY_CLOSE_DELAY) + + server.close((error) => { + clearTimeout(forceCloseTimeoutId) + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +function createIdleWaiter() { + let pendingCount = 0 + let idlePromise = Promise.resolve() + let resolveIdlePromise: undefined | (() => void) + let waitTimeoutId: NodeJS.Timeout | undefined + + function resolveAfterDelay() { + waitTimeoutId = setTimeout(() => { + resolveIdlePromise?.() + resolveIdlePromise = undefined + }, SALESFORCE_INTAKE_PROXY_IDLE_DELAY) + } + + return { + trackResponse(res: http.ServerResponse) { + clearTimeout(waitTimeoutId) + if (!resolveIdlePromise) { + idlePromise = new Promise((resolve) => { + resolveIdlePromise = resolve + }) + } + + pendingCount += 1 + res.on('close', () => { + pendingCount -= 1 + if (pendingCount === 0) { + resolveAfterDelay() + } + }) + }, + wait() { + return idlePromise + }, + } +} + +function generateSelfSignedCertificate() { + const pki = forge.pki + const md = forge.md + const keys = pki.rsa.generateKeyPair(2048) + const cert = pki.createCertificate() + + cert.publicKey = keys.publicKey + cert.serialNumber = '01' + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1) + + const attrs = [ + { + name: 'commonName', + value: 'localhost', + }, + ] + + cert.setSubject(attrs) + cert.setIssuer(attrs) + cert.setExtensions([ + { + name: 'basicConstraints', + cA: true, + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true, + }, + { + name: 'extKeyUsage', + serverAuth: true, + }, + { + name: 'subjectAltName', + altNames: [ + { + type: 2, + value: 'localhost', + }, + { + type: 7, + ip: '127.0.0.1', + }, + ], + }, + ]) + + cert.sign(keys.privateKey, md.sha256.create()) + + return { + key: pki.privateKeyToPem(keys.privateKey), + cert: pki.certificateToPem(cert), + } +} diff --git a/test/e2e/salesforce/support/salesforceRumRegistry.ts b/test/e2e/salesforce/support/salesforceRumRegistry.ts new file mode 100644 index 0000000000..41f610f766 --- /dev/null +++ b/test/e2e/salesforce/support/salesforceRumRegistry.ts @@ -0,0 +1,100 @@ +import type { Page, Request } from '@playwright/test' + +interface SalesforceRumEventView { + url?: unknown +} + +export interface SalesforceRumEvent { + type?: unknown + view?: SalesforceRumEventView +} + +export interface SalesforceRumRegistry { + rumRequests: Request[] + rumEvents: SalesforceRumEvent[] + rumViewEvents: SalesforceRumEvent[] + findViewByPath: (pathname: string) => SalesforceRumEvent | undefined + hasViewPath: (pathname: string) => boolean + stop: () => void +} + +export function createSalesforceRumRegistry(page: Page): SalesforceRumRegistry { + const rumRequests: Request[] = [] + + const onRequest = (request: Request) => { + if (isRumIntakeRequest(request)) { + rumRequests.push(request) + } + } + + page.context().on('request', onRequest) + + const registry: SalesforceRumRegistry = { + get rumRequests() { + return rumRequests + }, + get rumEvents() { + return rumRequests.flatMap((request) => getRumEvents(request)) + }, + get rumViewEvents() { + return registry.rumEvents.filter((event) => event.type === 'view') + }, + findViewByPath(pathname: string) { + const expectedPath = normalizePathname(pathname) + return registry.rumViewEvents.find((event) => normalizePathname(event.view?.url) === expectedPath) + }, + hasViewPath(pathname: string) { + return registry.findViewByPath(pathname) !== undefined + }, + stop() { + page.context().off('request', onRequest) + }, + } + + return registry +} + +function isRumIntakeRequest(request: Request) { + return request.method() === 'POST' && isRumIntakeUrl(request.url()) +} + +function isRumIntakeUrl(candidate: string) { + try { + return new URL(candidate).pathname === '/api/v2/rum' + } catch { + return false + } +} + +function getRumEvents(request: Request): SalesforceRumEvent[] { + const rawBody = request.postDataBuffer()?.toString('utf8') ?? '' + + if (!rawBody.trim()) { + return [] + } + + return rawBody + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as SalesforceRumEvent] + } catch { + return [] + } + }) +} + +function normalizePathname(candidate: unknown) { + if (typeof candidate !== 'string' || !candidate.trim()) { + return undefined + } + + try { + const pathname = new URL(candidate, 'https://example.org').pathname + return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname + } catch { + return undefined + } +} diff --git a/test/e2e/salesforce/support/salesforceTargets.ts b/test/e2e/salesforce/support/salesforceTargets.ts new file mode 100644 index 0000000000..854aeafc6d --- /dev/null +++ b/test/e2e/salesforce/support/salesforceTargets.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process' + +let cachedTargets: SalesforceTargets | undefined +const ANSI_ESCAPE_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +export interface SalesforceTargets { + loginUrl: string + experienceUrl: string + experienceProductExplorerUrl: string + lightningHomeUrl: string + lightningProductExplorerUrl: string +} + +// Uses `sf org open --url-only --json` to obtain an authenticated org URL. +// Reference: https://github.com/salesforcecli/plugin-org#sf-org-open +export function getSalesforceTargets() { + if (cachedTargets) { + return cachedTargets + } + + const environment = { ...process.env } + delete environment.NO_COLOR + const stdout = execFileSync( + 'sf', + ['org', 'open', '-o', 'ebikes', '--url-only', '--path', '/lightning/page/home', '--json'], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...environment, FORCE_COLOR: '0', CLICOLOR: '0' }, + } + ) + + const result = JSON.parse(stripAnsi(stdout)) as { result?: { url?: string } } + const loginUrl = result.result?.url + + if (!loginUrl) { + throw new Error('sf org open did not return an authenticated URL.') + } + + // Derive Lightning and Experience Cloud origins from the authenticated org URL. + const loginOrigin = new URL(loginUrl).origin + const lightningOrigin = loginOrigin.replace('.my.salesforce.com', '.lightning.force.com') + const experienceOrigin = loginOrigin.replace('.my.salesforce.com', '.my.site.com') + + cachedTargets = { + loginUrl, + experienceUrl: `${experienceOrigin}/ebikes/s`, + experienceProductExplorerUrl: `${experienceOrigin}/ebikes/s/product-explorer`, + lightningHomeUrl: `${lightningOrigin}/lightning/page/home`, + lightningProductExplorerUrl: `${lightningOrigin}/lightning/n/Product_Explorer`, + } + + return cachedTargets +} + +function stripAnsi(candidate: string) { + return candidate.replace(ANSI_ESCAPE_SEQUENCE, '') +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 040b37b41f..6add355941 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,7 +25,6 @@ "@datadog/browser-rum": ["./packages/rum/src/entries/main"], "@datadog/browser-rum/internal": ["./packages/rum/src/entries/internal"], "@datadog/browser-rum/internal-synthetics": ["./packages/rum/src/entries/internalSynthetics"], - "@datadog/browser-rum-slim": ["./packages/rum-slim/src/entries/main"], "@datadog/browser-rum-react": ["./packages/rum-react/src/entries/main"],