From 354d3a5832c15cff4735d5a7d9e4c3ec863afb83 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 13:20:33 -0800 Subject: [PATCH 1/8] feat: Add public API for setting event dispatcher --- src/client/eppo-client.ts | 18 ++++---- src/events/default-event-dispatcher.spec.ts | 22 +++++++++- src/events/default-event-dispatcher.ts | 48 +++++++++++++++++++++ src/events/event-delivery.ts | 2 +- src/index.ts | 12 ++++-- 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 3ae2b2f..ae33739 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -22,6 +22,7 @@ import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; import ArrayBackedNamedEventQueue from '../events/array-backed-named-event-queue'; import { BoundedEventQueue } from '../events/bounded-event-queue'; +import { EventDispatcherConfig } from '../events/default-event-dispatcher'; import EventDispatcher from '../events/event-dispatcher'; import NoOpEventDispatcher from '../events/no-op-event-dispatcher'; import { @@ -79,17 +80,8 @@ export interface IContainerExperiment { treatmentVariationEntries: Array; } -const DEFAULT_EVENT_DISPATCHER_CONFIG = { - // TODO: Replace with actual ingestion URL - ingestionUrl: 'https://example.com/events', - batchSize: 10, - flushIntervalMs: 10_000, - retryIntervalMs: 5_000, - maxRetries: 3, -}; - export default class EppoClient { - private readonly eventDispatcher: EventDispatcher; + private eventDispatcher: EventDispatcher; private readonly assignmentEventsQueue: BoundedEventQueue = newBoundedArrayEventQueue('assignments'); private readonly banditEventsQueue: BoundedEventQueue = @@ -152,6 +144,12 @@ export default class EppoClient { this.banditVariationConfigurationStore = banditVariationConfigurationStore; } + /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ + // noinspection JSUnusedGlobalSymbols + setEventDispatcher(eventDispatcher: EventDispatcher) { + this.eventDispatcher = eventDispatcher; + } + // noinspection JSUnusedGlobalSymbols setBanditModelConfigurationStore( banditModelConfigurationStore: IConfigurationStore, diff --git a/src/events/default-event-dispatcher.spec.ts b/src/events/default-event-dispatcher.spec.ts index a773b33..f1f2737 100644 --- a/src/events/default-event-dispatcher.spec.ts +++ b/src/events/default-event-dispatcher.spec.ts @@ -2,7 +2,10 @@ import { resolve } from 'eslint-import-resolver-typescript'; import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; import BatchEventProcessor from './batch-event-processor'; -import DefaultEventDispatcher, { EventDispatcherConfig } from './default-event-dispatcher'; +import DefaultEventDispatcher, { + EventDispatcherConfig, + newDefaultEventDispatcher, +} from './default-event-dispatcher'; import { Event } from './event-dispatcher'; import NetworkStatusListener from './network-status-listener'; @@ -198,4 +201,21 @@ describe('DefaultEventDispatcher', () => { expect(global.fetch).toHaveBeenCalled(); }); }); + + describe('newDefaultEventDispatcher', () => { + it('should create a new DefaultEventDispatcher with the provided configuration', () => { + const networkStatusListener = { + isOffline: () => false, + onNetworkStatusChange: (callback: (isOffline: boolean) => void) => null as unknown as void, + triggerNetworkStatusChange: () => null, + }; + const eventQueue = new ArrayBackedNamedEventQueue('test-queue'); + const dispatcher = newDefaultEventDispatcher( + eventQueue, + networkStatusListener, + 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', + ); + expect(dispatcher).toBeInstanceOf(DefaultEventDispatcher); + }); + }); }); diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index e6addb3..4009608 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -4,7 +4,9 @@ import BatchEventProcessor from './batch-event-processor'; import BatchRetryManager from './batch-retry-manager'; import EventDelivery from './event-delivery'; import EventDispatcher, { Event } from './event-dispatcher'; +import NamedEventQueue from './named-event-queue'; import NetworkStatusListener from './network-status-listener'; +import SdkKeyDecoder from './sdk-key-decoder'; export type EventDispatcherConfig = { // target url to deliver events to @@ -19,6 +21,16 @@ export type EventDispatcherConfig = { maxRetries?: number; }; +// TODO: Have more realistic default batch size based on average event payload size once we have +// more concrete data. +export const DEFAULT_EVENT_DISPATCHER_BATCH_SIZE = 100; +export const DEFAULT_EVENT_DISPATCHER_CONFIG: Omit = { + deliveryIntervalMs: 10_000, + retryIntervalMs: 5_000, + maxRetryDelayMs: 30_000, + maxRetries: 3, +}; + /** * @internal * An {@link EventDispatcher} that, given the provided config settings, delivers events in batches @@ -37,6 +49,7 @@ export default class DefaultEventDispatcher implements EventDispatcher { private readonly networkStatusListener: NetworkStatusListener, config: EventDispatcherConfig, ) { + this.ensureConfigFields(config); this.eventDelivery = new EventDelivery(config.ingestionUrl); this.retryManager = new BatchRetryManager(this.eventDelivery, { retryIntervalMs: config.retryIntervalMs, @@ -94,4 +107,39 @@ export default class DefaultEventDispatcher implements EventDispatcher { this.dispatchTimer = setTimeout(() => this.deliverNextBatch(), this.deliveryIntervalMs); } } + + private ensureConfigFields(config: EventDispatcherConfig) { + if (!config.ingestionUrl) { + throw new Error('Missing required ingestionUrl in EventDispatcherConfig'); + } + if (!config.deliveryIntervalMs) { + throw new Error('Missing required deliveryIntervalMs in EventDispatcherConfig'); + } + if (!config.retryIntervalMs) { + throw new Error('Missing required retryIntervalMs in EventDispatcherConfig'); + } + if (!config.maxRetryDelayMs) { + throw new Error('Missing required maxRetryDelayMs in EventDispatcherConfig'); + } + } +} + +/** Creates a new {@link DefaultEventDispatcher} with the provided configuration. */ +export function newDefaultEventDispatcher( + eventQueue: NamedEventQueue, + networkStatusListener: NetworkStatusListener, + sdkKey: string, + batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, + config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, +): DefaultEventDispatcher { + const sdkKeyDecoder = new SdkKeyDecoder(); + const ingestionUrl = sdkKeyDecoder.decodeEventIngestionHostName(sdkKey); + if (!ingestionUrl) { + throw new Error('Unable to parse Event ingestion URL from SDK key'); + } + return new DefaultEventDispatcher( + new BatchEventProcessor(eventQueue, batchSize), + networkStatusListener, + { ...config, ingestionUrl }, + ); } diff --git a/src/events/event-delivery.ts b/src/events/event-delivery.ts index c21831e..d827ea8 100644 --- a/src/events/event-delivery.ts +++ b/src/events/event-delivery.ts @@ -1,7 +1,7 @@ import { logger } from '../application-logger'; export default class EventDelivery { - constructor(private ingestionUrl: string) {} + constructor(private readonly ingestionUrl: string) {} async deliver(batch: unknown[]): Promise { try { diff --git a/src/index.ts b/src/index.ts index 98a05a7..55ababc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,12 @@ import { import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import * as constants from './constants'; -import ArrayBackedNamedEventQueue from './events/array-backed-named-event-queue'; import BatchEventProcessor from './events/batch-event-processor'; -import DefaultEventDispatcher from './events/default-event-dispatcher'; +import DefaultEventDispatcher, { + DEFAULT_EVENT_DISPATCHER_CONFIG, + DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, + newDefaultEventDispatcher, +} from './events/default-event-dispatcher'; import EventDispatcher from './events/event-dispatcher'; import NamedEventQueue from './events/named-event-queue'; import NetworkStatusListener from './events/network-status-listener'; @@ -93,9 +96,12 @@ export { BanditSubjectAttributes, BanditActions, - // event queue types + // event dispatcher types NamedEventQueue, EventDispatcher, + DEFAULT_EVENT_DISPATCHER_CONFIG, + DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, + newDefaultEventDispatcher, BatchEventProcessor, NetworkStatusListener, DefaultEventDispatcher, From 05f66aaf5fa91c8ba387ba800f6b285a94e378af Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 13:23:59 -0800 Subject: [PATCH 2/8] nit --- src/events/default-event-dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts index 4009608..9fd5481 100644 --- a/src/events/default-event-dispatcher.ts +++ b/src/events/default-event-dispatcher.ts @@ -131,7 +131,7 @@ export function newDefaultEventDispatcher( sdkKey: string, batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, config: Omit = DEFAULT_EVENT_DISPATCHER_CONFIG, -): DefaultEventDispatcher { +): EventDispatcher { const sdkKeyDecoder = new SdkKeyDecoder(); const ingestionUrl = sdkKeyDecoder.decodeEventIngestionHostName(sdkKey); if (!ingestionUrl) { From 1abba22f94fb5096d0a637c90eeedb2080b6fa9b Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 13:26:05 -0800 Subject: [PATCH 3/8] test --- src/events/default-event-dispatcher.spec.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/events/default-event-dispatcher.spec.ts b/src/events/default-event-dispatcher.spec.ts index f1f2737..cec3ba6 100644 --- a/src/events/default-event-dispatcher.spec.ts +++ b/src/events/default-event-dispatcher.spec.ts @@ -203,16 +203,21 @@ describe('DefaultEventDispatcher', () => { }); describe('newDefaultEventDispatcher', () => { + it('should throw if SDK key is invalid', () => { + expect(() => { + newDefaultEventDispatcher( + new ArrayBackedNamedEventQueue('test-queue'), + mockNetworkStatusListener, + 'invalid-sdk-key', + ); + }).toThrow('Unable to parse Event ingestion URL from SDK key'); + }); + it('should create a new DefaultEventDispatcher with the provided configuration', () => { - const networkStatusListener = { - isOffline: () => false, - onNetworkStatusChange: (callback: (isOffline: boolean) => void) => null as unknown as void, - triggerNetworkStatusChange: () => null, - }; const eventQueue = new ArrayBackedNamedEventQueue('test-queue'); const dispatcher = newDefaultEventDispatcher( eventQueue, - networkStatusListener, + mockNetworkStatusListener, 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', ); expect(dispatcher).toBeInstanceOf(DefaultEventDispatcher); From 11bd6b22d876d03749e193f0b03c4a12949f55ac Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 14:42:12 -0800 Subject: [PATCH 4/8] Make BoundedEventQueue implement NamedEventQueue --- src/events/bounded-event-queue.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/events/bounded-event-queue.ts b/src/events/bounded-event-queue.ts index 700fe3b..7e701b6 100644 --- a/src/events/bounded-event-queue.ts +++ b/src/events/bounded-event-queue.ts @@ -4,12 +4,28 @@ import { MAX_EVENT_QUEUE_SIZE } from '../constants'; import NamedEventQueue from './named-event-queue'; /** A bounded event queue that drops events when it reaches its maximum size. */ -export class BoundedEventQueue { +export class BoundedEventQueue implements NamedEventQueue { constructor( private readonly queue: NamedEventQueue, private readonly maxSize = MAX_EVENT_QUEUE_SIZE, ) {} + length = this.queue.length; + + name = this.queue.name; + + splice(count: number): T[] { + return this.queue.splice(count); + } + + isEmpty(): boolean { + return this.queue.isEmpty(); + } + + [Symbol.iterator](): IterableIterator { + return this.queue[Symbol.iterator](); + } + push(event: T) { if (this.queue.length < this.maxSize) { this.queue.push(event); From 0bd1de8d06426d91006cfda5070554001ff1184e Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 15:05:52 -0800 Subject: [PATCH 5/8] simplify --- src/client/eppo-client.ts | 10 ++-------- src/events/bounded-event-queue.ts | 10 +++++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index ae33739..f6447f8 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -20,9 +20,7 @@ import { import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; -import ArrayBackedNamedEventQueue from '../events/array-backed-named-event-queue'; import { BoundedEventQueue } from '../events/bounded-event-queue'; -import { EventDispatcherConfig } from '../events/default-event-dispatcher'; import EventDispatcher from '../events/event-dispatcher'; import NoOpEventDispatcher from '../events/no-op-event-dispatcher'; import { @@ -83,9 +81,9 @@ export interface IContainerExperiment { export default class EppoClient { private eventDispatcher: EventDispatcher; private readonly assignmentEventsQueue: BoundedEventQueue = - newBoundedArrayEventQueue('assignments'); + new BoundedEventQueue('assignments'); private readonly banditEventsQueue: BoundedEventQueue = - newBoundedArrayEventQueue('bandit'); + new BoundedEventQueue('bandit'); private readonly banditEvaluator = new BanditEvaluator(); private banditLogger?: IBanditLogger; private banditAssignmentCache?: AssignmentCache; @@ -1143,7 +1141,3 @@ export function checkValueTypeMatch( return false; } } - -function newBoundedArrayEventQueue(name: string): BoundedEventQueue { - return new BoundedEventQueue(new ArrayBackedNamedEventQueue(name)); -} diff --git a/src/events/bounded-event-queue.ts b/src/events/bounded-event-queue.ts index 7e701b6..06bd799 100644 --- a/src/events/bounded-event-queue.ts +++ b/src/events/bounded-event-queue.ts @@ -1,25 +1,25 @@ import { logger } from '../application-logger'; import { MAX_EVENT_QUEUE_SIZE } from '../constants'; +import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; import NamedEventQueue from './named-event-queue'; /** A bounded event queue that drops events when it reaches its maximum size. */ export class BoundedEventQueue implements NamedEventQueue { constructor( - private readonly queue: NamedEventQueue, + readonly name: string, + private readonly queue = new Array(), private readonly maxSize = MAX_EVENT_QUEUE_SIZE, ) {} length = this.queue.length; - name = this.queue.name; - splice(count: number): T[] { return this.queue.splice(count); } isEmpty(): boolean { - return this.queue.isEmpty(); + return this.queue.length === 0; } [Symbol.iterator](): IterableIterator { @@ -30,7 +30,7 @@ export class BoundedEventQueue implements NamedEventQueue { if (this.queue.length < this.maxSize) { this.queue.push(event); } else { - logger.warn(`Dropping event for queue ${this.queue.name} since the queue is full`); + logger.warn(`Dropping event for queue ${this.name} since the queue is full`); } } From 66aa69971c488927297e7aa611300d6dda0fd706 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 15:17:46 -0800 Subject: [PATCH 6/8] export BoundedEventQueue --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 55ababc..1aca805 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import * as constants from './constants'; import BatchEventProcessor from './events/batch-event-processor'; +import { BoundedEventQueue } from './events/bounded-event-queue'; import DefaultEventDispatcher, { DEFAULT_EVENT_DISPATCHER_CONFIG, DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, @@ -99,6 +100,7 @@ export { // event dispatcher types NamedEventQueue, EventDispatcher, + BoundedEventQueue, DEFAULT_EVENT_DISPATCHER_CONFIG, DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, newDefaultEventDispatcher, From de38e1a206455f61d42e877dbdcfa968b32cbcf9 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 19:50:00 -0800 Subject: [PATCH 7/8] cleanup import --- src/events/bounded-event-queue.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/events/bounded-event-queue.ts b/src/events/bounded-event-queue.ts index 06bd799..f82f633 100644 --- a/src/events/bounded-event-queue.ts +++ b/src/events/bounded-event-queue.ts @@ -1,7 +1,6 @@ import { logger } from '../application-logger'; import { MAX_EVENT_QUEUE_SIZE } from '../constants'; -import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; import NamedEventQueue from './named-event-queue'; /** A bounded event queue that drops events when it reaches its maximum size. */ From 5b33e17ab46d7f5f8c2db51c75d55f1c8b4271b3 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Mon, 25 Nov 2024 19:52:35 -0800 Subject: [PATCH 8/8] add unused imports lint --- .eslintrc.js | 3 ++- package.json | 1 + src/events/default-event-dispatcher.spec.ts | 2 -- yarn.lock | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index cd06049..97300d4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,7 @@ module.exports = { 'plugin:promise/recommended', 'plugin:import/recommended', ], - plugins: ['@typescript-eslint', 'prettier', 'import', 'promise'], + plugins: ['@typescript-eslint', 'prettier', 'import', 'promise', 'unused-imports'], rules: { 'prettier/prettier': [ 'warn', @@ -24,6 +24,7 @@ module.exports = { ], 'import/named': 'off', 'import/no-unresolved': 'off', + 'unused-imports/no-unused-imports': 'error', '@typescript-eslint/no-explicit-any': 'off', 'import/order': [ 'warn', diff --git a/package.json b/package.json index 87d3c96..f42a3f8 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "eslint-plugin-import": "^2.25.4", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-unused-imports": "^4.1.4", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lodash": "^4.17.21", diff --git a/src/events/default-event-dispatcher.spec.ts b/src/events/default-event-dispatcher.spec.ts index cec3ba6..015b5e7 100644 --- a/src/events/default-event-dispatcher.spec.ts +++ b/src/events/default-event-dispatcher.spec.ts @@ -1,5 +1,3 @@ -import { resolve } from 'eslint-import-resolver-typescript'; - import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; import BatchEventProcessor from './batch-event-processor'; import DefaultEventDispatcher, { diff --git a/yarn.lock b/yarn.lock index 1a3d002..6aae6b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1831,6 +1831,11 @@ eslint-plugin-promise@^6.0.0: resolved "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.0.tgz" integrity sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw== +eslint-plugin-unused-imports@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz#62ddc7446ccbf9aa7b6f1f0b00a980423cda2738" + integrity sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ== + eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"