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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/core/src/browser/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { display } from '../tools/display'
import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
import { findCommaSeparatedValue, findCommaSeparatedValues, generateUUID } from '../tools/utils/stringUtils'
import {
findAllCommaSeparatedValues,
findCommaSeparatedValue,
findCommaSeparatedValues,
generateUUID,
} from '../tools/utils/stringUtils'
import { buildUrl } from '../tools/utils/urlPolyfill'

export interface CookieOptions {
Expand All @@ -21,15 +26,28 @@ export function setCookie(name: string, value: string, expireDelay: number = 0,
document.cookie = `${name}=${value};${expires};path=/;samesite=${sameSite}${domain}${secure}${partitioned}`
}

/**
* Returns the value of the cookie with the given name
* If there are multiple cookies with the same name, returns the first one
*/
export function getCookie(name: string) {
return findCommaSeparatedValue(document.cookie, name)
}

/**
* Returns all the values of the cookies with the given name
*/
export function getCookies(name: string): string[] | undefined {
return findAllCommaSeparatedValues(document.cookie).get(name)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: to simplify a bit, always return an array (similar to cookieStore.getAll())

Suggested change
export function getCookies(name: string): string[] | undefined {
return findAllCommaSeparatedValues(document.cookie).get(name)
}
export function getCookies(name: string): string[] {
return findAllCommaSeparatedValues(document.cookie).get(name) || []
}


let initCookieParsed: Map<string, string> | undefined

/**
* Returns a cached value of the cookie. Use this during SDK initialization (and whenever possible)
* to avoid accessing document.cookie multiple times.
*
* ⚠️ If there are multiple cookies with the same name, returns the LAST one (unlike `getCookie()`)
*/
export function getInitCookie(name: string) {
if (!initCookieParsed) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export interface Configuration extends TransportConfiguration {
// Built from init configuration
beforeSend: GenericBeforeSendCallback | undefined
sessionStoreStrategyType: SessionStoreStrategyType | undefined
usePartitionedCrossSiteSessionCookie: boolean
sessionSampleRate: number
telemetrySampleRate: number
telemetryConfigurationSampleRate: number
Expand Down Expand Up @@ -401,6 +402,7 @@ export function validateAndBuildConfiguration(
beforeSend:
initConfiguration.beforeSend && catchUserErrors(initConfiguration.beforeSend, 'beforeSend threw an error:'),
sessionStoreStrategyType: isWorkerEnvironment ? undefined : selectSessionStoreStrategyType(initConfiguration),
usePartitionedCrossSiteSessionCookie: initConfiguration.usePartitionedCrossSiteSessionCookie ?? false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion: use cookieOption.partitioned or crossSite instead of exposing this property to the config‏

sessionSampleRate: initConfiguration.sessionSampleRate ?? 100,
telemetrySampleRate: initConfiguration.telemetrySampleRate ?? 20,
telemetryConfigurationSampleRate: initConfiguration.telemetryConfigurationSampleRate ?? 5,
Expand Down
45 changes: 32 additions & 13 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ import { getCurrentSite } from '../../browser/cookie'
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
import { findLast } from '../../tools/utils/polyfills'
import { monitorError } from '../../tools/monitor'
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY } from './sessionConstants'
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
import { startSessionStore } from './sessionStore'
import type { SessionState } from './sessionState'
import { toSessionState } from './sessionState'
import { retrieveSessionCookie } from './storeStrategies/sessionInCookie'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage'

export interface SessionManager<TrackingType extends string> {
findSession: (
Expand Down Expand Up @@ -107,7 +108,7 @@ export function startSessionManager<TrackingType extends string>(
const session = sessionStore.getSession()

if (!session) {
reportUnexpectedSessionState().catch(() => void 0) // Ignore errors
reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors

return {
id: 'invalid',
Expand Down Expand Up @@ -172,17 +173,35 @@ function trackResume(configuration: Configuration, cb: () => void) {
stopCallbacks.push(stop)
}

async function reportUnexpectedSessionState() {
const rawSession = retrieveSessionCookie()
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
addTelemetryDebug('Unexpected session state', {
session: rawSession,
isSyntheticsTest: isSyntheticsTest(),
createdTimestamp: rawSession?.created,
expireTimestamp: rawSession?.expire,
cookie: await getSessionCookies(),
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
})
async function reportUnexpectedSessionState(configuration: Configuration) {
const sessionStoreStrategyType = configuration.sessionStoreStrategyType
if (!sessionStoreStrategyType) {
return
}

if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) {
const rawSession = retrieveSessionCookie(configuration, sessionStoreStrategyType.cookieOptions)
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
addTelemetryDebug('Unexpected session state', {
sessionStoreStrategyType: sessionStoreStrategyType.type,
session: rawSession,
isSyntheticsTest: isSyntheticsTest(),
createdTimestamp: rawSession?.created,
expireTimestamp: rawSession?.expire,
cookie: await getSessionCookies(),
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
})
} else if (sessionStoreStrategyType.type === SessionPersistence.LOCAL_STORAGE) {
const rawSession = retrieveSessionFromLocalStorage()
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
addTelemetryDebug('Unexpected session state', {
sessionStoreStrategyType: sessionStoreStrategyType.type,
session: rawSession,
isSyntheticsTest: isSyntheticsTest(),
createdTimestamp: rawSession?.created,
expireTimestamp: rawSession?.expire,
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: factorize a bit

let rawSession
let cookieContext
if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) {
  rawSession = retrieveSessionCookie(configuration, sessionStoreStrategyType.cookieOptions)
  cookieContext = {
    cookie: await getSessionCookies(),
    currentDomain: `${window.location.protocol}//${window.location.hostname}`,
  }
} else {
  rawSession = retrieveSessionFromLocalStorage()
}


  // monitor-until: forever, could be handy to troubleshoot issues until session manager rework
  addTelemetryDebug('Unexpected session state', {
    sessionStoreStrategyType: sessionStoreStrategyType.type,
    session: rawSession,
    isSyntheticsTest: isSyntheticsTest(),
    createdTimestamp: rawSession?.created,
    expireTimestamp: rawSession?.expire,
    ...cookieContext
  })

}
}

function detectSessionIdChange(configuration: Configuration, initialSessionState: SessionState) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import { mockClock, getSessionState } from '../../../../test'
import { ExperimentalFeature } from '../../../tools/experimentalFeatures'
import { mockClock, getSessionState, registerCleanupTask, mockExperimentalFeatures } from '../../../../test'
import { setCookie, deleteCookie, getCookie, getCurrentSite } from '../../../browser/cookie'
import type { SessionState } from '../sessionState'
import type { Configuration } from '../../configuration'
import { validateAndBuildConfiguration } from '../../configuration'
import type { InitConfiguration } from '../../configuration'
import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants'
import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie'
import type { SessionStoreStrategy } from './sessionStoreStrategy'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'

export const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration
describe('session in cookie strategy', () => {
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy
const DEFAULT_INIT_CONFIGURATION = { clientToken: 'abc', trackAnonymousUser: true }

beforeEach(() => {
cookieStorageStrategy = initCookieStrategy(DEFAULT_INIT_CONFIGURATION, {})
})
function setupCookieStrategy(partialInitConfiguration: Partial<InitConfiguration> = {}) {
const initConfiguration = {
...DEFAULT_INIT_CONFIGURATION,
...partialInitConfiguration,
} as InitConfiguration

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})
const configuration = validateAndBuildConfiguration(initConfiguration)!
const cookieOptions = buildCookieOptions(initConfiguration)!

registerCleanupTask(() => deleteCookie(SESSION_STORE_KEY, cookieOptions))

return initCookieStrategy(configuration, cookieOptions)
}

describe('session in cookie strategy', () => {
const sessionState: SessionState = { id: '123', created: '0' }

it('should persist a session in a cookie', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.persistSession(sessionState)
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ ...sessionState })
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0')
})

it('should set `isExpired=1` and `aid` to the cookie holding the session', () => {
const cookieStorageStrategy = setupCookieStrategy()
spyOn(Math, 'random').and.callFake(() => 0)
cookieStorageStrategy.persistSession(sessionState)
cookieStorageStrategy.expireSession(sessionState)
Expand All @@ -37,6 +46,7 @@ describe('session in cookie strategy', () => {
})

it('should return an empty object if session string is invalid', () => {
const cookieStorageStrategy = setupCookieStrategy()
setCookie(SESSION_STORE_KEY, '{test:42}', 1000)
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({})
Expand Down Expand Up @@ -101,37 +111,69 @@ describe('session in cookie strategy', () => {
})
})
})

describe('encode cookie options', () => {
beforeEach(() => {
mockExperimentalFeatures([ExperimentalFeature.ENCODE_COOKIE_OPTIONS])
})

it('should encode cookie options in the cookie value', () => {
// Some older browsers don't support partitioned cross-site session cookies
// so instead of testing the cookie value, we test the call to the cookie setter
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
cookieStorageStrategy.persistSession({ id: '123' })

const calls = cookieSetSpy.calls.all()
const lastCall = calls[calls.length - 1]
expect(lastCall.args[0]).toMatch(/^_dd_s=id=123&c=1/)
})

it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', () => {
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
cookieStorageStrategy.persistSession({})

expect(getCookie(SESSION_STORE_KEY)).toBeUndefined()
})

it('should return the correct session state from the cookies', () => {
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=456&c=1;_dd_s=id=789&c=2')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })

expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' })
})

it('should return the session state from the first cookie if there is no match', () => {
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=789&c=2')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })

expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' })
})
})
})

describe('session in cookie strategy when opt-in anonymous user tracking', () => {
const anonymousId = 'device-123'
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy
beforeEach(() => {
cookieStorageStrategy = initCookieStrategy(
{ ...DEFAULT_INIT_CONFIGURATION, trackAnonymousUser: true } as Configuration,
{}
)
})

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})
it('should persist with anonymous id', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ ...sessionState, anonymousId })
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&aid=device-123')
})

it('should expire with anonymous id', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ isExpired: '1', anonymousId })
expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1&aid=device-123')
})

it('should persist for one year when opt-in', () => {
const cookieStorageStrategy = setupCookieStrategy()
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
Expand All @@ -141,6 +183,7 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
})

it('should expire in one year when opt-in', () => {
const cookieStorageStrategy = setupCookieStrategy()
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
Expand All @@ -153,30 +196,24 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
describe('session in cookie strategy when opt-out anonymous user tracking', () => {
const anonymousId = 'device-123'
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy

beforeEach(() => {
cookieStorageStrategy = initCookieStrategy({ trackAnonymousUser: false } as Configuration, {})
})

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})

it('should not extend cookie expiration time when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(clock.timeStamp(SESSION_TIME_OUT_DELAY)).toUTCString())
})

it('should not persist with one year when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(Date.now() + SESSION_EXPIRATION_DELAY).toUTCString())
})

it('should not persist or expire a session with anonymous id when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
Expand Down
Loading