Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3b6cc9e
♻️ add findTrackedSession, TrackedSession, and startSessionManagerStu…
thomas-lebeau Feb 18, 2026
6349707
♻️ extract SessionReplayState and computeSessionReplayState into sess…
thomas-lebeau Feb 18, 2026
1633062
🔥 remove logsSessionManager and use core SessionManager directly in l…
thomas-lebeau Feb 18, 2026
bc093ed
🔥 remove rumSessionManager and use core SessionManager directly in ru…
thomas-lebeau Feb 18, 2026
03d0733
♻️ update rum package to use core SessionManager and computeSessionRe…
thomas-lebeau Feb 18, 2026
cec2b3d
✅ consolidate session manager specs into core and rum-core
thomas-lebeau Feb 20, 2026
8522890
🎨 extract onSessionManagerReady callback to reduce duplication
thomas-lebeau Feb 20, 2026
76f25a6
🎨 format import statements
thomas-lebeau Feb 20, 2026
0705ccd
♻️ remove sampleRate parameter from findTrackedSession
thomas-lebeau Feb 24, 2026
bf0ade8
♻️ merge TrackedSession into SessionContext
thomas-lebeau Feb 25, 2026
a509bfe
♻️ pass sessionManager directly to startUserContext
thomas-lebeau Feb 25, 2026
59d70a4
♻️ simplify sessionManager and sessionManagerStub
thomas-lebeau Feb 25, 2026
8043acb
✅ consolidate session manager test mocks into core package
thomas-lebeau Feb 25, 2026
14a623b
♻️ extract recording condition helpers in postStartStrategy
thomas-lebeau Feb 26, 2026
0a0328e
✨ handle bridge environment in computeSessionReplayState
thomas-lebeau Feb 26, 2026
3d3000b
♻️ simplify canStartRecording and return full session from findTracke…
thomas-lebeau Mar 3, 2026
971dd67
✅ use session ID to drive replay sampling in recorderApi tests
thomas-lebeau Mar 6, 2026
a314c51
♻️ reuse LOW_HASH_UUID instead of MOCK_SESSION_ID in session manager …
thomas-lebeau Mar 6, 2026
af3ad6c
✅ skip hash-based sampling tests when BigInt is unavailable
thomas-lebeau Mar 6, 2026
1bdee48
♻️ replace custom cookie lock with native Web Locks API
thomas-lebeau Feb 26, 2026
76a99ac
♻️ move cookieObservable from rum-core to core
thomas-lebeau Feb 26, 2026
bb318ce
✨ add CookieAccessor abstraction layer
thomas-lebeau Feb 26, 2026
e106ed8
♻️ make SessionStoreStrategy async with event-driven change detection
thomas-lebeau Feb 26, 2026
07d5d54
🎨 minor formatting cleanup in session store files
thomas-lebeau Feb 27, 2026
2dc8eb7
🐛 fix cookie fallback polling firing on every interval instead of on …
thomas-lebeau Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/core/src/browser/browser.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ export interface CookieStore extends EventTarget {
partitioned?: boolean
}>
>
set(options: {
name: string
value: string
domain?: string
path?: string
expires?: number | Date
sameSite?: 'strict' | 'lax' | 'none'
secure?: boolean
partitioned?: boolean
}): Promise<void>
delete(options: { name: string; domain?: string; path?: string }): Promise<void>
}

