Skip to content
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
74 changes: 72 additions & 2 deletions lib/event_processor/event_processor_factory.browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ vi.mock('../utils/cache/local_storage_cache.browser', () => {
return { LocalStorageCache: vi.fn() };
});

vi.mock('../utils/env/browser', () => {
return {
isLocalStorageAvailable: vi.fn().mockReturnValue(true),
__platforms: ['browser'],
};
});

vi.mock('./event_store', () => {
return {
DEFAULT_MAX_EVENTS_IN_STORE: 500,
Expand Down Expand Up @@ -88,11 +95,15 @@ vi.mock('../utils/cache/store', () => {


import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
import { SyncPrefixStore } from '../utils/cache/store';
import { Store, SyncPrefixStore } from '../utils/cache/store';
import { EventWithId } from './batch_event_processor';
import { isLocalStorageAvailable } from '../utils/env/browser';
import { default as browserDefaultEventDispatcher, default as defaultEventDispatcher } from './event_dispatcher/default_dispatcher.browser';
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor } from './event_processor_factory';
import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor, getPrefixEventStore } from './event_processor_factory';
import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor_factory.browser';
import { LogLevel } from '../logging/logger';
import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message';
import { EventStore } from './event_store';

describe('createForwardingEventProcessor', () => {
Expand Down Expand Up @@ -126,12 +137,71 @@ describe('createBatchEventProcessor', () => {
const MockLocalStorageCache = vi.mocked(LocalStorageCache);
const MockSyncPrefixStore = vi.mocked(SyncPrefixStore);
const MockEventStore = vi.mocked(EventStore);
const mockIsLocalStorageAvailable = vi.mocked(isLocalStorageAvailable);
const mockGetPrefixEventStore = vi.mocked(getPrefixEventStore);

beforeEach(() => {
mockGetOpaqueBatchEventProcessor.mockClear();
MockLocalStorageCache.mockClear();
MockSyncPrefixStore.mockClear();
MockEventStore.mockClear();
mockIsLocalStorageAvailable.mockClear();
mockIsLocalStorageAvailable.mockReturnValue(true);
mockGetPrefixEventStore.mockClear();
});

it('does not use any eventStore and adds a warning startup log if no eventStore is provided and localStorage is not available', () => {
mockIsLocalStorageAvailable.mockReturnValue(false);

const processor = createBatchEventProcessor({ storeTtl: 60_000 });

expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(undefined);
expect(MockEventStore).not.toHaveBeenCalled();
expect(MockLocalStorageCache).not.toHaveBeenCalled();

expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([{
level: LogLevel.Warn,
message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE,
params: [],
}]);
});

it('does not add any startup log if localStorage is available', () => {
mockIsLocalStorageAvailable.mockReturnValue(true);

createBatchEventProcessor({});
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]);
});

it('does not add any startup log if an eventStore is provided and localStorage is not available', () => {
mockIsLocalStorageAvailable.mockReturnValue(false);

createBatchEventProcessor({ eventStore: { operation: 'sync' } as Store<string> });
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]);
});

it('uses the provided eventStore wrapped with getPrefixEventStore regardless of localStorage availability', () => {
const providedStore = { operation: 'sync' } as Store<string>;
const prefixStore = {} as Store<EventWithId>;
mockGetPrefixEventStore.mockReturnValue(prefixStore);

mockIsLocalStorageAvailable.mockReturnValue(true);
const processor1 = createBatchEventProcessor({ eventStore: providedStore });

expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(1, providedStore);
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(prefixStore);

mockIsLocalStorageAvailable.mockReturnValue(false);
const processor2 = createBatchEventProcessor({ eventStore: providedStore });

expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(2, providedStore);
expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].eventStore).toBe(prefixStore);

expect(MockEventStore).not.toHaveBeenCalled();
expect(MockLocalStorageCache).not.toHaveBeenCalled();
});

it('uses an EventStore instance with AsyncStorageCache and correct options if no eventStore is provided', () => {
Expand Down
51 changes: 38 additions & 13 deletions lib/event_processor/event_processor_factory.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,28 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message';
import { LogLevel } from '../logging/logger';
import { Platform } from '../platform_support';
import { StartupLog } from '../service';
import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
import { Store } from '../utils/cache/store';
import { isLocalStorageAvailable } from '../utils/env/browser';
import { EventWithId } from './batch_event_processor';
import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
import { EventDispatcher } from './event_dispatcher/event_dispatcher';
import { EventProcessor } from './event_processor';
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
import {
getOpaqueBatchEventProcessor,
BatchEventProcessorOptions,
OpaqueEventProcessor,
wrapEventProcessor,
FAILED_EVENT_RETRY_INTERVAL,
getForwardingEventProcessor,
getOpaqueBatchEventProcessor,
getPrefixEventStore,
OpaqueEventProcessor,
wrapEventProcessor,
} from './event_processor_factory';
import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
import { FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
import { DEFAULT_MAX_EVENTS_IN_STORE, EventStore } from './event_store';
import { Platform } from '../platform_support';

export const DEFAULT_EVENT_BATCH_SIZE = 10;
export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000;
export const EVENT_MAX_RETRIES_BROWSER = 2;
Expand All @@ -39,15 +45,33 @@ export const createForwardingEventProcessor = (
return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
};

export const createBatchEventProcessor = (
options: BatchEventProcessorOptions = {}
): OpaqueEventProcessor => {
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : new EventStore({
const getDefaultEventStore = (
options: BatchEventProcessorOptions,
startupLogs: StartupLog[],
): Store<EventWithId> | undefined => {
if (!isLocalStorageAvailable()) {
startupLogs.push({
level: LogLevel.Warn,
message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE,
params: [],
});
return undefined;
}

return new EventStore({
store: new LocalStorageCache(),
maxSize: options.batchSize ? Math.max(options.batchSize * 2, DEFAULT_MAX_EVENTS_IN_STORE)
: DEFAULT_MAX_EVENTS_IN_STORE,
ttl: options.storeTtl,
});
};

export const createBatchEventProcessor = (
options: BatchEventProcessorOptions = {}
): OpaqueEventProcessor => {
const startupLogs: StartupLog[] = [];
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) :
getDefaultEventStore(options, startupLogs);

return getOpaqueBatchEventProcessor({
eventDispatcher: options.eventDispatcher || defaultEventDispatcher,
Expand All @@ -63,6 +87,7 @@ export const createBatchEventProcessor = (
failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL,
eventStore,
storeTtl: options.storeTtl,
startupLogs,
});
};

Expand Down
30 changes: 30 additions & 0 deletions lib/event_processor/event_processor_factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,36 @@ describe('getBatchEventProcessor', () => {
}]));
});

it('includes the provided startupLogs along with its own startup logs', () => {
const providedLog = {
level: LogLevel.Warn,
message: 'provided startup log %s',
params: ['param'],
};

const options = {
eventDispatcher: getMockEventDispatcher(),
defaultBatchSize: 77,
defaultFlushInterval: 12345,
startupLogs: [providedLog],
};

const processor = getBatchEventProcessor(options);

expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);

const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
expect(startupLogs).toEqual(expect.arrayContaining([
providedLog,
{
level: LogLevel.Warn,
message: 'Invalid batchSize %s, defaulting to %s',
params: [undefined, 77],
},
]));
expect(options.startupLogs).toEqual([providedLog]);
});

it('does not use a failedEventRepeater if failedEventRetryInterval is not provided', () => {
const options = {
eventDispatcher: getMockEventDispatcher(),
Expand Down
3 changes: 2 additions & 1 deletion lib/event_processor/event_processor_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type BatchEventProcessorFactoryOptions = Omit<BatchEventProcessorOptions,
minBackoff?: number;
maxBackoff?: number;
};
startupLogs?: StartupLog[];
}

export const validateEventDispatcher = (eventDispatcher: EventDispatcher): void => {
Expand Down Expand Up @@ -109,7 +110,7 @@ export const getBatchEventProcessor = (
}
} : undefined;

const startupLogs: StartupLog[] = [];
const startupLogs: StartupLog[] = [...(options.startupLogs ?? [])];

const { defaultFlushInterval, defaultBatchSize } = options;

Expand Down
2 changes: 2 additions & 0 deletions lib/message/error_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export const PROMISE_NOT_ALLOWED = "Promise value is not allowed in sync operati
export const SERVICE_NOT_RUNNING = "%s not running";
export const EVENT_STORE_FULL = 'Event store is full. Not saving event with id %d.';
export const LOCAL_HOLDOUT_MISSING_INCLUDED_RULES = 'Local holdout "%s" is missing or has empty "includedRules"; skipping.';
export const EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE =
'localStorage is not available, events will not be persisted.';

export const messages: string[] = [];

Expand Down
66 changes: 66 additions & 0 deletions lib/utils/env/browser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Copyright 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { it, describe, expect, beforeEach, afterEach } from 'vitest';
import { isLocalStorageAvailable } from './browser';

describe('isLocalStorageAvailable', () => {
let originalDescriptor: PropertyDescriptor | undefined;

const mockLocalStorageGetter = (get: () => unknown) => {
Object.defineProperty(globalThis, 'localStorage', { get, configurable: true });
};

beforeEach(() => {
originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
});

afterEach(() => {
if (originalDescriptor) {
Object.defineProperty(globalThis, 'localStorage', originalDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'localStorage');
}
});

it('should return true when localStorage global can be accessed', () => {
mockLocalStorageGetter(() => ({}));

expect(isLocalStorageAvailable()).toBe(true);
});

it('should return false when localStorage is null', () => {
mockLocalStorageGetter(() => null);

expect(isLocalStorageAvailable()).toBe(false);
});

it('should return false when localStorage is not defined', () => {
mockLocalStorageGetter(() => {
throw new ReferenceError('localStorage is not defined');
});

expect(isLocalStorageAvailable()).toBe(false);
});

it('should return false when accessing localStorage throws a SecurityError', () => {
mockLocalStorageGetter(() => {
throw new DOMException('The operation is insecure.', 'SecurityError');
});

expect(isLocalStorageAvailable()).toBe(false);
});
});
32 changes: 32 additions & 0 deletions lib/utils/env/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Platform } from '../../platform_support';

/**
* Checks whether localStorage exists and is accessible. Referencing it throws a
* ReferenceError where it is not defined (e.g. SSR) and a SecurityError where
* storage is blocked (e.g. cookies disabled, sandboxed iframes).
*/
export const isLocalStorageAvailable = (): boolean => {
try {
return !!localStorage;
} catch {
return false;
}
};

export const __platforms: Platform[] = ['browser'];
Loading