diff --git a/packages/core/src/browser/addEventListener.ts b/packages/core/src/browser/addEventListener.ts index feedc154ae..ff27ec5910 100644 --- a/packages/core/src/browser/addEventListener.ts +++ b/packages/core/src/browser/addEventListener.ts @@ -1,7 +1,7 @@ import { monitor } from '../tools/monitor' import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue' import type { Configuration } from '../domain/configuration' -import type { VisualViewport, VisualViewportEventMap } from './types' +import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './types' export type TrustableEvent = E & { __ddIsTrusted?: boolean } @@ -75,7 +75,9 @@ type EventMapFor = T extends Window ? PerformanceEventMap : T extends Worker ? WorkerEventMap - : Record + : T extends CookieStore + ? CookieStoreEventMap + : Record /** * Add an event listener to an event target object (Window, Element, mock object...). This provides diff --git a/packages/core/src/browser/types.ts b/packages/core/src/browser/types.ts index abd90cbecb..54f09932b7 100644 --- a/packages/core/src/browser/types.ts +++ b/packages/core/src/browser/types.ts @@ -38,3 +38,20 @@ export interface VisualViewport extends EventTarget { options?: boolean | EventListenerOptions ): void } + +// Those are native API types that are not official supported by TypeScript yet + +export interface CookieStore extends EventTarget {} + +export interface CookieStoreEventMap { + change: CookieChangeEvent +} + +export type CookieChangeItem = { name: string; value: string | undefined } + +export type CookieChangeEvent = Event & { + changed: CookieChangeItem[] + deleted: CookieChangeItem[] +} + +export interface CookieStore extends EventTarget {} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6dc7c5763a..cbd049e324 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,7 +88,15 @@ export { } from './domain/error/error' export { NonErrorPrefix } from './domain/error/error.types' export { Context, ContextArray, ContextValue } from './tools/serialisation/context' -export { areCookiesAuthorized, getCookie, setCookie, deleteCookie } from './browser/cookie' +export { + areCookiesAuthorized, + getCookie, + getInitCookie, + setCookie, + deleteCookie, + resetInitCookies, +} from './browser/cookie' +export { CookieStore } from './browser/types' export { initXhrObservable, XhrCompleteContext, XhrStartContext } from './browser/xhrObservable' export { initFetchObservable, FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable' export { createPageExitObservable, PageExitEvent, PageExitReason, isPageExitReason } from './browser/pageExitObservable' diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index fe584d48aa..d2cef706ac 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -46,6 +46,7 @@ import { startPageStateHistory } from '../domain/contexts/pageStateHistory' import type { CommonContext } from '../domain/contexts/commonContext' import { startDisplayContext } from '../domain/contexts/displayContext' import { startVitalCollection } from '../domain/vital/vitalCollection' +import { startCiVisibilityContext } from '../domain/contexts/ciVisibilityContext' import type { RecorderApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -230,6 +231,7 @@ export function startRumEventCollection( ) const displayContext = startDisplayContext(configuration) + const ciVisibilityContext = startCiVisibilityContext(configuration) startRumAssembly( configuration, @@ -239,6 +241,7 @@ export function startRumEventCollection( urlContexts, actionContexts, displayContext, + ciVisibilityContext, getCommonContext, reportError ) @@ -250,6 +253,7 @@ export function startRumEventCollection( addAction, actionContexts, stop: () => { + ciVisibilityContext.stop() displayContext.stop() pageStateHistory.stop() urlContexts.stop() diff --git a/packages/rum-core/src/browser/cookieObservable.spec.ts b/packages/rum-core/src/browser/cookieObservable.spec.ts new file mode 100644 index 0000000000..a2981c3d5d --- /dev/null +++ b/packages/rum-core/src/browser/cookieObservable.spec.ts @@ -0,0 +1,68 @@ +import type { Subscription } from '@datadog/browser-core' +import { ONE_MINUTE, deleteCookie, setCookie } from '@datadog/browser-core' +import type { Clock } from '@datadog/browser-core/test' +import { mockClock } from '@datadog/browser-core/test' +import type { RumConfiguration } from '../domain/configuration' +import { WATCH_COOKIE_INTERVAL_DELAY, createCookieObservable } from './cookieObservable' + +const COOKIE_NAME = 'cookie_name' +const COOKIE_DURATION = ONE_MINUTE + +describe('cookieObservable', () => { + let subscription: Subscription + let originalSupportedEntryTypes: PropertyDescriptor | undefined + let clock: Clock + beforeEach(() => { + clock = mockClock() + originalSupportedEntryTypes = Object.getOwnPropertyDescriptor(window, 'cookieStore') + }) + + afterEach(() => { + subscription?.unsubscribe() + if (originalSupportedEntryTypes) { + Object.defineProperty(window, 'cookieStore', originalSupportedEntryTypes) + } + clock.cleanup() + deleteCookie(COOKIE_NAME) + }) + + it('should notify observers on cookie change', (done) => { + const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME) + + subscription = observable.subscribe((cookieChange) => { + expect(cookieChange).toEqual('foo') + + done() + }) + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + }) + + it('should notify observers on cookie change when cookieStore is not supported', () => { + Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) + const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME) + + let cookieChange: string | undefined + subscription = observable.subscribe((change) => (cookieChange = change)) + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + expect(cookieChange).toEqual('foo') + }) + + it('should not notify observers on cookie change when the cookie value as not changed when cookieStore is not supported', () => { + Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) + const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME) + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + + let cookieChange: string | undefined + subscription = observable.subscribe((change) => (cookieChange = change)) + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + expect(cookieChange).toBeUndefined() + }) +}) diff --git a/packages/rum-core/src/browser/cookieObservable.ts b/packages/rum-core/src/browser/cookieObservable.ts new file mode 100644 index 0000000000..bafe55c730 --- /dev/null +++ b/packages/rum-core/src/browser/cookieObservable.ts @@ -0,0 +1,64 @@ +import type { Configuration, CookieStore } from '@datadog/browser-core' +import { + setInterval, + clearInterval, + Observable, + addEventListener, + ONE_SECOND, + findCommaSeparatedValue, + DOM_EVENT, + find, +} from '@datadog/browser-core' + +export interface CookieStoreWindow extends Window { + cookieStore?: CookieStore +} + +export type CookieObservable = ReturnType + +export function createCookieObservable(configuration: Configuration, cookieName: string) { + const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore + ? listenToCookieStoreChange(configuration) + : watchCookieFallback + + return new Observable((observable) => + detectCookieChangeStrategy(cookieName, (event) => observable.notify(event)) + ) +} + +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 = + find(event.changed, (event) => event.name === cookieName) || + find(event.deleted, (event) => event.name === cookieName) + if (changeEvent) { + callback(changeEvent.value) + } + } + ) + return listener.stop + } +} + +export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND + +function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) { + const previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName) + const watchCookieIntervalId = setInterval(() => { + const cookieValue = findCommaSeparatedValue(document.cookie, cookieName) + if (cookieValue !== previousCookieValue) { + callback(cookieValue) + } + }, WATCH_COOKIE_INTERVAL_DELAY) + + return () => { + clearInterval(watchCookieIntervalId) + } +} diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index 99816ab3f6..5755df7c35 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -9,13 +9,7 @@ import { setNavigatorConnection, } from '@datadog/browser-core/test' import type { TestSetupBuilder } from '../../test' -import { - createRumSessionManagerMock, - mockCiVisibilityWindowValues, - setup, - createRawRumEvent, - cleanupCiVisibilityWindowValues, -} from '../../test' +import { createRumSessionManagerMock, setup, createRawRumEvent } from '../../test' import type { RumEventDomainContext } from '../domainContext.types' import type { RawRumActionEvent, RawRumEvent } from '../rawRumEvent.types' import { RumEventType } from '../rawRumEvent.types' @@ -26,6 +20,7 @@ import { LifeCycleEventType } from './lifeCycle' import type { RumConfiguration } from './configuration' import type { ViewContext } from './contexts/viewContexts' import type { CommonContext } from './contexts/commonContext' +import type { CiVisibilityContext } from './contexts/ciVisibilityContext' describe('rum assembly', () => { let setupBuilder: TestSetupBuilder @@ -34,6 +29,8 @@ describe('rum assembly', () => { let extraConfigurationOptions: Partial = {} let findView: () => ViewContext let reportErrorSpy: jasmine.Spy + let ciVisibilityContext: { test_execution_id: string } | undefined + beforeEach(() => { findView = () => ({ id: '7890', @@ -46,6 +43,8 @@ describe('rum assembly', () => { user: {}, hasReplay: undefined, } + ciVisibilityContext = undefined + setupBuilder = setup() .withViewContexts({ findView: () => findView(), @@ -67,6 +66,7 @@ describe('rum assembly', () => { urlContexts, actionContexts, displayContext, + { get: () => ciVisibilityContext } as CiVisibilityContext, () => commonContext, reportErrorSpy ) @@ -76,7 +76,6 @@ describe('rum assembly', () => { afterEach(() => { cleanupSyntheticsWorkerValues() - cleanupCiVisibilityWindowValues() }) describe('beforeSend', () => { @@ -674,8 +673,8 @@ describe('rum assembly', () => { expect(serverRumEvents[0].session.type).toEqual('synthetics') }) - it('should detect ci visibility tests based on ci visibility global window values', () => { - mockCiVisibilityWindowValues('traceId') + it('should detect ci visibility tests', () => { + ciVisibilityContext = { test_execution_id: 'traceId' } const { lifeCycle } = setupBuilder.build() notifyRawRumEvent(lifeCycle, { @@ -798,7 +797,7 @@ describe('rum assembly', () => { describe('ci visibility context', () => { it('includes the ci visibility context', () => { - mockCiVisibilityWindowValues('traceId') + ciVisibilityContext = { test_execution_id: 'traceId' } const { lifeCycle } = setupBuilder.build() notifyRawRumEvent(lifeCycle, { diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index 6fd7a2bae3..729cf42c28 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -24,7 +24,7 @@ import type { import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' import { getSyntheticsContext } from './contexts/syntheticsContext' -import { getCiTestContext } from './contexts/ciTestContext' +import type { CiVisibilityContext } from './contexts/ciVisibilityContext' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' import type { ViewContexts } from './contexts/viewContexts' @@ -68,6 +68,7 @@ export function startRumAssembly( urlContexts: UrlContexts, actionContexts: ActionContexts, displayContext: DisplayContext, + ciVisibilityContext: CiVisibilityContext, getCommonContext: () => CommonContext, reportError: (error: RawError) => void ) { @@ -122,7 +123,6 @@ export function startRumAssembly( } const syntheticsContext = getSyntheticsContext() - const ciTestContext = getCiTestContext() lifeCycle.subscribe( LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, ({ startTime, rawRumEvent, domainContext, savedCommonContext, customerContext }) => { @@ -152,7 +152,11 @@ export function startRumAssembly( source: 'browser', session: { id: session.id, - type: syntheticsContext ? SessionType.SYNTHETICS : ciTestContext ? SessionType.CI_TEST : SessionType.USER, + type: syntheticsContext + ? SessionType.SYNTHETICS + : ciVisibilityContext.get() + ? SessionType.CI_TEST + : SessionType.USER, }, view: { id: viewContext.id, @@ -162,7 +166,7 @@ export function startRumAssembly( }, action: needToAssembleWithAction(rawRumEvent) && actionId ? { id: actionId } : undefined, synthetics: syntheticsContext, - ci_test: ciTestContext, + ci_test: ciVisibilityContext.get(), display: displayContext.get(), connectivity: getConnectivity(), } diff --git a/packages/rum-core/src/domain/contexts/ciTestContext.spec.ts b/packages/rum-core/src/domain/contexts/ciTestContext.spec.ts deleted file mode 100644 index 8ee552533f..0000000000 --- a/packages/rum-core/src/domain/contexts/ciTestContext.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { cleanupCiVisibilityWindowValues, mockCiVisibilityWindowValues } from '../../../test' -import { getCiTestContext } from './ciTestContext' - -describe('getCiTestContext', () => { - afterEach(() => { - cleanupCiVisibilityWindowValues() - }) - - it('sets the ci visibility context defined by Cypress global variables', () => { - mockCiVisibilityWindowValues('trace_id_value') - - expect(getCiTestContext()).toEqual({ - test_execution_id: 'trace_id_value', - }) - }) - - it('does not set ci visibility context if the Cypress global variable is undefined', () => { - mockCiVisibilityWindowValues() - - expect(getCiTestContext()).toBeUndefined() - }) - - it('does not set ci visibility context if Cypress global variables are not strings', () => { - mockCiVisibilityWindowValues({ key: 'value' }) - - expect(getCiTestContext()).toBeUndefined() - }) -}) diff --git a/packages/rum-core/src/domain/contexts/ciTestContext.ts b/packages/rum-core/src/domain/contexts/ciTestContext.ts deleted file mode 100644 index d5aee24d14..0000000000 --- a/packages/rum-core/src/domain/contexts/ciTestContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface CiTestWindow extends Window { - Cypress?: { - env: (key: string) => string | undefined - } -} - -export function getCiTestContext() { - const testExecutionId = (window as CiTestWindow).Cypress?.env('traceId') - - if (typeof testExecutionId === 'string') { - return { - test_execution_id: testExecutionId, - } - } -} diff --git a/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts b/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts new file mode 100644 index 0000000000..f4d85d3daa --- /dev/null +++ b/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts @@ -0,0 +1,60 @@ +import type { Configuration } from '@datadog/browser-core' +import { Observable } from '@datadog/browser-core' +import { mockCiVisibilityValues } from '../../../test' +import type { CookieObservable } from '../../browser/cookieObservable' +import type { CiVisibilityContext } from './ciVisibilityContext' +import { startCiVisibilityContext } from './ciVisibilityContext' + +describe('startCiVisibilityContext', () => { + let ciVisibilityContext: CiVisibilityContext + let cookieObservable: CookieObservable + beforeEach(() => { + cookieObservable = new Observable() + }) + + afterEach(() => { + ciVisibilityContext.stop() + }) + + it('sets the ci visibility context defined by Cypress global variables', () => { + mockCiVisibilityValues('trace_id_value') + ciVisibilityContext = startCiVisibilityContext({} as Configuration, cookieObservable) + + expect(ciVisibilityContext.get()).toEqual({ + test_execution_id: 'trace_id_value', + }) + }) + + it('sets the ci visibility context defined by global cookie', () => { + mockCiVisibilityValues('trace_id_value', 'cookies') + ciVisibilityContext = startCiVisibilityContext({} as Configuration, cookieObservable) + + expect(ciVisibilityContext.get()).toEqual({ + test_execution_id: 'trace_id_value', + }) + }) + + it('update the ci visibility context when global cookie is updated', () => { + mockCiVisibilityValues('trace_id_value', 'cookies') + ciVisibilityContext = startCiVisibilityContext({} as Configuration, cookieObservable) + cookieObservable.notify('trace_id_value_updated') + + expect(ciVisibilityContext.get()).toEqual({ + test_execution_id: 'trace_id_value_updated', + }) + }) + + it('does not set ci visibility context if the Cypress global variable is undefined', () => { + mockCiVisibilityValues(undefined) + ciVisibilityContext = startCiVisibilityContext({} as Configuration, cookieObservable) + + expect(ciVisibilityContext.get()).toBeUndefined() + }) + + it('does not set ci visibility context if it is not a string', () => { + mockCiVisibilityValues({ key: 'value' }) + ciVisibilityContext = startCiVisibilityContext({} as Configuration, cookieObservable) + + expect(ciVisibilityContext.get()).toBeUndefined() + }) +}) diff --git a/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts b/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts new file mode 100644 index 0000000000..d9a96f6525 --- /dev/null +++ b/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts @@ -0,0 +1,36 @@ +import { getInitCookie, type Configuration } from '@datadog/browser-core' + +import { createCookieObservable } from '../../browser/cookieObservable' + +export const CI_VISIBILITY_TEST_ID_COOKIE_NAME = 'datadog-ci-visibility-test-execution-id' + +export interface CiTestWindow extends Window { + Cypress?: { + env: (key: string) => string | undefined + } +} + +export type CiVisibilityContext = ReturnType + +export function startCiVisibilityContext( + configuration: Configuration, + cookieObservable = createCookieObservable(configuration, CI_VISIBILITY_TEST_ID_COOKIE_NAME) +) { + let testExecutionId = + getInitCookie(CI_VISIBILITY_TEST_ID_COOKIE_NAME) || (window as CiTestWindow).Cypress?.env('traceId') + + const cookieObservableSubscription = cookieObservable.subscribe((value) => { + testExecutionId = value + }) + + return { + get: () => { + if (typeof testExecutionId === 'string') { + return { + test_execution_id: testExecutionId, + } + } + }, + stop: () => cookieObservableSubscription.unsubscribe(), + } +} diff --git a/packages/rum-core/test/index.ts b/packages/rum-core/test/index.ts index 54b8739906..ce17998593 100644 --- a/packages/rum-core/test/index.ts +++ b/packages/rum-core/test/index.ts @@ -2,7 +2,7 @@ export * from './createFakeClick' export * from './dom' export * from './fixtures' export * from './formatValidation' -export * from './mockCiVisibilityWindowValues' +export * from './mockCiVisibilityValues' export * from './mockRumSessionManager' export * from './noopRecorderApi' export * from './testSetupBuilder' diff --git a/packages/rum-core/test/mockCiVisibilityValues.ts b/packages/rum-core/test/mockCiVisibilityValues.ts new file mode 100644 index 0000000000..4f5b4412b4 --- /dev/null +++ b/packages/rum-core/test/mockCiVisibilityValues.ts @@ -0,0 +1,33 @@ +import { ONE_MINUTE, resetInitCookies, deleteCookie, setCookie } from '@datadog/browser-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { CI_VISIBILITY_TEST_ID_COOKIE_NAME, type CiTestWindow } from '../src/domain/contexts/ciVisibilityContext' + +// Duration to create a cookie lasting at least until the end of the test +const COOKIE_DURATION = ONE_MINUTE + +export function mockCiVisibilityValues(testExecutionId: unknown, method: 'globals' | 'cookies' = 'globals') { + switch (method) { + case 'globals': + ;(window as CiTestWindow).Cypress = { + env: (key: string) => { + if (typeof testExecutionId === 'string' && key === 'traceId') { + return testExecutionId + } + }, + } + + break + case 'cookies': + if (typeof testExecutionId === 'string') { + setCookie(CI_VISIBILITY_TEST_ID_COOKIE_NAME, testExecutionId, COOKIE_DURATION) + } + break + } + resetInitCookies() + + registerCleanupTask(() => { + delete (window as CiTestWindow).Cypress + deleteCookie(CI_VISIBILITY_TEST_ID_COOKIE_NAME) + resetInitCookies() + }) +} diff --git a/packages/rum-core/test/mockCiVisibilityWindowValues.ts b/packages/rum-core/test/mockCiVisibilityWindowValues.ts deleted file mode 100644 index 797c79daea..0000000000 --- a/packages/rum-core/test/mockCiVisibilityWindowValues.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CiTestWindow } from '../src/domain/contexts/ciTestContext' - -export function mockCiVisibilityWindowValues(traceId?: unknown) { - if (traceId) { - ;(window as CiTestWindow).Cypress = { - env: (key: string) => { - if (typeof traceId === 'string' && key === 'traceId') { - return traceId - } - }, - } - } -} - -export function cleanupCiVisibilityWindowValues() { - delete (window as CiTestWindow).Cypress -}