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
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 9 additions & 17 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ 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 EventDispatcher from '../events/event-dispatcher';
import NoOpEventDispatcher from '../events/no-op-event-dispatcher';
Expand Down Expand Up @@ -79,21 +78,12 @@ export interface IContainerExperiment<T> {
treatmentVariationEntries: Array<T>;
}

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<IAssignmentEvent> =
newBoundedArrayEventQueue<IAssignmentEvent>('assignments');
new BoundedEventQueue<IAssignmentEvent>('assignments');
private readonly banditEventsQueue: BoundedEventQueue<IBanditEvent> =
newBoundedArrayEventQueue<IBanditEvent>('bandit');
new BoundedEventQueue<IBanditEvent>('bandit');
private readonly banditEvaluator = new BanditEvaluator();
private banditLogger?: IBanditLogger;
private banditAssignmentCache?: AssignmentCache;
Expand Down Expand Up @@ -152,6 +142,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;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i really don't love these mutable setter APIs we have in the EppoClient, as a fan of immutable data types, i'd much rather do it like that, but I'm following the existing pattern here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, anything that won't toggle after instantion should be constructor options and immutable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would be a bigger refactoring, so maybe we can consider for a future major release


// noinspection JSUnusedGlobalSymbols
setBanditModelConfigurationStore(
banditModelConfigurationStore: IConfigurationStore<BanditParameters>,
Expand Down Expand Up @@ -1145,7 +1141,3 @@ export function checkValueTypeMatch(
return false;
}
}

function newBoundedArrayEventQueue<T>(name: string): BoundedEventQueue<T> {
return new BoundedEventQueue<T>(new ArrayBackedNamedEventQueue<T>(name));
}
21 changes: 18 additions & 3 deletions src/events/bounded-event-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,32 @@ 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<T> {
export class BoundedEventQueue<T> implements NamedEventQueue<T> {
constructor(
private readonly queue: NamedEventQueue<T>,
readonly name: string,
private readonly queue = new Array<T>(),
private readonly maxSize = MAX_EVENT_QUEUE_SIZE,
) {}

length = this.queue.length;

splice(count: number): T[] {
return this.queue.splice(count);
}

isEmpty(): boolean {
return this.queue.length === 0;
}

[Symbol.iterator](): IterableIterator<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your ruby is bleeding into typescript 😛

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol this is actually part of the standard typescript API :) maybe they were inspired by ruby 🤣

return this.queue[Symbol.iterator]();
}

push(event: T) {
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`);
}
}

Expand Down
29 changes: 26 additions & 3 deletions src/events/default-event-dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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';

Expand All @@ -10,7 +11,7 @@

const mockNetworkStatusListener = {
isOffline: () => false,
onNetworkStatusChange: (_: (_: boolean) => void) => null as unknown as void,

Check warning on line 14 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

'_' is defined but never used

Check warning on line 14 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

'_' is defined but never used

Check warning on line 14 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

'_' is defined but never used

Check warning on line 14 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

'_' is defined but never used
};

const createDispatcher = (
Expand Down Expand Up @@ -140,7 +141,7 @@
describe('offline handling', () => {
it('skips delivery when offline', async () => {
let isOffline = false;
let cb = (_: boolean) => null as unknown as void;

Check warning on line 144 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

'_' is defined but never used

Check warning on line 144 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

'_' is defined but never used

Check warning on line 144 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

'_' is defined but never used

Check warning on line 144 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

'_' is defined but never used
const networkStatusListener = {
isOffline: () => isOffline,
onNetworkStatusChange: (callback: (isOffline: boolean) => void) => {
Expand All @@ -164,7 +165,7 @@

it('resumes delivery when back online', async () => {
let isOffline = true;
let cb = (_: boolean) => null as unknown as void;

Check warning on line 168 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

'_' is defined but never used

Check warning on line 168 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

'_' is defined but never used

Check warning on line 168 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

'_' is defined but never used

Check warning on line 168 in src/events/default-event-dispatcher.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

'_' is defined but never used
const networkStatusListener = {
isOffline: () => isOffline,
onNetworkStatusChange: (callback: (isOffline: boolean) => void) => {
Expand Down Expand Up @@ -198,4 +199,26 @@
expect(global.fetch).toHaveBeenCalled();
});
});

describe('newDefaultEventDispatcher', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌

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 eventQueue = new ArrayBackedNamedEventQueue('test-queue');
const dispatcher = newDefaultEventDispatcher(
eventQueue,
mockNetworkStatusListener,
'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk',
);
expect(dispatcher).toBeInstanceOf(DefaultEventDispatcher);
});
});
});
48 changes: 48 additions & 0 deletions src/events/default-event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<EventDispatcherConfig, 'ingestionUrl'> = {
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
Expand All @@ -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,
Expand Down Expand Up @@ -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');
}
}
Comment on lines +112 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but this could probably be made into a loop to simplify.

}

/** Creates a new {@link DefaultEventDispatcher} with the provided configuration. */
export function newDefaultEventDispatcher(
eventQueue: NamedEventQueue<unknown>,
networkStatusListener: NetworkStatusListener,
sdkKey: string,
batchSize: number = DEFAULT_EVENT_DISPATCHER_BATCH_SIZE,
config: Omit<EventDispatcherConfig, 'ingestionUrl'> = DEFAULT_EVENT_DISPATCHER_CONFIG,
): EventDispatcher {
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 },
);
}
2 changes: 1 addition & 1 deletion src/events/event-delivery.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
try {
Expand Down
14 changes: 11 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ 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 { BoundedEventQueue } from './events/bounded-event-queue';
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';
Expand Down Expand Up @@ -93,9 +97,13 @@ export {
BanditSubjectAttributes,
BanditActions,

// event queue types
// event dispatcher types
NamedEventQueue,
EventDispatcher,
BoundedEventQueue,
DEFAULT_EVENT_DISPATCHER_CONFIG,
DEFAULT_EVENT_DISPATCHER_BATCH_SIZE,
newDefaultEventDispatcher,
BatchEventProcessor,
NetworkStatusListener,
DefaultEventDispatcher,
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==

[email protected], eslint-scope@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
Expand Down
Loading