Skip to content

Commit

Permalink
feat: storage options, change priority or use custom
Browse files Browse the repository at this point in the history
  • Loading branch information
oscb committed Jul 20, 2023
1 parent 48ce3ec commit 352ee82
Show file tree
Hide file tree
Showing 17 changed files with 1,037 additions and 616 deletions.
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
28 changes: 28 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,30 @@ describe('Analytics', () => {
expect(fn).toHaveBeenCalledTimes(1)
})
})

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

it('handles custom priority 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.LocalStorage, StoreType.Memory],
}
)

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

analytics.user().id('known-user')
expect(analytics.user().id()).toEqual('known-user')
expect(setCookieSpy).toHaveBeenCalled()
})
})
})
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 !== undefined && storageSetting !== null) {
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)
})
})
})
Loading

0 comments on commit 352ee82

Please sign in to comment.