export interface CookieStoreEventMap {
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/browser/cookieAccess.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { registerCleanupTask } from '../../test'
import { deleteCookie, getCookie } from './cookie'
import { createCookieAccessor } from './cookieAccess'
import type { CookieAccessor } from './cookieAccess'

const COOKIE_NAME = 'test_cookie'

describe('cookieAccess', () => {
let accessor: CookieAccessor

describe('document.cookie fallback', () => {
beforeEach(() => {
Object.defineProperty(globalThis, 'cookieStore', { value: undefined, configurable: true, writable: true })
accessor = createCookieAccessor({})
registerCleanupTask(() => {
deleteCookie(COOKIE_NAME)
delete (globalThis as any).cookieStore
})
})

it('should set a cookie', async () => {
await accessor.set(COOKIE_NAME, 'hello', 60_000)
expect(getCookie(COOKIE_NAME)).toBe('hello')
})

it('should get all cookies', async () => {
await accessor.set(COOKIE_NAME, 'value1', 60_000)
const values = await accessor.getAll(COOKIE_NAME)
expect(values).toContain('value1')
})

it('should delete a cookie', async () => {
await accessor.set(COOKIE_NAME, 'to_delete', 60_000)
await accessor.delete(COOKIE_NAME)
expect(getCookie(COOKIE_NAME)).toBeUndefined()
})
})
})
70 changes: 70 additions & 0 deletions packages/core/src/browser/cookieAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { dateNow } from '../tools/utils/timeUtils'
import type { CookieOptions } from './cookie'
import { getCookies, setCookie, deleteCookie } from './cookie'
import type { CookieStore } from './browser.types'
import type { CookieStoreWindow } from './cookieObservable'

export interface CookieAccessor {
getAll(name: string): Promise<string[]>
set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise<void>
delete(name: string, options?: CookieOptions): Promise<void>
}

export function createCookieAccessor(cookieOptions: CookieOptions): CookieAccessor {
const store = (globalThis as CookieStoreWindow).cookieStore

if (store) {
return createCookieStoreAccessor(store, cookieOptions)
}

return createDocumentCookieAccessor()
}

function createCookieStoreAccessor(store: CookieStore, cookieOptions: CookieOptions): CookieAccessor {
return {
async getAll(name: string): Promise<string[]> {
const cookies = await store.getAll(name)
return cookies.map((cookie) => cookie.value)
},

async set(name: string, value: string, expireDelay: number): Promise<void> {
const expires = new Date(dateNow() + expireDelay)
await store.set({
name,
value,
domain: cookieOptions.domain,
path: '/',
expires,
sameSite: cookieOptions.crossSite ? 'none' : 'strict',
secure: cookieOptions.secure,
partitioned: cookieOptions.partitioned,
})
},

async delete(name: string): Promise<void> {
await store.delete({
name,
domain: cookieOptions.domain,
path: '/',
})
},
}
}

function createDocumentCookieAccessor(): CookieAccessor {
return {
getAll(name: string): Promise<string[]> {
return Promise.resolve(getCookies(name))
},

set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise<void> {
setCookie(name, value, expireDelay, options)
return Promise.resolve()
},

delete(name: string, options?: CookieOptions): Promise<void> {
deleteCookie(name, options)
return Promise.resolve()
},
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
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 { mockRumConfiguration } from '../../test'
import type { Clock } from '../../test'
import { mockClock } from '../../test'
import type { Configuration } from '../domain/configuration'
import type { Subscription } from '../tools/observable'
import { ONE_MINUTE } from '../tools/utils/timeUtils'
import { deleteCookie, setCookie } from './cookie'
import type { CookieStoreWindow } from './cookieObservable'
import { WATCH_COOKIE_INTERVAL_DELAY, createCookieObservable } from './cookieObservable'

const COOKIE_NAME = 'cookie_name'
const COOKIE_DURATION = ONE_MINUTE

function mockConfiguration(): Configuration {
return {} as Configuration
}

describe('cookieObservable', () => {
let subscription: Subscription
let originalSupportedEntryTypes: PropertyDescriptor | undefined
Expand All @@ -27,7 +32,7 @@ describe('cookieObservable', () => {
})

it('should notify observers on cookie change', async () => {
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

const cookieChangePromise = new Promise((resolve) => {
subscription = observable.subscribe(resolve)
Expand All @@ -53,7 +58,7 @@ describe('cookieObservable', () => {

it('should notify observers on cookie change when cookieStore is not supported', () => {
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

let cookieChange: string | undefined
subscription = observable.subscribe((change) => (cookieChange = change))
Expand All @@ -66,7 +71,7 @@ describe('cookieObservable', () => {

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(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)

Expand All @@ -78,4 +83,34 @@ describe('cookieObservable', () => {

expect(cookieChange).toBeUndefined()
})

it('should not re-notify observers if the cookie has not changed since last notification when cookieStore is not supported', () => {
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

const cookieChanges: Array<string | undefined> = []
subscription = observable.subscribe((change) => cookieChanges.push(change))

setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
clock.tick(WATCH_COOKIE_INTERVAL_DELAY) // detects 'foo'
clock.tick(WATCH_COOKIE_INTERVAL_DELAY) // no change since last notification

expect(cookieChanges).toEqual(['foo'])
})

it('should notify observers on consecutive cookie changes when cookieStore is not supported', () => {
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

const cookieChanges: Array<string | undefined> = []
subscription = observable.subscribe((change) => cookieChanges.push(change))

setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)

setCookie(COOKIE_NAME, 'bar', COOKIE_DURATION)
clock.tick(WATCH_COOKIE_INTERVAL_DELAY)

expect(cookieChanges).toEqual(['foo', 'bar'])
})
})
61 changes: 61 additions & 0 deletions packages/core/src/browser/cookieObservable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Configuration } from '../domain/configuration'
import { setInterval, clearInterval } from '../tools/timer'
import { Observable } from '../tools/observable'
import { ONE_SECOND } from '../tools/utils/timeUtils'
import { findCommaSeparatedValue } from '../tools/utils/stringUtils'
import type { CookieStore } from './browser.types'
import { addEventListener, DOM_EVENT } from './addEventListener'

export interface CookieStoreWindow {
cookieStore?: CookieStore
}

export type CookieObservable = ReturnType<typeof createCookieObservable>

export function createCookieObservable(configuration: Configuration, cookieName: string) {
const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore
? listenToCookieStoreChange(configuration)
: watchCookieFallback

return new Observable<string | undefined>((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 =
event.changed.find((event) => event.name === cookieName) ||
event.deleted.find((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) {
let previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName)
const watchCookieIntervalId = setInterval(() => {
const cookieValue = findCommaSeparatedValue(document.cookie, cookieName)
if (cookieValue !== previousCookieValue) {
previousCookieValue = cookieValue
callback(cookieValue)
}
}, WATCH_COOKIE_INTERVAL_DELAY)

return () => {
clearInterval(watchCookieIntervalId)
}
}
20 changes: 20 additions & 0 deletions packages/core/src/domain/session/sessionLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { mockable } from '../../tools/mockable'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'

let lockPromise: Promise<void> | undefined

export function withNativeSessionLock(fn: () => void | Promise<void>): void {
if (navigator?.locks) {
void navigator.locks.request(SESSION_STORE_KEY, fn)
return
}
// Chain async callbacks to prevent interleaving
if (!lockPromise) {
lockPromise = Promise.resolve()
}
lockPromise = lockPromise.then(fn, fn)
}

export function withSessionLock(fn: () => void | Promise<void>): void {
mockable(withNativeSessionLock)(fn)
}
Loading
Loading