Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RUM-6075] Test anonymous id on staging behind ff #3206

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion packages/core/src/browser/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface CookieOptions {
domain?: string
}

export function setCookie(name: string, value: string, expireDelay: number, options?: CookieOptions) {
export function setCookie(name: string, value: string, expireDelay: number = 0, options?: CookieOptions) {
const date = new Date()
date.setTime(date.getTime() + expireDelay)
const expires = `expires=${date.toUTCString()}`
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/domain/session/sessionConstants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ONE_HOUR, ONE_MINUTE } from '../../tools/utils/timeUtils'
import { ONE_HOUR, ONE_MINUTE, ONE_YEAR } from '../../tools/utils/timeUtils'

export const SESSION_TIME_OUT_DELAY = 4 * ONE_HOUR
export const SESSION_EXPIRATION_DELAY = 15 * ONE_MINUTE
export const SESSION_COOKIE_EXPIRATION_DELAY = ONE_YEAR
2 changes: 2 additions & 0 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface SessionContext<TrackingType extends string> extends Context {
id: string
trackingType: TrackingType
isReplayForced: boolean
anonymousId: string | undefined
}

export const VISIBILITY_CHECK_DELAY = ONE_MINUTE
Expand Down Expand Up @@ -86,6 +87,7 @@ export function startSessionManager<TrackingType extends string>(
id: sessionStore.getSession().id!,
trackingType: sessionStore.getSession()[productKey] as TrackingType,
isReplayForced: !!sessionStore.getSession().forcedReplay,
anonymousId: sessionStore.getSession().anonymousId,
}
}

Expand Down
42 changes: 25 additions & 17 deletions packages/core/src/domain/session/sessionState.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
import { isEmptyObject } from '../../tools/utils/objectUtils'
import { objectEntries } from '../../tools/utils/polyfills'
import { dateNow } from '../../tools/utils/timeUtils'
import { generateAnonymousId } from '../user'
import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants'

const SESSION_ENTRY_REGEXP = /^([a-zA-Z]+)=([a-z0-9-]+)$/
const SESSION_ENTRY_SEPARATOR = '&'

import { isValidSessionString, SESSION_ENTRY_REGEXP, SESSION_ENTRY_SEPARATOR } from './sessionStateValidation'
export const EXPIRED = '1'

