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

feat: storage options, change priority or use custom #908

Merged
merged 6 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/smooth-seahorses-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-next': minor
---

Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation
50 changes: 50 additions & 0 deletions packages/browser/src/core/analytics/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
import { Context } from '../../context'
import { Plugin } from '../../plugin'
import { EventQueue } from '../../queue/event-queue'
import { StoreType } from '../../storage'
import { Analytics } from '../index'
import jar from 'js-cookie'
import {
TestAfterPlugin,
TestBeforePlugin,
Expand Down Expand Up @@ -271,4 +273,52 @@ describe('Analytics', () => {
expect(fn).toHaveBeenCalledTimes(1)
})
})

describe('storage', () => {
beforeEach(() => {
clearAjsBrowserStorage()
})

it('handles custom priority storage', async () => {
oscb marked this conversation as resolved.
Show resolved Hide resolved
const setCookieSpy = jest.spyOn(jar, 'set')
const expected = 'CookieValue'
jar.set('ajs_anonymous_id', expected)
localStorage.setItem('ajs_anonymous_id', 'localStorageValue')

const analytics = new Analytics(
{ writeKey: '' },
{
storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
}
)

expect(analytics.user().anonymousId()).toEqual(expected)

analytics.user().id('known-user')
expect(analytics.user().id()).toEqual('known-user')
expect(setCookieSpy).toHaveBeenCalled()
})

it('handles disabling storage', async () => {
const setCookieSpy = jest.spyOn(jar, 'set')
const expected = 'CookieValue'
jar.set('ajs_anonymous_id', expected)
localStorage.setItem('ajs_anonymous_id', 'localStorageValue')

const analytics = new Analytics(
{ writeKey: '' },
{
storage: [StoreType.Cookie, StoreType.Memory],
}
)

expect(analytics.user().anonymousId()).toEqual(expected)

analytics.user().id('known-user')
expect(analytics.user().id()).toEqual('known-user')
expect(setCookieSpy).toHaveBeenCalled()
// Local storage shouldn't change
expect(localStorage.getItem('ajs_anonymous_id')).toBe('localStorageValue')
})
})
})
95 changes: 73 additions & 22 deletions packages/browser/src/core/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,7 @@ import {
} from '../events'
import type { Plugin } from '../plugin'
import { EventQueue } from '../queue/event-queue'
import {
CookieOptions,
getAvailableStorageOptions,
Group,
ID,
UniversalStorage,
User,
UserOptions,
} from '../user'
import { Group, ID, User, UserOptions } from '../user'
import autoBind from '../../lib/bind-all'
import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
import type { LegacyDestination } from '../../plugins/ajs-destination'
Expand All @@ -50,6 +42,18 @@ import { getGlobal } from '../../lib/get-global'
import { AnalyticsClassic, AnalyticsCore } from './interfaces'
import { HighEntropyHint } from '../../lib/client-hints/interfaces'
import type { LegacySettings } from '../../browser'
import {
CookieOptions,
MemoryStorage,
UniversalStorage,
Storage,
StorageSettings,
StoreType,
applyCookieOptions,
initializeStorages,
isArrayOfStoreType,
isStorageObject,
} from '../storage'

const deprecationWarning =
'This is being deprecated and will be not be available in future releases of Analytics JS'
Expand Down Expand Up @@ -93,6 +97,7 @@ export interface InitOptions {
disableAutoISOConversion?: boolean
initialPageview?: boolean
cookie?: CookieOptions
storage?: StorageSettings
user?: UserOptions
group?: UserOptions
integrations?: Integrations
Expand Down Expand Up @@ -133,9 +138,7 @@ export class Analytics
private _group: Group
private eventFactory: EventFactory
private _debug = false
private _universalStorage: UniversalStorage<{
[k: string]: unknown
}>
private _universalStorage: Storage

initialized = false
integrations: Integrations
Expand All @@ -162,25 +165,33 @@ export class Analytics
disablePersistance
)

this._universalStorage = new UniversalStorage(
disablePersistance ? ['memory'] : ['localStorage', 'cookie', 'memory'],
getAvailableStorageOptions(cookieOptions)
const storageSetting = options?.storage
this._universalStorage = this.createStore(
disablePersistance,
storageSetting,
cookieOptions
)

this._user =
user ??
new User(
disablePersistance
? { ...options?.user, persist: false }
: options?.user,
{
persist: !disablePersistance,
storage: options?.storage,
// Any User specific options override everything else
...options?.user,
},
cookieOptions
).load()
this._group =
group ??
new Group(
disablePersistance
? { ...options?.group, persist: false }
: options?.group,
{
persist: !disablePersistance,
storage: options?.storage,
// Any group specific options override everything else
...options?.group,
},
cookieOptions
).load()
this.eventFactory = new EventFactory(this._user)
Expand All @@ -194,7 +205,47 @@ export class Analytics
return this._user
}

