Skip to content

Commit

Permalink
Test anonymous id on staging behind ff (#3206)
Browse files Browse the repository at this point in the history
  • Loading branch information
cy-moi authored Dec 11, 2024
1 parent 8c093dd commit f87c2fe
Show file tree
Hide file tree
Showing 22 changed files with 150 additions and 53 deletions.
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
2 changes: 1 addition & 1 deletion packages/core/src/domain/session/sessionStoreOperations.ts
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

0 comments on commit f87c2fe

Please sign in to comment.