Skip to content

Commit 2522989

Browse files
[ci-visibility] Implement driver-agnostic integration with CI Visibility (#2639)
--------- Co-authored-by: Aymeric Mortemousque <[email protected]>
1 parent f644560 commit 2522989

15 files changed

+314
-79
lines changed

packages/core/src/browser/addEventListener.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { monitor } from '../tools/monitor'
22
import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue'
33
import type { Configuration } from '../domain/configuration'
4-
import type { VisualViewport, VisualViewportEventMap } from './types'
4+
import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './types'
55

66
export type TrustableEvent<E extends Event = Event> = E & { __ddIsTrusted?: boolean }
77

@@ -75,7 +75,9 @@ type EventMapFor<T> = T extends Window
7575
? PerformanceEventMap
7676
: T extends Worker
7777
? WorkerEventMap
78-
: Record<never, never>
78+
: T extends CookieStore
79+
? CookieStoreEventMap
80+
: Record<never, never>
7981

8082
/**
8183
* Add an event listener to an event target object (Window, Element, mock object...). This provides

packages/core/src/browser/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,20 @@ export interface VisualViewport extends EventTarget {
3838
options?: boolean | EventListenerOptions
3939
): void
4040
}
41+
42+
// Those are native API types that are not official supported by TypeScript yet
43+
44+
export interface CookieStore extends EventTarget {}
45+
46+
export interface CookieStoreEventMap {
47+
change: CookieChangeEvent
48+
}
49+
50+
export type CookieChangeItem = { name: string; value: string | undefined }
51+
52+
export type CookieChangeEvent = Event & {
53+
changed: CookieChangeItem[]
54+
deleted: CookieChangeItem[]
55+
}
56+
57+
export interface CookieStore extends EventTarget {}

packages/core/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,15 @@ export {
8888
} from './domain/error/error'
8989
export { NonErrorPrefix } from './domain/error/error.types'
9090
export { Context, ContextArray, ContextValue } from './tools/serialisation/context'
91-
export { areCookiesAuthorized, getCookie, setCookie, deleteCookie } from './browser/cookie'
91+
export {
92+
areCookiesAuthorized,
93+
getCookie,
94+
getInitCookie,
95+
setCookie,
96+
deleteCookie,
97+
resetInitCookies,
98+
} from './browser/cookie'
99+
export { CookieStore } from './browser/types'
92100
export { initXhrObservable, XhrCompleteContext, XhrStartContext } from './browser/xhrObservable'
93101
export { initFetchObservable, FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable'
94102
export { createPageExitObservable, PageExitEvent, PageExitReason, isPageExitReason } from './browser/pageExitObservable'

packages/rum-core/src/boot/startRum.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { startPageStateHistory } from '../domain/contexts/pageStateHistory'
4646
import type { CommonContext } from '../domain/contexts/commonContext'
4747
import { startDisplayContext } from '../domain/contexts/displayContext'
4848
import { startVitalCollection } from '../domain/vital/vitalCollection'
49+
import { startCiVisibilityContext } from '../domain/contexts/ciVisibilityContext'
4950
import type { RecorderApi } from './rumPublicApi'
5051

5152
export type StartRum = typeof startRum
@@ -230,6 +231,7 @@ export function startRumEventCollection(
230231
)
231232

232233
const displayContext = startDisplayContext(configuration)
234+
const ciVisibilityContext = startCiVisibilityContext(configuration)
233235

234236
startRumAssembly(
235237
configuration,
@@ -239,6 +241,7 @@ export function startRumEventCollection(
239241
urlContexts,
240242
actionContexts,
241243
displayContext,
244+
ciVisibilityContext,
242245
getCommonContext,
243246
reportError
244247
)
@@ -250,6 +253,7 @@ export function startRumEventCollection(
250253
addAction,
251254
actionContexts,
252255
stop: () => {
256+
ciVisibilityContext.stop()
253257
displayContext.stop()
254258
pageStateHistory.stop()
255259
urlContexts.stop()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Subscription } from '@datadog/browser-core'
2+
import { ONE_MINUTE, deleteCookie, setCookie } from '@datadog/browser-core'
3+
import type { Clock } from '@datadog/browser-core/test'
4+
import { mockClock } from '@datadog/browser-core/test'
5+
import type { RumConfiguration } from '../domain/configuration'
6+
import { WATCH_COOKIE_INTERVAL_DELAY, createCookieObservable } from './cookieObservable'
7+
8+
const COOKIE_NAME = 'cookie_name'
9+
const COOKIE_DURATION = ONE_MINUTE
10+
11+
describe('cookieObservable', () => {
12+
let subscription: Subscription
13+
let originalSupportedEntryTypes: PropertyDescriptor | undefined
14+
let clock: Clock
15+
beforeEach(() => {
16+
clock = mockClock()
17+
originalSupportedEntryTypes = Object.getOwnPropertyDescriptor(window, 'cookieStore')
18+
})
19+
20+
afterEach(() => {
21+
subscription?.unsubscribe()
22+
if (originalSupportedEntryTypes) {
23+
Object.defineProperty(window, 'cookieStore', originalSupportedEntryTypes)
24+
}
25+
clock.cleanup()
26+
deleteCookie(COOKIE_NAME)
27+
})
28+
29+
it('should notify observers on cookie change', (done) => {
30+
const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME)
31+
32+
subscription = observable.subscribe((cookieChange) => {
33+
expect(cookieChange).toEqual('foo')
34+
35+
done()
36+
})
37+
setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
38+
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)
39+
})
40+
41+
it('should notify observers on cookie change when cookieStore is not supported', () => {
42+
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
43+
const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME)
44+
45+
let cookieChange: string | undefined
46+
subscription = observable.subscribe((change) => (cookieChange = change))
47+
48+
setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
49+
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)
50+
51+
expect(cookieChange).toEqual('foo')
52+
})
53+
54+
it('should not notify observers on cookie change when the cookie value as not changed when cookieStore is not supported', () => {
55+
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
56+
const observable = createCookieObservable({} as RumConfiguration, COOKIE_NAME)
57+
58+
setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
59+
60+
let cookieChange: string | undefined
61+
subscription = observable.subscribe((change) => (cookieChange = change))
62+
63+
setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
64+
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)
65+
66+
expect(cookieChange).toBeUndefined()
67+
})
68+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Configuration, CookieStore } from '@datadog/browser-core'
2+
import {
3+
setInterval,
4+
clearInterval,
5+
Observable,
6+
addEventListener,
7+
ONE_SECOND,
8+
findCommaSeparatedValue,
9+
DOM_EVENT,
10+
find,
11+
} from '@datadog/browser-core'
12+
13+
export interface CookieStoreWindow extends Window {
14+
cookieStore?: CookieStore
15+
}
16+
17+
export type CookieObservable = ReturnType<typeof createCookieObservable>
18+
19+
export function createCookieObservable(configuration: Configuration, cookieName: string) {
20+
const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore
21+
? listenToCookieStoreChange(configuration)
22+
: watchCookieFallback
23+
24+
return new Observable<string | undefined>((observable) =>
25+
detectCookieChangeStrategy(cookieName, (event) => observable.notify(event))
26+
)
27+
}
28+
29+
function listenToCookieStoreChange(configuration: Configuration) {
30+
return (cookieName: string, callback: (event: string | undefined) => void) => {
31+
const listener = addEventListener(
32+
configuration,
33+
(window as CookieStoreWindow).cookieStore!,
34+
DOM_EVENT.CHANGE,
35+
(event) => {
36+
// Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays.
37+
// However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226
38+
const changeEvent =
39+
find(event.changed, (event) => event.name === cookieName) ||
40+
find(event.deleted, (event) => event.name === cookieName)
41+
if (changeEvent) {
42+
callback(changeEvent.value)
43+
}
44+
}
45+
)
46+
return listener.stop
47+
}
48+
}
49+
50+
export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND
51+
52+
function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) {
53+
const previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName)
54+
const watchCookieIntervalId = setInterval(() => {
55+
const cookieValue = findCommaSeparatedValue(document.cookie, cookieName)
56+
if (cookieValue !== previousCookieValue) {
57+
callback(cookieValue)
58+
}
59+
}, WATCH_COOKIE_INTERVAL_DELAY)
60+
61+
return () => {
62+
clearInterval(watchCookieIntervalId)
63+
}
64+
}

packages/rum-core/src/domain/assembly.spec.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@ import {
99
setNavigatorConnection,
1010
} from '@datadog/browser-core/test'
1111
import type { TestSetupBuilder } from '../../test'
12-
import {
13-
createRumSessionManagerMock,
14-
mockCiVisibilityWindowValues,
15-
setup,
16-
createRawRumEvent,
17-
cleanupCiVisibilityWindowValues,
18-
} from '../../test'
12+
import { createRumSessionManagerMock, setup, createRawRumEvent } from '../../test'
1913
import type { RumEventDomainContext } from '../domainContext.types'
2014
import type { RawRumActionEvent, RawRumEvent } from '../rawRumEvent.types'
2115
import { RumEventType } from '../rawRumEvent.types'
@@ -26,6 +20,7 @@ import { LifeCycleEventType } from './lifeCycle'
2620
import type { RumConfiguration } from './configuration'
2721
import type { ViewContext } from './contexts/viewContexts'
2822
import type { CommonContext } from './contexts/commonContext'
23+
import type { CiVisibilityContext } from './contexts/ciVisibilityContext'
2924

3025
describe('rum assembly', () => {
3126
let setupBuilder: TestSetupBuilder
@@ -34,6 +29,8 @@ describe('rum assembly', () => {
3429
let extraConfigurationOptions: Partial<RumConfiguration> = {}
3530
let findView: () => ViewContext
3631
let reportErrorSpy: jasmine.Spy<jasmine.Func>
32+
let ciVisibilityContext: { test_execution_id: string } | undefined
33+
3734
beforeEach(() => {
3835
findView = () => ({
3936
id: '7890',
@@ -46,6 +43,8 @@ describe('rum assembly', () => {
4643
user: {},
4744
hasReplay: undefined,
4845
}
46+
ciVisibilityContext = undefined
47+
4948
setupBuilder = setup()
5049
.withViewContexts({
5150
findView: () => findView(),
@@ -67,6 +66,7 @@ describe('rum assembly', () => {
6766
urlContexts,
6867
actionContexts,
6968
displayContext,
69+
{ get: () => ciVisibilityContext } as CiVisibilityContext,
7070
() => commonContext,
7171
reportErrorSpy
7272
)
@@ -76,7 +76,6 @@ describe('rum assembly', () => {
7676

7777
afterEach(() => {
7878
cleanupSyntheticsWorkerValues()
79-
cleanupCiVisibilityWindowValues()
8079
})
8180

8281
describe('beforeSend', () => {
@@ -674,8 +673,8 @@ describe('rum assembly', () => {
674673
expect(serverRumEvents[0].session.type).toEqual('synthetics')
675674
})
676675

677-
it('should detect ci visibility tests based on ci visibility global window values', () => {
678-
mockCiVisibilityWindowValues('traceId')
676+
it('should detect ci visibility tests', () => {
677+
ciVisibilityContext = { test_execution_id: 'traceId' }
679678

680679
const { lifeCycle } = setupBuilder.build()
681680
notifyRawRumEvent(lifeCycle, {
@@ -798,7 +797,7 @@ describe('rum assembly', () => {
798797

799798
describe('ci visibility context', () => {
800799
it('includes the ci visibility context', () => {
801-
mockCiVisibilityWindowValues('traceId')
800+
ciVisibilityContext = { test_execution_id: 'traceId' }
802801

803802
const { lifeCycle } = setupBuilder.build()
804803
notifyRawRumEvent(lifeCycle, {

packages/rum-core/src/domain/assembly.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type {
2424
import { RumEventType } from '../rawRumEvent.types'
2525
import type { RumEvent } from '../rumEvent.types'
2626
import { getSyntheticsContext } from './contexts/syntheticsContext'
27-
import { getCiTestContext } from './contexts/ciTestContext'
27+
import type { CiVisibilityContext } from './contexts/ciVisibilityContext'
2828
import type { LifeCycle } from './lifeCycle'
2929
import { LifeCycleEventType } from './lifeCycle'
3030
import type { ViewContexts } from './contexts/viewContexts'
@@ -68,6 +68,7 @@ export function startRumAssembly(
6868
urlContexts: UrlContexts,
6969
actionContexts: ActionContexts,
7070
displayContext: DisplayContext,
71+
ciVisibilityContext: CiVisibilityContext,
7172
getCommonContext: () => CommonContext,
7273
reportError: (error: RawError) => void
7374
) {
@@ -122,7 +123,6 @@ export function startRumAssembly(
122123
}
123124

124125
const syntheticsContext = getSyntheticsContext()
125-
const ciTestContext = getCiTestContext()
126126
lifeCycle.subscribe(
127127
LifeCycleEventType.RAW_RUM_EVENT_COLLECTED,
128128
({ startTime, rawRumEvent, domainContext, savedCommonContext, customerContext }) => {
@@ -152,7 +152,11 @@ export function startRumAssembly(
152152
source: 'browser',
153153
session: {
154154
id: session.id,
155-
type: syntheticsContext ? SessionType.SYNTHETICS : ciTestContext ? SessionType.CI_TEST : SessionType.USER,
155+
type: syntheticsContext
156+
? SessionType.SYNTHETICS
157+
: ciVisibilityContext.get()
158+
? SessionType.CI_TEST
159+
: SessionType.USER,
156160
},
157161
view: {
158162
id: viewContext.id,
@@ -162,7 +166,7 @@ export function startRumAssembly(
162166
},
163167
action: needToAssembleWithAction(rawRumEvent) && actionId ? { id: actionId } : undefined,
164168
synthetics: syntheticsContext,
165-
ci_test: ciTestContext,
169+
ci_test: ciVisibilityContext.get(),
166170
display: displayContext.get(),
167171
connectivity: getConnectivity(),
168172
}

packages/rum-core/src/domain/contexts/ciTestContext.spec.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

packages/rum-core/src/domain/contexts/ciTestContext.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)