get storage(): UniversalStorage {
/**
* Creates the storage system based on the settings received
* @returns Storage
*/
private createStore(
disablePersistance: boolean,
storageSetting: InitOptions['storage'],
cookieOptions?: CookieOptions | undefined
): Storage {
// DisablePersistance option overrides all, no storage will be used outside of memory even if specified
if (disablePersistance) {
return new MemoryStorage()
} else {
if (storageSetting) {
if (isArrayOfStoreType(storageSetting)) {
// We will create the store with the priority for customer settings
return new UniversalStorage(
initializeStorages(
applyCookieOptions(storageSetting, cookieOptions)
)
)
} else if (isStorageObject(storageSetting)) {
// If it is an object we will use the customer provided storage
return storageSetting
}
}
}
// We default to our multi storage with priority
return new UniversalStorage(
initializeStorages([
StoreType.LocalStorage,
{
name: StoreType.Cookie,
settings: cookieOptions,
},
StoreType.Memory,
])
)
}

get storage(): Storage {
return this._universalStorage
}

Expand Down
76 changes: 76 additions & 0 deletions packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { CookieStorage } from '../cookieStorage'
import jar from 'js-cookie'
import { disableCookies } from './test-helpers'

describe('cookieStorage', () => {
function clearCookies() {
document.cookie.split(';').forEach(function (c) {
document.cookie = c
.replace(/^ +/, '')
.replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/')
})
}

afterEach(() => {
clearCookies()
})

describe('#available', () => {
afterEach(() => {
jest.restoreAllMocks()
})

it('is available', () => {
const cookie = new CookieStorage()
expect(cookie.available).toBe(true)
})

it("is unavailable if can't write cookies", () => {
disableCookies()
const cookie = new CookieStorage()
expect(cookie.available).toBe(false)
})
})

describe('cookie options', () => {
it('should have default cookie options', () => {
const cookie = new CookieStorage()
expect(cookie['options'].domain).toBe(undefined)
expect(cookie['options'].maxage).toBe(365)
expect(cookie['options'].path).toBe('/')
expect(cookie['options'].sameSite).toBe('Lax')
expect(cookie['options'].secure).toBe(undefined)
})

it('should set options properly', () => {
const cookie = new CookieStorage({
domain: 'foo',
secure: true,
path: '/test',
})
expect(cookie['options'].domain).toBe('foo')
expect(cookie['options'].secure).toBe(true)
expect(cookie['options'].path).toBe('/test')
expect(cookie['options'].secure).toBe(true)
})

it('should pass options when creating cookie', () => {
const jarSpy = jest.spyOn(jar, 'set')
const cookie = new CookieStorage({
domain: 'foo',
secure: true,
path: '/test',
})

cookie.set('foo', 'bar')

expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', {
domain: 'foo',
expires: 365,
path: '/test',
sameSite: 'Lax',
secure: true,
})
})
})
})
70 changes: 70 additions & 0 deletions packages/browser/src/core/storage/__tests__/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { LocalStorage } from '../localStorage'

describe('LocalStorage', function () {
let store: LocalStorage

beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam.
store = new LocalStorage()
})

afterEach(() => {
localStorage.clear()
})

describe('#get', function () {
it('should return null if localStorage throws an error (or does not exist)', function () {
const getItemSpy = jest
.spyOn(global.Storage.prototype, 'getItem')
.mockImplementationOnce(() => {
throw new Error('getItem fail.')
})
store.set('foo', 'some value')
expect(store.get('foo')).toBeNull()
expect(getItemSpy).toBeCalledTimes(1)
})

it('should not get an empty record', function () {
expect(store.get('abc')).toBe(null)
})

it('should get an existing record', function () {
store.set('x', { a: 'b' })
store.set('a', 'hello world')
store.set('b', '')
store.set('c', false)
store.set('d', null)
store.set('e', undefined)

expect(store.get('x')).toStrictEqual({ a: 'b' })
expect(store.get('a')).toBe('hello world')
expect(store.get('b')).toBe('')
expect(store.get('c')).toBe(false)
expect(store.get('d')).toBe(null)
expect(store.get('e')).toBe('undefined')
})
})

describe('#set', function () {
it('should be able to set a record', function () {
store.set('x', { a: 'b' })
expect(store.get('x')).toStrictEqual({ a: 'b' })
})

it('should catch localStorage quota exceeded errors', () => {
const val = 'x'.repeat(10 * 1024 * 1024)
store.set('foo', val)

expect(store.get('foo')).toBe(null)
})
})

describe('#clear', function () {
it('should be able to remove a record', function () {
store.set('x', { a: 'b' })
expect(store.get('x')).toStrictEqual({ a: 'b' })
store.clear('x')
expect(store.get('x')).toBe(null)
})
})
})
19 changes: 19 additions & 0 deletions packages/browser/src/core/storage/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Disables Cookies
* @returns jest spy
*/
export function disableCookies(): jest.SpyInstance {
return jest
.spyOn(window.navigator, 'cookieEnabled', 'get')
.mockReturnValue(false)
}

/**
* Disables LocalStorage
* @returns jest spy
*/
export function disableLocalStorage(): jest.SpyInstance {
return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error()
})
}
Loading