export interface SessionState {
Expand All @@ -17,10 +16,18 @@ export interface SessionState {
[key: string]: string | undefined
}

export function getExpiredSessionState(): SessionState {
return {
export function getExpiredSessionState(previousSessionState: SessionState | undefined): SessionState {
const expiredSessionState: SessionState = {
isExpired: EXPIRED,
}
if (isExperimentalFeatureEnabled(ExperimentalFeature.ANONYMOUS_USER_TRACKING)) {
if (previousSessionState?.anonymousId) {
expiredSessionState.anonymousId = previousSessionState?.anonymousId
} else {
expiredSessionState.anonymousId = generateAnonymousId()
}
}
return expiredSessionState
}

export function isSessionInNotStartedState(session: SessionState) {
Expand Down Expand Up @@ -50,9 +57,12 @@ export function expandSessionState(session: SessionState) {
}

export function toSessionString(session: SessionState) {
return objectEntries(session)
.map(([key, value]) => `${key}=${value}`)
.join(SESSION_ENTRY_SEPARATOR)
return (
objectEntries(session)
// we use `aid` as a key for anonymousId
.map(([key, value]) => (key === 'anonymousId' ? `aid=${value}` : `${key}=${value}`))
.join(SESSION_ENTRY_SEPARATOR)
)
}

export function toSessionState(sessionString: string | undefined | null) {
Expand All @@ -62,16 +72,14 @@ export function toSessionState(sessionString: string | undefined | null) {
const matches = SESSION_ENTRY_REGEXP.exec(entry)
if (matches !== null) {
const [, key, value] = matches
session[key] = value
if (key === 'aid') {
// we use `aid` as a key for anonymousId
session.anonymousId = value
} else {
session[key] = value
}
}
})
}
return session
}

function isValidSessionString(sessionString: string | undefined | null): sessionString is string {
return (
!!sessionString &&
(sessionString.indexOf(SESSION_ENTRY_SEPARATOR) !== -1 || SESSION_ENTRY_REGEXP.test(sessionString))
)
}
9 changes: 9 additions & 0 deletions packages/core/src/domain/session/sessionStateValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const SESSION_ENTRY_REGEXP = /^([a-zA-Z]+)=([a-z0-9-]+)$/
export const SESSION_ENTRY_SEPARATOR = '&'

export function isValidSessionString(sessionString: string | undefined | null): sessionString is string {
return (
!!sessionString &&
(sessionString.indexOf(SESSION_ENTRY_SEPARATOR) !== -1 || SESSION_ENTRY_REGEXP.test(sessionString))
)
}
13 changes: 7 additions & 6 deletions packages/core/src/domain/session/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ export function startSessionStore<TrackingType extends string>(
function watchSession() {
processSessionStoreOperations(
{
process: (sessionState) => (isSessionInExpiredState(sessionState) ? getExpiredSessionState() : undefined),
process: (sessionState) =>
isSessionInExpiredState(sessionState) ? getExpiredSessionState(sessionState) : undefined,
after: synchronizeSession,
},
sessionStoreStrategy
Expand All @@ -126,7 +127,7 @@ export function startSessionStore<TrackingType extends string>(

function synchronizeSession(sessionState: SessionState) {
if (isSessionInExpiredState(sessionState)) {
sessionState = getExpiredSessionState()
sessionState = getExpiredSessionState(sessionState)
}
if (hasSessionInCache()) {
if (isSessionInCacheOutdated(sessionState)) {
Expand All @@ -144,7 +145,7 @@ export function startSessionStore<TrackingType extends string>(
{
process: (sessionState) => {
if (isSessionInNotStartedState(sessionState)) {
return getExpiredSessionState()
return getExpiredSessionState(sessionState)
}
},
after: (sessionState) => {
Expand Down Expand Up @@ -178,7 +179,7 @@ export function startSessionStore<TrackingType extends string>(
}

function expireSessionInCache() {
sessionCache = getExpiredSessionState()
sessionCache = getExpiredSessionState(sessionCache)
expireObservable.notify()
}

Expand Down Expand Up @@ -207,8 +208,8 @@ export function startSessionStore<TrackingType extends string>(
restartSession: startSession,
expire: () => {
cancelExpandOrRenewSession()
expireSession()
synchronizeSession(getExpiredSessionState())
expireSession(sessionCache)
synchronizeSession(getExpiredSessionState(sessionCache))
},
stop: () => {
clearInterval(watchSessionTimeoutId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const EXPIRED_SESSION: SessionState = { isExpired: '1' }
const now = Date.now()

beforeEach(() => {
sessionStoreStrategy.expireSession()
sessionStoreStrategy.expireSession(initialSession)
initialSession = { id: '123', created: String(now) }
otherSession = { id: '456', created: String(now + 100) }
processSpy = jasmine.createSpy('process')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function processSessionStoreOperations(
}
if (processedSession) {
if (isSessionInExpiredState(processedSession)) {
expireSession()
expireSession(processedSession)
} else {
expandSessionState(processedSession)
isLockEnabled ? persistWithLock(processedSession) : persistSession(processedSession)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ExperimentalFeature, resetExperimentalFeatures } from '../../../tools/experimentalFeatures'
import { mockExperimentalFeatures } from '../../../../test'
import { setCookie, deleteCookie, getCookie, getCurrentSite } from '../../../browser/cookie'
import { type SessionState } from '../sessionState'
import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie'
Expand Down Expand Up @@ -25,7 +27,7 @@ describe('session in cookie strategy', () => {

it('should set `isExpired=1` to the cookie holding the session', () => {
cookieStorageStrategy.persistSession(sessionState)
cookieStorageStrategy.expireSession()
cookieStorageStrategy.expireSession(sessionState)
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ isExpired: '1' })
expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1')
Expand Down Expand Up @@ -108,3 +110,33 @@ describe('session in cookie strategy', () => {
})
})
})
describe('session in cookie strategy with anonymous user tracking', () => {
const anonymousId = 'device-123'
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy

beforeEach(() => {
mockExperimentalFeatures([ExperimentalFeature.ANONYMOUS_USER_TRACKING])
cookieStorageStrategy = initCookieStrategy({})
})

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

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

it('should expire a session with anonymous id in a cookie', () => {
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ isExpired: '1', anonymousId })
expect(getCookie(SESSION_STORE_KEY)).toBe(`isExpired=1&aid=${anonymousId}`)
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { isChromium } from '../../../tools/utils/browserDetection'
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../../tools/experimentalFeatures'
import type { CookieOptions } from '../../../browser/cookie'
import { getCurrentSite, areCookiesAuthorized, getCookie, setCookie } from '../../../browser/cookie'
import type { InitConfiguration } from '../../configuration'
import { tryOldCookiesMigration } from '../oldCookiesMigration'
import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants'
import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants'
import type { SessionState } from '../sessionState'
import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState'
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
Expand All @@ -23,7 +24,7 @@ export function initCookieStrategy(cookieOptions: CookieOptions): SessionStoreSt
isLockEnabled: isChromium(),
persistSession: persistSessionCookie(cookieOptions),
retrieveSession: retrieveSessionCookie,
expireSession: () => expireSessionCookie(cookieOptions),
expireSession: (sessionState: SessionState) => expireSessionCookie(cookieOptions, sessionState),
}

tryOldCookiesMigration(cookieStore)
Expand All @@ -37,13 +38,22 @@ function persistSessionCookie(options: CookieOptions) {
}
}

function expireSessionCookie(options: CookieOptions) {
setCookie(SESSION_STORE_KEY, toSessionString(getExpiredSessionState()), SESSION_TIME_OUT_DELAY, options)
function expireSessionCookie(options: CookieOptions, sessionState: SessionState) {
const expiredSessionState = getExpiredSessionState(sessionState)
setCookie(
SESSION_STORE_KEY,
toSessionString(expiredSessionState),
isExperimentalFeatureEnabled(ExperimentalFeature.ANONYMOUS_USER_TRACKING)
? SESSION_COOKIE_EXPIRATION_DELAY
: SESSION_TIME_OUT_DELAY,
options
)
}

function retrieveSessionCookie(): SessionState {
const sessionString = getCookie(SESSION_STORE_KEY)
return toSessionState(sessionString)
const sessionState = toSessionState(sessionString)
return sessionState
}

export function buildCookieOptions(initConfiguration: InitConfiguration) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { mockExperimentalFeatures } from '../../../../test'
import { ExperimentalFeature } from '../../../tools/experimentalFeatures'
import { type SessionState } from '../sessionState'
import { selectLocalStorageStrategy, initLocalStorageStrategy } from './sessionInLocalStorage'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'

describe('session in local storage strategy', () => {
const sessionState: SessionState = { id: '123', created: '0' }
beforeEach(() => {
mockExperimentalFeatures([ExperimentalFeature.ANONYMOUS_USER_TRACKING])
spyOn(Math, 'random').and.returnValue(1)
})

afterEach(() => {
window.localStorage.clear()
Expand Down Expand Up @@ -31,25 +37,18 @@ describe('session in local storage strategy', () => {
it('should set `isExpired=1` to the local storage item holding the session', () => {
const localStorageStrategy = initLocalStorageStrategy()
localStorageStrategy.persistSession(sessionState)
localStorageStrategy.expireSession()
localStorageStrategy.expireSession(sessionState)
const session = localStorageStrategy?.retrieveSession()
expect(session).toEqual({ isExpired: '1' })
expect(window.localStorage.getItem(SESSION_STORE_KEY)).toBe('isExpired=1')
expect(session).toEqual({ isExpired: '1', anonymousId: '2gosa7pa2gw' })
expect(window.localStorage.getItem(SESSION_STORE_KEY)).toBe('isExpired=1&aid=2gosa7pa2gw')
})

it('should not interfere with other keys present in local storage', () => {
window.localStorage.setItem('test', 'hello')
const localStorageStrategy = initLocalStorageStrategy()
localStorageStrategy.persistSession(sessionState)
localStorageStrategy.retrieveSession()
localStorageStrategy.expireSession()
localStorageStrategy.expireSession(sessionState)
expect(window.localStorage.getItem('test')).toEqual('hello')
})

it('should return an empty object if session string is invalid', () => {
const localStorageStrategy = initLocalStorageStrategy()
localStorage.setItem(SESSION_STORE_KEY, '{test:42}')
const session = localStorageStrategy?.retrieveSession()
expect(session).toEqual({})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ function retrieveSessionFromLocalStorage(): SessionState {
return toSessionState(sessionString)
}

function expireSessionFromLocalStorage() {
persistInLocalStorage(getExpiredSessionState())
function expireSessionFromLocalStorage(previousSessionState: SessionState) {
persistInLocalStorage(getExpiredSessionState(previousSessionState))
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export interface SessionStoreStrategy {
isLockEnabled: boolean
persistSession: (session: SessionState) => void
retrieveSession: () => SessionState
expireSession: () => void
expireSession: (previousSessionState: SessionState) => void
}
8 changes: 7 additions & 1 deletion packages/core/src/domain/user/user.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { display } from '../../tools/display'
import { checkUser, sanitizeUser } from './user'
import { checkUser, generateAnonymousId, sanitizeUser } from './user'
import type { User } from './user.types'

describe('sanitize user function', () => {
Expand Down Expand Up @@ -37,3 +37,9 @@ describe('check user function', () => {
expect(display.error).toHaveBeenCalledTimes(3)
})
})

describe('check anonymous id storage functions', () => {
it('should generate a random anonymous id', () => {
expect(generateAnonymousId()).toMatch(/^[a-z0-9]+$/)
})
})
4 changes: 4 additions & 0 deletions packages/core/src/domain/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ export function checkUser(newUser: User): boolean {
}
return isValid
}

export function generateAnonymousId() {
return Math.floor(Math.random() * Math.pow(2, 53)).toString(36)
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export {
deleteCookie,
resetInitCookies,
} from './browser/cookie'
export { generateAnonymousId } from './domain/user'
export { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser.types'
export { initXhrObservable, XhrCompleteContext, XhrStartContext } from './browser/xhrObservable'
export {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export enum ExperimentalFeature {
WRITABLE_RESOURCE_GRAPHQL = 'writable_resource_graphql',
REMOTE_CONFIGURATION = 'remote_configuration',
LONG_ANIMATION_FRAME = 'long_animation_frame',
ANONYMOUS_USER_TRACKING = 'anonymous_user_tracking',
ACTION_NAME_MASKING = 'action_name_masking',
}

Expand Down
4 changes: 1 addition & 3 deletions packages/core/test/emulate/mockStorages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ export interface MockStorage {
setCurrentValue: (key: string, value: string) => void
}

export function mockCookie(): MockStorage {
let cookie = ''

export function mockCookie(cookie: string = ''): MockStorage {
return {
getSpy: spyOnProperty(document, 'cookie', 'get').and.callFake(() => cookie),
setSpy: spyOnProperty(document, 'cookie', 'set').and.callFake((newCookie) => (cookie = newCookie)),
Expand Down
Loading
Loading