Skip to content

Commit 46e8819

Browse files
authored
Scope signal buffer to session (#1190)
1 parent 0a30f38 commit 46e8819

File tree

8 files changed

+287
-58
lines changed

8 files changed

+287
-58
lines changed

.changeset/khaki-cheetahs-sit.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@segment/analytics-signals': minor
3+
---
4+
5+
* Clear signal buffer at start of new session
6+
* Prune signalBuffer to maxBufferSize on new session (if different)
7+
* Add sessionStorage storage type
Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,123 @@
1+
import { sleep } from '@segment/analytics-core'
2+
import { range } from '../../../test-helpers/range'
13
import { createInteractionSignal } from '../../../types/factories'
24
import { getSignalBuffer, SignalBuffer } from '../index'
35

46
describe(getSignalBuffer, () => {
57
let buffer: SignalBuffer
68
beforeEach(async () => {
9+
sessionStorage.clear()
710
buffer = getSignalBuffer({
811
maxBufferSize: 10,
912
})
1013
await buffer.clear()
1114
})
15+
describe('indexDB', () => {
16+
it('should instantiate without throwing an error', () => {
17+
expect(buffer).toBeTruthy()
18+
})
19+
it('should add and clear', async () => {
20+
const mockSignal = createInteractionSignal({
21+
eventType: 'submit',
22+
target: {},
23+
})
24+
await buffer.add(mockSignal)
25+
await expect(buffer.getAll()).resolves.toEqual([mockSignal])
26+
await buffer.clear()
27+
await expect(buffer.getAll()).resolves.toHaveLength(0)
28+
})
29+
30+
it('should delete older signals when maxBufferSize is exceeded', async () => {
31+
const signals = range(15).map((_, idx) =>
32+
createInteractionSignal({
33+
idx: idx,
34+
eventType: 'change',
35+
target: {},
36+
})
37+
)
38+
39+
for (const signal of signals) {
40+
await buffer.add(signal)
41+
}
42+
43+
const storedSignals = await buffer.getAll()
44+
expect(storedSignals).toHaveLength(10)
45+
expect(storedSignals).toEqual(signals.slice(-10).reverse())
46+
})
47+
48+
it('should delete older signals on initialize if current number exceeds maxBufferSize', async () => {
49+
const signals = range(15).map((_, idx) =>
50+
createInteractionSignal({
51+
idx: idx,
52+
eventType: 'change',
53+
target: {},
54+
})
55+
)
56+
57+
for (const signal of signals) {
58+
await buffer.add(signal)
59+
}
60+
61+
// Re-initialize buffer
62+
buffer = getSignalBuffer({
63+
maxBufferSize: 10,
64+
})
65+
66+
const storedSignals = await buffer.getAll()
67+
expect(storedSignals).toHaveLength(10)
68+
expect(storedSignals).toEqual(signals.slice(-10).reverse())
69+
})
1270

13-
it('should instantiate without throwing an error', () => {
14-
expect(buffer).toBeTruthy()
71+
it('should clear signal buffer if there is a new session according to session storage', async () => {
72+
const mockSignal = createInteractionSignal({
73+
eventType: 'submit',
74+
target: {},
75+
})
76+
await buffer.add(mockSignal)
77+
await expect(buffer.getAll()).resolves.toEqual([mockSignal])
78+
79+
// Simulate a new session by clearing session storage and re-initializing the buffer
80+
sessionStorage.clear()
81+
await sleep(100)
82+
buffer = getSignalBuffer({
83+
maxBufferSize: 10,
84+
})
85+
86+
await expect(buffer.getAll()).resolves.toHaveLength(0)
87+
})
1588
})
16-
it('should add and clear', async () => {
17-
const mockSignal = createInteractionSignal({
18-
eventType: 'submit',
19-
target: {},
89+
describe('sessionStorage', () => {
90+
it('should instantiate without throwing an error', () => {
91+
expect(buffer).toBeTruthy()
92+
})
93+
94+
it('should add and clear', async () => {
95+
const mockSignal = createInteractionSignal({
96+
eventType: 'submit',
97+
target: {},
98+
})
99+
await buffer.add(mockSignal)
100+
await expect(buffer.getAll()).resolves.toEqual([mockSignal])
101+
await buffer.clear()
102+
await expect(buffer.getAll()).resolves.toHaveLength(0)
103+
})
104+
105+
it('should delete older signals when maxBufferSize is exceeded', async () => {
106+
const signals = range(15).map((_, idx) =>
107+
createInteractionSignal({
108+
idx: idx,
109+
eventType: 'change',
110+
target: {},
111+
})
112+
)
113+
114+
for (const signal of signals) {
115+
await buffer.add(signal)
116+
}
117+
118+
const storedSignals = await buffer.getAll()
119+
expect(storedSignals).toHaveLength(10)
120+
expect(storedSignals).toEqual(signals.slice(-10).reverse())
20121
})
21-
await buffer.add(mockSignal)
22-
await expect(buffer.getAll()).resolves.toEqual([mockSignal])
23-
await buffer.clear()
24-
await expect(buffer.getAll()).resolves.toHaveLength(0)
25122
})
26123
})
Lines changed: 133 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Signal } from '@segment/analytics-signals-runtime'
2-
import { openDB, DBSchema, IDBPDatabase } from 'idb'
2+
import { openDB, DBSchema, IDBPDatabase, IDBPObjectStore } from 'idb'
33
import { logger } from '../../lib/logger'
4+
import { WebStorage } from '../../lib/storage/web-storage'
45

56
interface SignalDatabase extends DBSchema {
67
signals: {
@@ -15,77 +16,147 @@ export interface SignalPersistentStorage {
1516
clear(): void
1617
}
1718

18-
export class SignalStore implements SignalPersistentStorage {
19+
interface IDBPDatabaseSignals extends IDBPDatabase<SignalDatabase> {}
20+
interface IDBPObjectStoreSignals
21+
extends IDBPObjectStore<
22+
SignalDatabase,
23+
['signals'],
24+
'signals',
25+
'readonly' | 'readwrite' | 'versionchange'
26+
> {}
27+
28+
interface StoreSettings {
29+
maxBufferSize: number
30+
}
31+
export class SignalStoreIndexDB implements SignalPersistentStorage {
1932
static readonly DB_NAME = 'Segment Signals Buffer'
2033
static readonly STORE_NAME = 'signals'
21-
private signalStore: Promise<IDBPDatabase<SignalDatabase>>
22-
private signalCount = 0
34+
private db: Promise<IDBPDatabaseSignals>
2335
private maxBufferSize: number
24-
25-
public length() {
26-
return this.signalCount
27-
}
28-
36+
private sessionKeyStorage = new WebStorage(window.sessionStorage)
2937
static deleteDatabase() {
30-
return indexedDB.deleteDatabase(SignalStore.DB_NAME)
38+
return indexedDB.deleteDatabase(SignalStoreIndexDB.DB_NAME)
3139
}
3240

33-
constructor(settings: { maxBufferSize?: number } = {}) {
34-
this.maxBufferSize = settings.maxBufferSize ?? 50
35-
this.signalStore = this.createSignalStore()
36-
void this.initializeSignalCount()
41+
async getStore(
42+
permission: IDBTransactionMode,
43+
database?: IDBPDatabaseSignals
44+
): Promise<IDBPObjectStoreSignals> {
45+
const db = database ?? (await this.db)
46+
const store = db
47+
.transaction(SignalStoreIndexDB.STORE_NAME, permission)
48+
.objectStore(SignalStoreIndexDB.STORE_NAME)
49+
return store
3750
}
3851

39-
private getStore() {
40-
return this.signalStore
52+
constructor(settings: StoreSettings) {
53+
this.maxBufferSize = settings.maxBufferSize
54+
this.db = this.initSignalDB()
4155
}
4256

43-
private async createSignalStore() {
44-
const db = await openDB<SignalDatabase>(SignalStore.DB_NAME, 1, {
57+
private async initSignalDB(): Promise<IDBPDatabaseSignals> {
58+
const db = await openDB<SignalDatabase>(SignalStoreIndexDB.DB_NAME, 1, {
4559
upgrade(db) {
46-
db.createObjectStore(SignalStore.STORE_NAME, { autoIncrement: true })
60+
db.createObjectStore(SignalStoreIndexDB.STORE_NAME, {
61+
autoIncrement: true,
62+
})
4763
},
4864
})
4965
logger.debug('Signals Buffer (indexDB) initialized')
66+
// if the signal buffer is too large, delete the oldest signals (e.g, the settings have changed)
67+
const store = await this.getStore('readwrite', db)
68+
await this.clearStoreIfNeeded(store)
69+
await this.countAndDeleteOldestIfNeeded(store, true)
70+
await store.transaction.done
5071
return db
5172
}
5273

53-
private async initializeSignalCount() {
54-
const store = await this.signalStore
55-
this.signalCount = await store.count(SignalStore.STORE_NAME)
56-
logger.debug(
57-
`Signal count initialized with ${this.signalCount} signals (max: ${this.maxBufferSize})`
58-
)
74+
private async clearStoreIfNeeded(store: IDBPObjectStoreSignals) {
75+
// prevent the signals buffer from persisting across sessions (e.g, user closes tab and reopens)
76+
const sessionKey = 'segment_signals_db_session_key'
77+
if (!sessionStorage.getItem(sessionKey)) {
78+
this.sessionKeyStorage.setItem(sessionKey, true)
79+
await store.clear!()
80+
logger.debug('New Session, so signals buffer cleared')
81+
}
5982
}
6083

6184
async add(signal: Signal): Promise<void> {
62-
const store = await this.signalStore
63-
if (this.signalCount >= this.maxBufferSize) {
64-
// Get the key of the oldest signal and delete it
65-
const oldestKey = await store
66-
.transaction(SignalStore.STORE_NAME)
67-
.store.getKey(IDBKeyRange.lowerBound(0))
68-
if (oldestKey !== undefined) {
69-
await store.delete(SignalStore.STORE_NAME, oldestKey)
70-
} else {
71-
this.signalCount--
85+
const store = await this.getStore('readwrite')
86+
await store.add!(signal)
87+
await this.countAndDeleteOldestIfNeeded(store)
88+
return store.transaction.done
89+
}
90+
91+
private async countAndDeleteOldestIfNeeded(
92+
store: IDBPObjectStoreSignals,
93+
deleteMultiple = false
94+
): Promise<void> {
95+
let count = await store.count()
96+
if (count > this.maxBufferSize) {
97+
const cursor = await store.openCursor()
98+
if (cursor) {
99+
// delete up to maxItems
100+
if (deleteMultiple) {
101+
while (count > this.maxBufferSize) {
102+
await cursor.delete!()
103+
await cursor.continue()
104+
count--
105+
}
106+
logger.debug(
107+
`Signals Buffer: Purged signals to max buffer size of ${this.maxBufferSize}`
108+
)
109+
} else {
110+
// just delete the oldest item
111+
await cursor.delete!()
112+
count--
113+
}
72114
}
73115
}
74-
await store.add(SignalStore.STORE_NAME, signal)
75-
this.signalCount++
76116
}
77117

78118
/**
79119
* Get list of signals from the store, with the newest signals first.
80120
*/
81121
async getAll(): Promise<Signal[]> {
82-
const store = await this.getStore()
83-
return (await store.getAll(SignalStore.STORE_NAME)).reverse()
122+
const store = await this.getStore('readonly')
123+
const signals = await store.getAll()
124+
await store.transaction.done
125+
return signals.reverse()
84126
}
85127

86-
async clear() {
87-
const store = await this.getStore()
88-
return store.clear(SignalStore.STORE_NAME)
128+
async clear(): Promise<void> {
129+
const store = await this.getStore('readwrite')
130+
await store.clear!()
131+
await store.transaction.done
132+
}
133+
}
134+
135+
export class SignalStoreSessionStorage implements SignalPersistentStorage {
136+
private readonly storageKey = 'segment_signals_buffer'
137+
private maxBufferSize: number
138+
139+
constructor(settings: StoreSettings) {
140+
this.maxBufferSize = settings.maxBufferSize
141+
}
142+
143+
add(signal: Signal): void {
144+
const signals = this.getAll()
145+
signals.unshift(signal)
146+
if (signals.length > this.maxBufferSize) {
147+
// delete the last one
148+
signals.splice(-1)
149+
}
150+
sessionStorage.setItem(this.storageKey, JSON.stringify(signals))
151+
}
152+
153+
clear(): void {
154+
sessionStorage.removeItem(this.storageKey)
155+
}
156+
157+
getAll(): Signal[] {
158+
const signals = sessionStorage.getItem(this.storageKey)
159+
return signals ? JSON.parse(signals) : []
89160
}
90161
}
91162

@@ -125,14 +196,33 @@ export class SignalBuffer<
125196
export interface SignalBufferSettingsConfig<
126197
T extends SignalPersistentStorage = SignalPersistentStorage
127198
> {
199+
/**
200+
* Maximum number of signals to store. Only applies if no custom storage implementation is provided.
201+
*/
128202
maxBufferSize?: number
203+
/**
204+
* Choose between sessionStorage and indexDB. Only applies if no custom storage implementation is provided.
205+
* @default 'indexDB'
206+
*/
207+
storageType?: 'session' | 'indexDB'
208+
/**
209+
* Custom storage implementation
210+
* @default SignalStoreIndexDB
211+
*/
129212
signalStorage?: T
130213
}
131214
export const getSignalBuffer = <
132215
T extends SignalPersistentStorage = SignalPersistentStorage
133216
>(
134217
settings: SignalBufferSettingsConfig<T>
135218
) => {
136-
const store = settings.signalStorage ?? new SignalStore(settings)
219+
const settingsWithDefaults: StoreSettings = {
220+
maxBufferSize: 50,
221+
...settings,
222+
}
223+
const store =
224+
settings.signalStorage ?? settings.storageType === 'session'
225+
? new SignalStoreSessionStorage(settingsWithDefaults)
226+
: new SignalStoreIndexDB(settingsWithDefaults)
137227
return new SignalBuffer(store)
138228
}

packages/signals/signals/src/core/signals/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type SignalsSettingsConfig = Pick<
1919
| 'networkSignalsAllowList'
2020
| 'networkSignalsDisallowList'
2121
| 'networkSignalsAllowSameDomain'
22+
| 'signalStorageType'
2223
> & {
2324
signalStorage?: SignalPersistentStorage
2425
processSignal?: string
@@ -52,6 +53,7 @@ export class SignalGlobalSettings {
5253

5354
this.signalBuffer = {
5455
signalStorage: settings.signalStorage,
56+
storageType: settings.signalStorageType,
5557
maxBufferSize: settings.maxBufferSize,
5658
}
5759
this.ingestClient = {

0 commit comments

Comments
 (0)