diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 277879c2..55ae4708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,8 @@ on: - '*' pull_request: branches: - - '*' + - main + - development jobs: build: diff --git a/CHANGES.txt b/CHANGES.txt index 35a4822c..00388146 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +1.4.0 (May 24, 2022) + - Added `scheduler.telemetryRefreshRate` property to SDK configuration, and deprecated `scheduler.metricsRefreshRate` property. + - Updated SDK telemetry storage, metrics and updater to be more effective and send less often. + - Bugfixing - Updated default values for `scheduler.impressionsRefreshRate` config parameter: 300s for OPTIMIZED impression mode and 60s for DEBUG impression mode. + 1.3.1 (April 19, 2022) - Bugfixing - Added peer dependencies to avoid issues when requiring some third-party dependencies used by modules of the package (Related to issue https://github.com/splitio/javascript-client/issues/662). - Bugfixing - Updated `ready` method to rejects the promise with an Error object instead of a string value (Related to issue https://github.com/splitio/javascript-client/issues/654). diff --git a/package-lock.json b/package-lock.json index 9459fc73..2be921ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index ba8520d5..671e18f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.3.1", + "version": "1.4.0", "description": "Split Javascript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/__tests__/testUtils/index.ts b/src/__tests__/testUtils/index.ts new file mode 100644 index 00000000..568f149f --- /dev/null +++ b/src/__tests__/testUtils/index.ts @@ -0,0 +1,14 @@ +const DEFAULT_ERROR_MARGIN = 50; // 0.05 secs if numbers are timestamps in milliseconds + +/** + * Assert if an `actual` and `expected` numeric values are nearly equal. + * + * @param {number} actual actual time lapse in millis + * @param {number} expected expected time lapse in millis + * @param {number} epsilon error margin in millis + * @returns {boolean} whether the absolute difference is minor to epsilon value or not + */ +export function nearlyEqual(actual: number, expected: number, epsilon = DEFAULT_ERROR_MARGIN) { + const diff = Math.abs(actual - expected); + return diff <= epsilon; +} diff --git a/src/consent/sdkUserConsent.ts b/src/consent/sdkUserConsent.ts index cd7d387f..ac8af3d8 100644 --- a/src/consent/sdkUserConsent.ts +++ b/src/consent/sdkUserConsent.ts @@ -23,7 +23,7 @@ export function createUserConsentAPI(params: ISdkFactoryContext) { setStatus(consent: unknown) { // validate input param if (!isBoolean(consent)) { - log.warn(ERROR_NOT_BOOLEAN, ['setUserConsent']); + log.warn(ERROR_NOT_BOOLEAN, ['UserConsent.setStatus']); return false; } diff --git a/src/listeners/__tests__/browser.spec.ts b/src/listeners/__tests__/browser.spec.ts index 5ec91eff..4657d24e 100644 --- a/src/listeners/__tests__/browser.spec.ts +++ b/src/listeners/__tests__/browser.spec.ts @@ -3,6 +3,18 @@ import { IEventsCacheSync, IImpressionCountsCacheSync, IImpressionsCacheSync, IS import { ISplitApi } from '../../services/types'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +jest.mock('../../sync/submitters/telemetrySubmitter', () => { + return { + telemetryCacheStatsAdapter: () => { + return { + isEmpty: () => false, + clear: () => { }, + state: () => ({}), + }; + } + }; +}); + /* Mocks start */ const fakeImpression = { @@ -26,6 +38,7 @@ const fakeImpressionCounts = { 'someFeature::0': 1 }; +// Storage with impressionsCount and telemetry cache const fakeStorageOptimized = { // @ts-expect-error impressions: { isEmpty: jest.fn(), @@ -48,6 +61,7 @@ const fakeStorageOptimized = { // @ts-expect-error return fakeImpressionCounts; } } as IImpressionCountsCacheSync, + telemetry: {} }; const fakeStorageDebug = { @@ -59,7 +73,8 @@ const fakeStorageDebug = { const fakeSplitApi = { postTestImpressionsBulk: jest.fn(() => Promise.resolve()), postEventsBulk: jest.fn(() => Promise.resolve()), - postTestImpressionsCount: jest.fn(() => Promise.resolve()) + postTestImpressionsCount: jest.fn(() => Promise.resolve()), + postMetricsUsage: jest.fn(() => Promise.resolve()), } as ISplitApi; const UNLOAD_DOM_EVENT = 'unload'; @@ -131,7 +146,7 @@ test('Browser JS listener / consumer mode', () => { expect((global.window.removeEventListener as jest.Mock).mock.calls).toEqual([[UNLOAD_DOM_EVENT, listener.flushData]]); }); -test('Browser JS listener / standalone mode / Impressions optimized mode', () => { +test('Browser JS listener / standalone mode / Impressions optimized mode with telemetry', () => { const syncManagerMock = {}; // @ts-expect-error @@ -144,8 +159,8 @@ test('Browser JS listener / standalone mode / Impressions optimized mode', () => triggerUnloadEvent(); - // Unload event was triggered. Thus sendBeacon method should have been called three times. - expect(global.window.navigator.sendBeacon).toBeCalledTimes(3); + // Unload event was triggered. Thus sendBeacon method should have been called four times. + expect(global.window.navigator.sendBeacon).toBeCalledTimes(4); // Http post services should have not been called expect(fakeSplitApi.postTestImpressionsBulk).not.toBeCalled(); @@ -195,7 +210,7 @@ test('Browser JS listener / standalone mode / Impressions debug mode', () => { }); test('Browser JS listener / standalone mode / Impressions debug mode without sendBeacon API', () => { - // remove sendBeacon API + // remove sendBeacon API temporally const sendBeacon = global.navigator.sendBeacon; // @ts-expect-error global.navigator.sendBeacon = undefined; const syncManagerMockWithoutPushManager = {}; @@ -254,8 +269,8 @@ test('Browser JS listener / standalone mode / user consent status', () => { settings.userConsent = undefined; triggerUnloadEvent(); - // Unload event was triggered when user consent was granted and undefined. Thus sendBeacon should be called 6 times (3 times per event in optimized mode). - expect(global.window.navigator.sendBeacon).toBeCalledTimes(6); + // Unload event was triggered when user consent was granted and undefined. Thus sendBeacon should be called 8 times (4 times per event in optimized mode with telemetry). + expect(global.window.navigator.sendBeacon).toBeCalledTimes(8); listener.stop(); }); diff --git a/src/listeners/browser.ts b/src/listeners/browser.ts index 5bbd3496..faf5c956 100644 --- a/src/listeners/browser.ts +++ b/src/listeners/browser.ts @@ -2,8 +2,8 @@ // @TODO eventually migrate to JS-Browser-SDK package. import { ISignalListener } from './types'; import { IRecorderCacheProducerSync, IStorageSync } from '../storages/types'; -import { fromImpressionsCollector } from '../sync/submitters/impressionsSyncTask'; -import { fromImpressionCountsCollector } from '../sync/submitters/impressionCountsSyncTask'; +import { fromImpressionsCollector } from '../sync/submitters/impressionsSubmitter'; +import { fromImpressionCountsCollector } from '../sync/submitters/impressionCountsSubmitter'; import { IResponse, ISplitApi } from '../services/types'; import { ImpressionDTO, ISettings } from '../types'; import { ImpressionsPayload } from '../sync/submitters/types'; @@ -12,6 +12,7 @@ import { objectAssign } from '../utils/lang/objectAssign'; import { CLEANUP_REGISTERING, CLEANUP_DEREGISTERING } from '../logger/constants'; import { ISyncManager } from '../sync/types'; import { isConsentGranted } from '../consent'; +import { telemetryCacheStatsAdapter } from '../sync/submitters/telemetrySubmitter'; // 'unload' event is used instead of 'beforeunload', since 'unload' is not a cancelable event, so no other listeners can stop the event from occurring. const UNLOAD_DOM_EVENT = 'unload'; @@ -77,6 +78,11 @@ export class BrowserSignalListener implements ISignalListener { this._flushData(eventsUrl + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata); this._flushData(eventsUrl + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk); if (this.storage.impressionCounts) this._flushData(eventsUrl + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector); + if (this.storage.telemetry) { + const telemetryUrl = this.settings.urls.telemetry; + const telemetryCacheAdapter = telemetryCacheStatsAdapter(this.storage.telemetry, this.storage.splits, this.storage.segments); + this._flushData(telemetryUrl + '/v1/metrics/usage/beacon', telemetryCacheAdapter, this.serviceApi.postMetricsUsage); + } } // Close streaming connection diff --git a/src/logger/constants.ts b/src/logger/constants.ts index 10b45254..698b68c3 100644 --- a/src/logger/constants.ts +++ b/src/logger/constants.ts @@ -123,6 +123,7 @@ export const ERROR_HTTP = 322; export const ERROR_LOCALHOST_MODULE_REQUIRED = 323; export const ERROR_STORAGE_INVALID = 324; export const ERROR_NOT_BOOLEAN = 325; +export const ERROR_MIN_CONFIG_PARAM = 326; // Log prefixes (a.k.a. tags or categories) export const LOG_PREFIX_SETTINGS = 'settings'; diff --git a/src/logger/messages/error.ts b/src/logger/messages/error.ts index 8ef9065b..1eebbbd7 100644 --- a/src/logger/messages/error.ts +++ b/src/logger/messages/error.ts @@ -32,5 +32,6 @@ export const codesError: [number, string][] = [ // initialization / settings validation [c.ERROR_INVALID_CONFIG_PARAM, c.LOG_PREFIX_SETTINGS + ': you passed an invalid "%s" config param. It should be one of the following values: %s. Defaulting to "%s".'], [c.ERROR_LOCALHOST_MODULE_REQUIRED, c.LOG_PREFIX_SETTINGS + ': an invalid value was received for "sync.localhostMode" config. A valid entity should be provided for localhost mode.'], - [c.ERROR_STORAGE_INVALID, c.LOG_PREFIX_SETTINGS+': The provided storage is invalid.%s Fallbacking into default MEMORY storage'], + [c.ERROR_STORAGE_INVALID, c.LOG_PREFIX_SETTINGS+': the provided storage is invalid.%s Falling back into default MEMORY storage'], + [c.ERROR_MIN_CONFIG_PARAM, c.LOG_PREFIX_SETTINGS + ': the provided "%s" config param is lower than allowed. Setting to the minimum value %s seconds'], ]; diff --git a/src/logger/messages/info.ts b/src/logger/messages/info.ts index 96703540..169e73ea 100644 --- a/src/logger/messages/info.ts +++ b/src/logger/messages/info.ts @@ -14,8 +14,8 @@ export const codesInfo: [number, string][] = codesWarn.concat([ [c.NEW_FACTORY, 'New Split SDK instance created.'], [c.EVENTS_TRACKER_SUCCESS, c.LOG_PREFIX_EVENTS_TRACKER + 'Successfully queued %s'], [c.IMPRESSIONS_TRACKER_SUCCESS, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Successfully stored %s impression(s).'], - [c.USER_CONSENT_UPDATED, 'setUserConsent: consent status changed from %s to %s.'], - [c.USER_CONSENT_NOT_UPDATED, 'setUserConsent: call had no effect because it was the current consent status (%s).'], + [c.USER_CONSENT_UPDATED, 'UserConsent: consent status changed from %s to %s.'], + [c.USER_CONSENT_NOT_UPDATED, 'UserConsent: call had no effect because it was the current consent status (%s).'], [c.USER_CONSENT_INITIAL, 'Starting the SDK with %s user consent. No data will be sent.'], // synchronizer @@ -24,7 +24,7 @@ export const codesInfo: [number, string][] = codesWarn.concat([ [c.POLLING_STOP, c.LOG_PREFIX_SYNC_POLLING + 'Stopping polling'], [c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying download of splits #%s. Reason: %s'], [c.SUBMITTERS_PUSH_FULL_QUEUE, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing full %s queue and reseting timer.'], - [c.SUBMITTERS_PUSH, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Pushing %s %s.'], + [c.SUBMITTERS_PUSH, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Pushing %s.'], [c.STREAMING_REFRESH_TOKEN, c.LOG_PREFIX_SYNC_STREAMING + 'Refreshing streaming token in %s seconds, and connecting streaming in %s seconds.'], [c.STREAMING_RECONNECT, c.LOG_PREFIX_SYNC_STREAMING + 'Attempting to reconnect streaming in %s seconds.'], [c.STREAMING_CONNECTING, c.LOG_PREFIX_SYNC_STREAMING + 'Connecting streaming.'], diff --git a/src/logger/messages/warn.ts b/src/logger/messages/warn.ts index f6a66eec..d820413d 100644 --- a/src/logger/messages/warn.ts +++ b/src/logger/messages/warn.ts @@ -11,8 +11,8 @@ export const codesWarn: [number, string][] = codesError.concat([ [c.STREAMING_PARSING_ERROR_FAILS, c.LOG_PREFIX_SYNC_STREAMING + 'Error parsing SSE error notification: %s'], [c.STREAMING_PARSING_MESSAGE_FAILS, c.LOG_PREFIX_SYNC_STREAMING + 'Error parsing SSE message notification: %s'], [c.STREAMING_FALLBACK, c.LOG_PREFIX_SYNC_STREAMING + 'Falling back to polling mode. Reason: %s'], - [c.SUBMITTERS_PUSH_FAILS, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Droping %s %s after retry. Reason: %s.'], - [c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s %s, keeping data to retry on next iteration. Reason: %s.'], + [c.SUBMITTERS_PUSH_FAILS, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Droping %s after retry. Reason: %s.'], + [c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s, keeping data to retry on next iteration. Reason: %s.'], // client status [c.CLIENT_NOT_READY, '%s: the SDK is not ready, results may be incorrect. Make sure to wait for SDK readiness before using this method.'], [c.CLIENT_NO_LISTENER, 'No listeners for SDK Readiness detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet ready.'], diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index b17ea51f..3c93b7a6 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -172,7 +172,8 @@ describe('SDK Readiness Manager - Event emitter', () => { test('The event callbacks should work as expected - SDK_READY emits with expected internal callbacks', () => { // the sdkReadinessManager expects more than one SDK_READY callback to not log the "No listeners" warning - const sdkReadinessManager = sdkReadinessManagerFactory(loggerMock, EventEmitterMock, undefined /* default readyTimeout */, 1 /* internalReadyCbCount */); + const sdkReadinessManager = sdkReadinessManagerFactory(loggerMock, EventEmitterMock); + sdkReadinessManager.incInternalReadyCbCount(); const gateMock = sdkReadinessManager.readinessManager.gate; // Get the callbacks diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index f50f78f9..c137e040 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -15,18 +15,16 @@ const REMOVE_LISTENER_EVENT = 'removeListener'; * It also updates logs related warnings and errors. * * @param readyTimeout time in millis to emit SDK_READY_TIME_OUT event - * @param internalReadyCbCount offset value of SDK_READY listeners that are added/removed internally - * by the SDK. It is required to properly log the warning 'No listeners for SDK Readiness detected' * @param readinessManager optional readinessManager to use. only used internally for `shared` method */ export function sdkReadinessManagerFactory( log: ILogger, EventEmitter: new () => IEventEmitter, readyTimeout = 0, - internalReadyCbCount = 0, readinessManager = readinessManagerFactory(EventEmitter, readyTimeout)): ISdkReadinessManager { /** Ready callback warning */ + let internalReadyCbCount = 0; let readyCbCount = 0; readinessManager.gate.on(REMOVE_LISTENER_EVENT, (event: any) => { if (event === SDK_READY) readyCbCount--; @@ -74,8 +72,12 @@ export function sdkReadinessManagerFactory( return { readinessManager, - shared(readyTimeout = 0, internalReadyCbCount = 0) { - return sdkReadinessManagerFactory(log, EventEmitter, readyTimeout, internalReadyCbCount, readinessManager.shared(readyTimeout)); + shared(readyTimeout = 0) { + return sdkReadinessManagerFactory(log, EventEmitter, readyTimeout, readinessManager.shared(readyTimeout)); + }, + + incInternalReadyCbCount() { + internalReadyCbCount++; }, sdkStatus: objectAssign( diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 288a2717..93986f60 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -66,6 +66,12 @@ export interface ISdkReadinessManager { readinessManager: IReadinessManager sdkStatus: IStatusInterface + /** + * Increment internalReadyCbCount, an offset value of SDK_READY listeners that are added/removed internally + * by the SDK. It is required to properly log the warning 'No listeners for SDK Readiness detected' + */ + incInternalReadyCbCount(): void + /** for client-side */ - shared(readyTimeout?: number, internalReadyCbCount?: number): ISdkReadinessManager + shared(readyTimeout?: number): ISdkReadinessManager } diff --git a/src/sdkClient/__tests__/sdkClientMethod.spec.ts b/src/sdkClient/__tests__/sdkClientMethod.spec.ts index 604f20f6..e35ccab1 100644 --- a/src/sdkClient/__tests__/sdkClientMethod.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethod.spec.ts @@ -2,6 +2,7 @@ import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; import { CONSUMER_MODE, STANDALONE_MODE } from '../../utils/constants'; import { sdkClientMethodFactory } from '../sdkClientMethod'; import { assertClientApi } from './testUtils'; +import { telemetryTrackerFactory } from '../../trackers/telemetryTracker'; const errorMessage = 'Shared Client not supported by the storage mechanism. Create isolated instances instead.'; @@ -12,7 +13,8 @@ const paramMocks = [ syncManager: undefined, sdkReadinessManager: { sdkStatus: jest.fn(), readinessManager: { destroy: jest.fn() } }, signalListener: undefined, - settings: { mode: CONSUMER_MODE, log: loggerMock, core: { authorizationKey: 'api key '} } + settings: { mode: CONSUMER_MODE, log: loggerMock, core: { authorizationKey: 'api key '} }, + telemetryTracker: telemetryTrackerFactory() }, // SyncManager (i.e., Sync SDK) and Signal listener { @@ -20,7 +22,8 @@ const paramMocks = [ syncManager: { stop: jest.fn(), flush: jest.fn(() => Promise.resolve()) }, sdkReadinessManager: { sdkStatus: jest.fn(), readinessManager: { destroy: jest.fn() } }, signalListener: { stop: jest.fn() }, - settings: { mode: STANDALONE_MODE, log: loggerMock, core: { authorizationKey: 'api key '} } + settings: { mode: STANDALONE_MODE, log: loggerMock, core: { authorizationKey: 'api key '} }, + telemetryTracker: telemetryTrackerFactory() } ]; diff --git a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts index 7eabeb7c..dbe57ec9 100644 --- a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts @@ -1,7 +1,7 @@ import { sdkClientMethodCSFactory as sdkClientMethodCSWithTTFactory } from '../sdkClientMethodCSWithTT'; import { sdkClientMethodCSFactory } from '../sdkClientMethodCS'; import { assertClientApi } from './testUtils'; - +import { telemetryTrackerFactory } from '../../trackers/telemetryTracker'; import { settingsWithKey, settingsWithKeyAndTT, settingsWithKeyObject } from '../../utils/settingsValidation/__tests__/settings.mocks'; const partialStorages: { destroy: jest.Mock }[] = []; @@ -44,7 +44,8 @@ const params = { sdkReadinessManager: sdkReadinessManagerMock, syncManager: syncManagerMock, signalListener: { stop: jest.fn() }, - settings: settingsWithKey + settings: settingsWithKey, + telemetryTracker: telemetryTrackerFactory() }; const invalidAttributes = [ diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 44501624..3d3f0fd6 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -4,7 +4,7 @@ import { getMatching, getBucketing } from '../utils/key'; import { validateSplitExistance } from '../utils/inputValidation/splitExistance'; import { validateTrafficTypeExistance } from '../utils/inputValidation/trafficTypeExistance'; import { SDK_NOT_READY } from '../utils/labels'; -import { CONTROL } from '../utils/constants'; +import { CONTROL, TREATMENT, TREATMENTS, TREATMENT_WITH_CONFIG, TREATMENTS_WITH_CONFIG, TRACK } from '../utils/constants'; import { IEvaluationResult } from '../evaluator/types'; import { SplitIO, ImpressionDTO } from '../types'; import { IMPRESSION, IMPRESSION_QUEUEING } from '../logger/constants'; @@ -13,16 +13,19 @@ import { ISdkFactoryContext } from '../sdkFactory/types'; /** * Creator of base client with getTreatments and track methods. */ -// @TODO missing time tracking to collect telemetry export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker } = params; const { log, mode } = settings; function getTreatment(key: SplitIO.SplitKey, splitName: string, attributes: SplitIO.Attributes | undefined, withConfig = false) { + const stopTelemetryTracker = telemetryTracker.trackEval(withConfig ? TREATMENT_WITH_CONFIG : TREATMENT); + const wrapUp = (evaluationResult: IEvaluationResult) => { const queue: ImpressionDTO[] = []; const treatment = processEvaluation(evaluationResult, splitName, key, attributes, withConfig, `getTreatment${withConfig ? 'withConfig' : ''}`, queue); impressionsTracker.track(queue, attributes); + + stopTelemetryTracker(queue[0] && queue[0].label); return treatment; }; @@ -36,6 +39,8 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl } function getTreatments(key: SplitIO.SplitKey, splitNames: string[], attributes: SplitIO.Attributes | undefined, withConfig = false) { + const stopTelemetryTracker = telemetryTracker.trackEval(withConfig ? TREATMENTS_WITH_CONFIG : TREATMENTS); + const wrapUp = (evaluationResults: Record) => { const queue: ImpressionDTO[] = []; const treatments: Record = {}; @@ -43,6 +48,8 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl treatments[splitName] = processEvaluation(evaluationResults[splitName], splitName, key, attributes, withConfig, `getTreatments${withConfig ? 'withConfig' : ''}`, queue); }); impressionsTracker.track(queue, attributes); + + stopTelemetryTracker(queue[0] && queue[0].label); return treatments; }; @@ -101,6 +108,8 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl } function track(key: SplitIO.SplitKey, trafficTypeName: string, eventTypeId: string, value?: number, properties?: SplitIO.Properties, size = 1024) { + const stopTelemetryTracker = telemetryTracker.trackEval(TRACK); + const matchingKey = getMatching(key); const timestamp = Date.now(); const eventData: SplitIO.EventData = { @@ -115,7 +124,17 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl // This may be async but we only warn, we don't actually care if it is valid or not in terms of queueing the event. validateTrafficTypeExistance(log, readinessManager, storage.splits, mode, trafficTypeName, 'track'); - return eventTracker.track(eventData, size); + const result = eventTracker.track(eventData, size); + + if (thenable(result)) { + return result.then((result) => { + stopTelemetryTracker(); + return result; + }); + } else { + stopTelemetryTracker(); + return result; + } } return { diff --git a/src/sdkClient/sdkClient.ts b/src/sdkClient/sdkClient.ts index c8ad8dc2..673825cf 100644 --- a/src/sdkClient/sdkClient.ts +++ b/src/sdkClient/sdkClient.ts @@ -9,7 +9,7 @@ import { ISdkFactoryContext } from '../sdkFactory/types'; * Creates an Sdk client, i.e., a base client with status and destroy interface */ export function sdkClientFactory(params: ISdkFactoryContext, isSharedClient?: boolean): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager, syncManager, storage, signalListener, settings } = params; + const { sdkReadinessManager, syncManager, storage, signalListener, settings, telemetryTracker } = params; return objectAssign( // Proto-linkage of the readiness Event Emitter @@ -25,6 +25,9 @@ export function sdkClientFactory(params: ISdkFactoryContext, isSharedClient?: bo // Sdk destroy { destroy() { + // record stat before flushing data + if (!isSharedClient) telemetryTracker.sessionLength(); + // Stop background jobs syncManager && syncManager.stop(); const flush = syncManager ? syncManager.flush() : Promise.resolve(); diff --git a/src/sdkFactory/__tests__/index.spec.ts b/src/sdkFactory/__tests__/index.spec.ts index 58a1144f..185fccb4 100644 --- a/src/sdkFactory/__tests__/index.spec.ts +++ b/src/sdkFactory/__tests__/index.spec.ts @@ -19,6 +19,12 @@ jest.mock('../../logger/sdkLogger', () => { createLoggerAPI: () => loggerApiMock }; }); +const telemetryTrackerMock = 'telemetryTracker'; +jest.mock('../../trackers/telemetryTracker', () => { + return { + telemetryTrackerFactory: () => telemetryTrackerMock + }; +}); // IAsyncSDK, minimal params const paramsForAsyncSDK = { @@ -74,7 +80,7 @@ function assertModulesCalled(params: any) { expect(SignalListenerInstanceMock.start).toBeCalledTimes(1); } if (params.splitApiFactory) { - expect(params.splitApiFactory.mock.calls).toEqual([[params.settings, params.platform]]); + expect(params.splitApiFactory.mock.calls).toEqual([[params.settings, params.platform, telemetryTrackerMock]]); } if (params.integrationsManagerFactory) { expect(params.integrationsManagerFactory.mock.calls).toEqual([[{ settings: params.settings, storage: mockStorage }]]); diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index e28bd1e2..9bba6f95 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -1,10 +1,10 @@ -import { ISdkFactoryParams } from './types'; +import { ISdkFactoryContext, ISdkFactoryContextSync, ISdkFactoryParams } from './types'; import { sdkReadinessManagerFactory } from '../readiness/sdkReadinessManager'; import { impressionsTrackerFactory } from '../trackers/impressionsTracker'; import { eventTrackerFactory } from '../trackers/eventTracker'; -import { IStorageFactoryParams, IStorageSync } from '../storages/types'; +import { telemetryTrackerFactory } from '../trackers/telemetryTracker'; +import { IStorageFactoryParams } from '../storages/types'; import { SplitIO } from '../types'; -import { ISplitApi } from '../services/types'; import { getMatching } from '../utils/key'; import { shouldBeOptimized } from '../trackers/impressionObserver/utils'; import { validateAndTrackApiKey } from '../utils/inputValidation/apiKey'; @@ -24,13 +24,14 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory } = params; const log = settings.log; - // @TODO handle non-recoverable errors: not start sync, mark the SDK as destroyed, etc. + // @TODO handle non-recoverable errors, such as, global `fetch` not available, invalid API Key, etc. + // On non-recoverable errors, we should mark the SDK as destroyed and not start synchronization. + // We will just log and allow for the SDK to end up throwing an SDK_TIMEOUT event for devs to handle. validateAndTrackApiKey(log, settings.core.authorizationKey); - // @TODO handle non-recoverable error, such as, `fetch` api not available, invalid API Key, etc. const sdkReadinessManager = sdkReadinessManagerFactory(log, platform.EventEmitter, settings.startup.readyTimeout); - const readinessManager = sdkReadinessManager.readinessManager; + const readiness = sdkReadinessManager.readinessManager; // @TODO consider passing the settings object, so that each storage access only what it needs const storageFactoryParams: IStorageFactoryParams = { @@ -49,8 +50,8 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. // or partial consumer mode, where it only has submitters, and therefore it doesn't emit readiness events. onReadyCb: (error) => { if (error) return; // Don't emit SDK_READY if storage failed to connect. Error message is logged by wrapperAdapter - readinessManager.splits.emit(SDK_SPLITS_ARRIVED); - readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + readiness.splits.emit(SDK_SPLITS_ARRIVED); + readiness.segments.emit(SDK_SEGMENTS_ARRIVED); }, metadata: metadataBuilder(settings), log @@ -59,29 +60,26 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. const storage = storageFactory(storageFactoryParams); // @TODO add support for dataloader: `if (params.dataLoader) params.dataLoader(storage);` - // splitApi is used by SyncManager and Browser signal listener - const splitApi = splitApiFactory && splitApiFactory(settings, platform); - - const syncManager = syncManagerFactory && syncManagerFactory({ - settings, - splitApi: splitApi as ISplitApi, - storage: storage as IStorageSync, - readiness: sdkReadinessManager.readinessManager, - platform - }); - const integrationsManager = integrationsManagerFactory && integrationsManagerFactory({ settings, storage }); // trackers const observer = impressionsObserverFactory && impressionsObserverFactory(); - const impressionsTracker = impressionsTrackerFactory(settings, storage.impressions, integrationsManager, observer, storage.impressionCounts); - const eventTracker = eventTrackerFactory(settings, storage.events, integrationsManager); + const impressionsTracker = impressionsTrackerFactory(settings, storage.impressions, integrationsManager, observer, storage.impressionCounts, storage.telemetry); + const eventTracker = eventTrackerFactory(settings, storage.events, integrationsManager, storage.telemetry); + const telemetryTracker = telemetryTrackerFactory(storage.telemetry, platform.now); + + // splitApi is used by SyncManager and Browser signal listener + const splitApi = splitApiFactory && splitApiFactory(settings, platform, telemetryTracker); + + const ctx: ISdkFactoryContext = { splitApi, eventTracker, impressionsTracker, telemetryTracker, sdkReadinessManager, readiness, settings, storage, platform }; + + const syncManager = syncManagerFactory && syncManagerFactory(ctx as ISdkFactoryContextSync); + ctx.syncManager = syncManager; - // signal listener const signalListener = SignalListener && new SignalListener(syncManager, settings, storage, splitApi); + ctx.signalListener = signalListener; - // Sdk client and manager - const ctx = { eventTracker, impressionsTracker, sdkReadinessManager, settings, storage, syncManager, signalListener }; + // SDK client and manager const clientMethod = sdkClientMethodFactory(ctx); const managerInstance = sdkManagerFactory(log, storage.splits, sdkReadinessManager); diff --git a/src/sdkFactory/types.ts b/src/sdkFactory/types.ts index 21968c46..de8abe3f 100644 --- a/src/sdkFactory/types.ts +++ b/src/sdkFactory/types.ts @@ -1,33 +1,64 @@ import { IIntegrationManager, IIntegrationFactoryParams } from '../integrations/types'; import { ISignalListener } from '../listeners/types'; import { ILogger } from '../logger/types'; -import { ISdkReadinessManager } from '../readiness/types'; +import { IReadinessManager, ISdkReadinessManager } from '../readiness/types'; import { IFetch, ISplitApi, IEventSourceConstructor } from '../services/types'; import { IStorageAsync, IStorageSync, ISplitsCacheSync, ISplitsCacheAsync, IStorageFactoryParams } from '../storages/types'; -import { ISyncManager, ISyncManagerFactoryParams } from '../sync/types'; +import { ISyncManager } from '../sync/types'; import { IImpressionObserver } from '../trackers/impressionObserver/types'; -import { IImpressionsTracker, IEventTracker } from '../trackers/types'; +import { IImpressionsTracker, IEventTracker, ITelemetryTracker } from '../trackers/types'; import { SplitIO, ISettings, IEventEmitter } from '../types'; +/** + * Environment related dependencies. + */ +export interface IPlatform { + /** + * If provided, it is used to retrieve the Fetch API for HTTP requests. Otherwise, the global fetch is used. + */ + getFetch?: () => (IFetch | undefined) + /** + * If provided, it is used to pass additional options to fetch calls. + */ + getOptions?: () => object + /** + * If provided, it is used to retrieve the EventSource constructor for streaming support. + */ + getEventSource?: () => (IEventSourceConstructor | undefined) + /** + * EventEmitter constructor, like NodeJS.EventEmitter or a polyfill. + */ + EventEmitter: new () => IEventEmitter, + /** + * Function used to track latencies for telemetry. + */ + now?: () => number +} + export interface ISdkFactoryContext { - storage: IStorageSync | IStorageAsync, + platform: IPlatform, sdkReadinessManager: ISdkReadinessManager, + readiness: IReadinessManager, settings: ISettings impressionsTracker: IImpressionsTracker, eventTracker: IEventTracker, + telemetryTracker: ITelemetryTracker, + storage: IStorageSync | IStorageAsync, signalListener?: ISignalListener + splitApi?: ISplitApi syncManager?: ISyncManager, } -/** - * Environment related dependencies. - * These getters are called a fixed number of times per factory instantiation. - */ -export interface IPlatform { - getOptions?: () => object - getFetch?: () => (IFetch | undefined) - getEventSource?: () => (IEventSourceConstructor | undefined) - EventEmitter: new () => IEventEmitter +export interface ISdkFactoryContextSync extends ISdkFactoryContext { + storage: IStorageSync, + splitApi: ISplitApi + syncManager: ISyncManager, +} + +export interface ISdkFactoryContextAsync extends ISdkFactoryContext { + storage: IStorageAsync, + splitApi: undefined, + syncManager: undefined } /** @@ -47,12 +78,12 @@ export interface ISdkFactoryParams { // Factory of Split Api (HTTP Client Service). // It is not required when providing an asynchronous storage or offline SyncManager - splitApiFactory?: (settings: ISettings, platform: IPlatform) => ISplitApi, + splitApiFactory?: (settings: ISettings, platform: IPlatform, telemetryTracker: ITelemetryTracker) => ISplitApi, // SyncManager factory. // Not required when providing an asynchronous storage (consumer mode), but required in standalone mode to avoid SDK timeout. // It can create an offline or online sync manager, with or without streaming support. - syncManagerFactory?: (params: ISyncManagerFactoryParams) => ISyncManager, + syncManagerFactory?: (params: ISdkFactoryContextSync) => ISyncManager, // Sdk manager factory sdkManagerFactory: ( diff --git a/src/services/__tests__/splitApi.spec.ts b/src/services/__tests__/splitApi.spec.ts index c014e678..ca4c172f 100644 --- a/src/services/__tests__/splitApi.spec.ts +++ b/src/services/__tests__/splitApi.spec.ts @@ -5,6 +5,8 @@ import { settingsSplitApi } from '../../utils/settingsValidation/__tests__/setti const settingsWithRuntime = { ...settingsSplitApi, runtime: { ip: 'ip', hostname: 'hostname' } } as ISettings; +const telemetryTrackerMock = { trackHttp: jest.fn(() => () => { }) }; + function assertHeaders(settings: ISettings, headers: Record) { expect(headers['Accept']).toBe('application/json'); expect(headers['Content-Type']).toBe('application/json'); @@ -20,7 +22,7 @@ describe('splitApi', () => { test.each([settingsSplitApi, settingsWithRuntime])('performs requests with expected headers', (settings) => { const fetchMock = jest.fn(() => Promise.resolve({ ok: true })); - const splitApi = splitApiFactory(settings, { getFetch: () => fetchMock }); + const splitApi = splitApiFactory(settings, { getFetch: () => fetchMock }, telemetryTrackerMock); splitApi.fetchAuth(); assertHeaders(settings, fetchMock.mock.calls[0][1].headers); @@ -44,18 +46,20 @@ describe('splitApi', () => { splitApi.postTestImpressionsCount('fake-body'); assertHeaders(settings, fetchMock.mock.calls[6][1].headers); - // Deprecated - splitApi.postMetricsCounters('fake-body'); + splitApi.postMetricsConfig('fake-body'); assertHeaders(settings, fetchMock.mock.calls[7][1].headers); - splitApi.postMetricsTimes('fake-body'); + splitApi.postMetricsUsage('fake-body'); assertHeaders(settings, fetchMock.mock.calls[8][1].headers); + expect(telemetryTrackerMock.trackHttp).toBeCalledTimes(9); + + telemetryTrackerMock.trackHttp.mockClear(); fetchMock.mockClear(); }); test('rejects requests if fetch Api is not provided', (done) => { - const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => undefined }); + const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => undefined }, telemetryTrackerMock); // Invoking any Service method, returns a rejected promise with Split error splitApi.fetchAuth().catch(error => { @@ -68,7 +72,7 @@ describe('splitApi', () => { test('performs requests with overwritten headers', () => { const fetchMock = jest.fn(() => Promise.resolve({ ok: true })); - const splitApi = splitApiFactory(settingsWithRuntime, { getFetch: () => fetchMock }); + const splitApi = splitApiFactory(settingsWithRuntime, { getFetch: () => fetchMock }, telemetryTrackerMock); const newHeaders = { SplitSDKVersion: 'newVersion', SplitSDKMachineIP: 'newIp', SplitSDKMachineName: 'newHostname' }; const expectedHeaders = { ...settingsWithRuntime, version: newHeaders.SplitSDKVersion, runtime: { ip: newHeaders.SplitSDKMachineIP, hostname: newHeaders.SplitSDKMachineName } }; @@ -83,7 +87,7 @@ describe('splitApi', () => { test('performs APIs health service check', (done) => { const fetchMock = jest.fn(() => Promise.resolve({ ok: true })); - const splitApi = splitApiFactory(settingsWithRuntime, { getFetch: () => fetchMock }); + const splitApi = splitApiFactory(settingsWithRuntime, { getFetch: () => fetchMock }, telemetryTrackerMock); splitApi.getSdkAPIHealthCheck().then((res) => { expect(res).toEqual(true); diff --git a/src/services/splitApi.ts b/src/services/splitApi.ts index dbbcb953..b1b01bfb 100644 --- a/src/services/splitApi.ts +++ b/src/services/splitApi.ts @@ -3,6 +3,8 @@ import { ISettings } from '../types'; import { splitHttpClientFactory } from './splitHttpClient'; import { ISplitApi } from './types'; import { objectAssign } from '../utils/lang/objectAssign'; +import { ITelemetryTracker } from '../trackers/types'; +import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MY_SEGMENT } from '../utils/constants'; const noCacheHeaderOptions = { headers: { 'Cache-Control': 'no-cache' } }; @@ -18,7 +20,8 @@ function userKeyToQueryParam(userKey: string) { */ export function splitApiFactory( settings: Pick, - platform: Pick + platform: Pick, + telemetryTracker: ITelemetryTracker ): ISplitApi { const urls = settings.urls; @@ -44,17 +47,17 @@ export function splitApiFactory( if (queryParams) // accounting the possibility that `userKeys` and thus `queryParams` are empty url += '?' + queryParams; } - return splitHttpClient(url); + return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); }, fetchSplitChanges(since: number, noCache?: boolean) { const url = `${urls.sdk}/splitChanges?since=${since}${filterQueryString || ''}`; - return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined); + return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(SPLITS)); }, fetchSegmentChanges(since: number, segmentName: string, noCache?: boolean) { const url = `${urls.sdk}/segmentChanges/${segmentName}?since=${since}`; - return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined); + return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(SEGMENT)); }, fetchMySegments(userMatchingKey: string, noCache?: boolean) { @@ -65,7 +68,7 @@ export function splitApiFactory( * - match user keys with special characters. E.g.: 'foo%bar', 'foo/bar' */ const url = `${urls.sdk}/mySegments/${encodeURIComponent(userMatchingKey)}`; - return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined); + return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(MY_SEGMENT)); }, /** @@ -76,7 +79,7 @@ export function splitApiFactory( */ postEventsBulk(body: string, headers?: Record) { const url = `${urls.events}/events/bulk`; - return splitHttpClient(url, { method: 'POST', body, headers }); + return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(EVENTS)); }, /** @@ -90,7 +93,7 @@ export function splitApiFactory( return splitHttpClient(url, { // Adding extra headers to send impressions in OPTIMIZED or DEBUG modes. method: 'POST', body, headers: objectAssign({ SplitSDKImpressionsMode }, headers) - }); + }, telemetryTracker.trackHttp(IMPRESSIONS)); }, /** @@ -101,17 +104,17 @@ export function splitApiFactory( */ postTestImpressionsCount(body: string, headers?: Record) { const url = `${urls.events}/testImpressions/count`; - return splitHttpClient(url, { method: 'POST', body, headers }); + return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(IMPRESSIONS_COUNT)); }, - postMetricsCounters(body: string) { - const url = `${urls.events}/metrics/counters`; - return splitHttpClient(url, { method: 'POST', body }, true); + postMetricsConfig(body: string) { + const url = `${urls.telemetry}/v1/metrics/config`; + return splitHttpClient(url, { method: 'POST', body }, telemetryTracker.trackHttp(TELEMETRY), true); }, - postMetricsTimes(body: string) { - const url = `${urls.events}/metrics/times`; - return splitHttpClient(url, { method: 'POST', body }, true); + postMetricsUsage(body: string) { + const url = `${urls.telemetry}/v1/metrics/usage`; + return splitHttpClient(url, { method: 'POST', body }, telemetryTracker.trackHttp(TELEMETRY), true); } }; } diff --git a/src/services/splitHttpClient.ts b/src/services/splitHttpClient.ts index d9ec90f7..25d70f0b 100644 --- a/src/services/splitHttpClient.ts +++ b/src/services/splitHttpClient.ts @@ -1,4 +1,4 @@ -import { IFetch, IRequestOptions, IResponse, ISplitHttpClient } from './types'; +import { IFetch, IRequestOptions, IResponse, ISplitHttpClient, NetworkError } from './types'; import { objectAssign } from '../utils/lang/objectAssign'; import { ERROR_HTTP, ERROR_CLIENT_CANNOT_GET_READY } from '../logger/constants'; import { ISettings } from '../types'; @@ -31,7 +31,7 @@ export function splitHttpClientFactory(settings: Pick { + return function httpClient(url: string, reqOpts: IRequestOptions = {}, latencyTracker: (error?: NetworkError) => void = () => { }, logErrorsAsInfo: boolean = false): Promise { const request = objectAssign({ headers: reqOpts.headers ? objectAssign({}, headers, reqOpts.headers) : headers, @@ -46,6 +46,7 @@ export function splitHttpClientFactory(settings: Pick Promise.reject({ response, message })); } + latencyTracker(); return response; }) .catch(error => { @@ -68,9 +69,11 @@ export function splitHttpClientFactory(settings: Pick Promise // IFetch specialization export type IHealthCheckAPI = () => Promise -export type ISplitHttpClient = (url: string, options?: IRequestOptions, logErrorsAsInfo?: boolean) => Promise +export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean) => Promise export type IFetchAuth = (userKeys?: string[]) => Promise @@ -45,9 +47,9 @@ export type IPostTestImpressionsBulk = (body: string, headers?: Record) => Promise -export type IPostMetricsCounters = (body: string) => Promise +export type IPostMetricsConfig = (body: string) => Promise -export type IPostMetricsTimes = (body: string) => Promise +export type IPostMetricsUsage = (body: string) => Promise export interface ISplitApi { getSdkAPIHealthCheck: IHealthCheckAPI @@ -59,8 +61,8 @@ export interface ISplitApi { postEventsBulk: IPostEventsBulk postTestImpressionsBulk: IPostTestImpressionsBulk postTestImpressionsCount: IPostTestImpressionsCount - postMetricsCounters: IPostMetricsCounters - postMetricsTimes: IPostMetricsTimes + postMetricsConfig: IPostMetricsConfig + postMetricsUsage: IPostMetricsUsage } // Minimal version of EventSource API used by the SDK diff --git a/src/storages/AbstractSegmentsCacheSync.ts b/src/storages/AbstractSegmentsCacheSync.ts index ad371912..23b3a491 100644 --- a/src/storages/AbstractSegmentsCacheSync.ts +++ b/src/storages/AbstractSegmentsCacheSync.ts @@ -37,10 +37,15 @@ export abstract class AbstractSegmentsCacheSync implements ISegmentsCacheSync { registerSegments(names: string[]): boolean { return false; } /** - * For server-side synchronizer: get the list of segments in the cache. - * For client-side synchronizer: the method is not used. + * For server-side synchronizer: get the list of segments to fetch changes. + * Also used for the `seC` (segment count) telemetry stat. + */ + abstract getRegisteredSegments(): string[] + + /** + * Only used for the `skC`(segment keys count) telemetry stat: 1 for client-side, and total count of keys in server-side. */ - getRegisteredSegments(): string[] { return []; } + abstract getKeysCount(): number /** * For server-side synchronizer: set the change number of `name` segment. diff --git a/src/storages/KeyBuilderSS.ts b/src/storages/KeyBuilderSS.ts index 2f8898c1..025f8608 100644 --- a/src/storages/KeyBuilderSS.ts +++ b/src/storages/KeyBuilderSS.ts @@ -1,9 +1,14 @@ import { KeyBuilder } from './KeyBuilder'; import { IMetadata } from '../dtos/types'; +import { Method } from '../sync/submitters/types'; -// NOT USED -// const everythingAfterCount = /count\.([^/]+)$/; -// const latencyMetricNameAndBucket = /latency\.([^/]+)\.bucket\.([0-9]+)$/; +const methodNames: Record = { + t: 'treatment', + ts: 'treatments', + tc: 'treatmentWithConfig', + tcs: 'treatmentsWithConfig', + tr: 'track' +}; export class KeyBuilderSS extends KeyBuilder { @@ -18,10 +23,6 @@ export class KeyBuilderSS extends KeyBuilder { return `${this.prefix}.segments.registered`; } - private buildVersionablePrefix() { - return `${this.prefix}/${this.metadata.s}/${this.metadata.i}`; - } - buildImpressionsKey() { return `${this.prefix}.impressions`; } @@ -30,58 +31,26 @@ export class KeyBuilderSS extends KeyBuilder { return `${this.prefix}.events`; } - private buildLatencyKeyPrefix() { - return `${this.buildVersionablePrefix()}/latency`; + searchPatternForSplitKeys() { + return `${this.buildSplitKeyPrefix()}*`; } - buildLatencyKey(metricName: string, bucketNumber: number | string) { - return `${this.buildLatencyKeyPrefix()}.${metricName}.bucket.${bucketNumber}`; - } + /* Telemetry keys */ - buildCountKey(metricName: string) { - return `${this.buildVersionablePrefix()}/count.${metricName}`; + buildLatencyKey(method: Method, bucket: number) { + return `${this.prefix}.telemetry.latencies::${this.buildVersionablePrefix()}/${methodNames[method]}/${bucket}`; } - // NOT USED - // buildGaugeKey(metricName: string) { - // return `${this.buildVersionablePrefix()}/gauge.${metricName}`; - // } - - // NOT USED - // searchPatternForCountKeys() { - // return `${this.buildVersionablePrefix()}/count.*`; - // } - - searchPatternForSplitKeys() { - return `${this.buildSplitKeyPrefix()}*`; + buildExceptionKey(method: Method) { + return `${this.prefix}.telemetry.exceptions::${this.buildVersionablePrefix()}/${methodNames[method]}`; } - // NOT USED - // searchPatternForLatency() { - // return `${this.buildLatencyKeyPrefix()}.*`; - // } - - // NOT USED - // extractCounterName(counterKey: string) { - // const m = counterKey.match(everythingAfterCount); - // if (m && m.length) { - // return m[1]; // everything after count - // } else { - // throw new Error('Invalid counter key provided'); - // } - // } + buildInitKey() { + return `${this.prefix}.telemetry.init::${this.buildVersionablePrefix()}`; + } - // NOT USED - // extractLatencyMetricNameAndBucket(latencyKey: string) { - // const parts = latencyKey.match(latencyMetricNameAndBucket); + private buildVersionablePrefix() { + return `${this.metadata.s}/${this.metadata.n}/${this.metadata.i}`; + } - // if (parts && parts.length > 2) { - // return { - // metricName: parts[1], - // bucketNumber: parts[2] - // }; - // } else { - // throw new Error('Invalid counter key provided'); - // } - // } } diff --git a/src/storages/__tests__/KeyBuilder.spec.ts b/src/storages/__tests__/KeyBuilder.spec.ts index e0b82ebe..196865d8 100644 --- a/src/storages/__tests__/KeyBuilder.spec.ts +++ b/src/storages/__tests__/KeyBuilder.spec.ts @@ -90,20 +90,17 @@ test('KEYS / events', () => { }); -test('KEYS / latency keys', () => { +test('KEYS / latency and exception keys (telemetry)', () => { const prefix = 'SPLITIO'; const metadata = { s: 'js-1234', i: '10-10-10-10', n: 'UNKNOWN' }; const builder = new KeyBuilderSS(prefix, metadata); - const metricName = 'unit testing metric name'; - const bucketNumber = '10'; + const methodName = 't'; // treatment + const bucketNumber = 10; - const expectedLatencyKey = `${prefix}/${metadata.s}/${metadata.i}/latency.${metricName}.bucket.${bucketNumber}`; + const expectedLatencyKey = `${prefix}.telemetry.latencies::${metadata.s}/${metadata.n}/${metadata.i}/treatment/${bucketNumber}`; + expect(builder.buildLatencyKey(methodName, bucketNumber)).toBe(expectedLatencyKey); - expect(builder.buildLatencyKey(metricName, bucketNumber) === expectedLatencyKey).toBe(true); - - // NOT USED - // const metricNameAndBucket = builder.extractLatencyMetricNameAndBucket(expectedLatencyKey); - // expect(metricName === metricNameAndBucket.metricName).toBe(true); - // expect(bucketNumber === metricNameAndBucket.bucketNumber).toBe(true); + const expectedExceptionKey = `${prefix}.telemetry.exceptions::${metadata.s}/${metadata.n}/${metadata.i}/treatment`; + expect(builder.buildExceptionKey(methodName)).toBe(expectedExceptionKey); }); diff --git a/src/storages/__tests__/findLatencyIndex.spec.ts b/src/storages/__tests__/findLatencyIndex.spec.ts new file mode 100644 index 00000000..b2b6f04c --- /dev/null +++ b/src/storages/__tests__/findLatencyIndex.spec.ts @@ -0,0 +1,27 @@ +import { findLatencyIndex } from '../findLatencyIndex'; + +const latenciesInMsAndBuckets = [ + // First bucket is up to 0.5 ms + [0, 0], + [0.500, 0], + [1.400, 1], + [1.500, 1], + [8.000, 6], + [11.39, 6], + [11.392, 7], + [17.085, 7], + // Last bucket + [7481.827, 22], + [7481.828, 22], + [7999.999, 22], + // Invalid values + [NaN, 0] +]; + +test('findLatencyIndex', () => { + + latenciesInMsAndBuckets.forEach(([latencyInMs, expectedBucket]) => { // @ts-ignore + expect(findLatencyIndex(latencyInMs)).toBe(expectedBucket); + }); + +}); diff --git a/src/storages/__tests__/testUtils.ts b/src/storages/__tests__/testUtils.ts index 4905adb2..520dba1d 100644 --- a/src/storages/__tests__/testUtils.ts +++ b/src/storages/__tests__/testUtils.ts @@ -7,8 +7,7 @@ export function assertStorageInterface(storage: IStorageSync | IStorageAsync) { expect(typeof storage.segments).toBe('object'); expect(typeof storage.impressions).toBe('object'); expect(typeof storage.events).toBe('object'); - expect(!storage.latencies || typeof storage.latencies === 'object').toBeTruthy; - expect(!storage.counts || typeof storage.counts === 'object').toBeTruthy; + expect(!storage.telemetry || typeof storage.telemetry === 'object').toBeTruthy; expect(!storage.impressionCounts || typeof storage.impressionCounts === 'object').toBeTruthy; } diff --git a/src/storages/findLatencyIndex.ts b/src/storages/findLatencyIndex.ts index 908752d9..aac73e7a 100644 --- a/src/storages/findLatencyIndex.ts +++ b/src/storages/findLatencyIndex.ts @@ -1,7 +1,16 @@ import { isNaNNumber } from '../utils/lang'; -// @TODO add unit tests -export function findLatencyIndex(latency: number, min = 0, max = 23, base = 1.5): number { - const index = Math.min(max, Math.max(min, Math.floor(Math.log(latency) / Math.log(base)))); +const MIN = 0; +const MAX = 22; +const BASE = 1.5; + +/** + * Calculates buckets from latency in milliseconds + * + * @param latencyInMs + * @returns a bucket index from 0 to 22 inclusive + */ +export function findLatencyIndex(latencyInMs: number): number { + const index = Math.min(MAX, Math.max(MIN, Math.ceil(Math.log(latencyInMs) / Math.log(BASE)))); return isNaNNumber(index) ? 0 : index; // index is NaN if latency is not a positive number } diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 627d1fcf..849a344b 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -72,7 +72,7 @@ export class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { if (segmentName) { accum.push(segmentName); } else { - // @BREAKING: This is only to clean up "old" keys. Remove this whole else code block. + // @TODO @BREAKING: This is only to clean up "old" keys. Remove this whole else code block and reuse `getRegisteredSegments` method. segmentName = this.keys.extractOldSegmentKey(key); if (segmentName) { // this was an old segment key, let's clean up. @@ -121,4 +121,16 @@ export class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { return isDiff; } + getRegisteredSegments(): string[] { + return Object.keys(localStorage).reduce((accum, key) => { + const segmentName = this.keys.extractSegmentName(key); + if (segmentName) accum.push(segmentName); + return accum; + }, []); + } + + getKeysCount() { + return 1; + } + } diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index 67c9efcc..ae6dac82 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -9,12 +9,17 @@ test('SEGMENT CACHE / in LocalStorage', () => { cache.clear(); cache.addToSegment('mocked-segment'); + cache.addToSegment('mocked-segment-2'); - expect(cache.isInSegment('mocked-segment') === true).toBe(true); + expect(cache.isInSegment('mocked-segment')).toBe(true); + expect(cache.getRegisteredSegments()).toEqual(['mocked-segment', 'mocked-segment-2']); + expect(cache.getKeysCount()).toBe(1); cache.removeFromSegment('mocked-segment'); - expect(cache.isInSegment('mocked-segment') === false).toBe(true); + expect(cache.isInSegment('mocked-segment')).toBe(false); + expect(cache.getRegisteredSegments()).toEqual(['mocked-segment-2']); + expect(cache.getKeysCount()).toBe(1); }); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 68604e30..850d495f 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -12,7 +12,8 @@ import { SplitsCacheInMemory } from '../inMemory/SplitsCacheInMemory'; import { DEFAULT_CACHE_EXPIRATION_IN_MILLIS } from '../../utils/constants/browser'; import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS'; import { LOG_PREFIX } from './constants'; -import { STORAGE_LOCALSTORAGE } from '../../utils/constants'; +import { LOCALHOST_MODE, STORAGE_LOCALSTORAGE } from '../../utils/constants'; +import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/TelemetryCacheInMemory'; export interface InLocalStorageOptions { prefix?: string @@ -29,7 +30,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn // Fallback to InMemoryStorage if LocalStorage API is not available if (!isLocalStorageAvailable()) { - params.log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Fallbacking into default MEMORY storage'); + params.log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); return InMemoryStorageCSFactory(params); } @@ -43,6 +44,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), + telemetry: params.mode !== LOCALHOST_MODE && shouldRecordTelemetry() ? new TelemetryCacheInMemory() : undefined, destroy() { this.splits = new SplitsCacheInMemory(); @@ -62,6 +64,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn impressions: this.impressions, impressionCounts: this.impressionCounts, events: this.events, + telemetry: this.telemetry, destroy() { this.splits = new SplitsCacheInMemory(); diff --git a/src/storages/inMemory/CountsCacheInMemory.ts b/src/storages/inMemory/CountsCacheInMemory.ts deleted file mode 100644 index ff7cd591..00000000 --- a/src/storages/inMemory/CountsCacheInMemory.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ICountsCacheSync } from '../types'; - -export class CountsCacheInMemory implements ICountsCacheSync { - - private counters: Record = {}; - - /** - * Add counts. - */ - track(metricName: string) { - if (this.counters[metricName] === undefined) this.counters[metricName] = 1; - else this.counters[metricName]++; - - return true; - } - - /** - * Clear the collector - */ - clear() { - this.counters = {}; - } - - /** - * Get the collected data, used as payload for posting. - */ - state() { - return this.counters; - } - - /** - * Check if the cache is empty. - */ - isEmpty() { - return Object.keys(this.counters).length === 0; - } -} diff --git a/src/storages/inMemory/InMemoryStorage.ts b/src/storages/inMemory/InMemoryStorage.ts index 6f446f20..d5acfb6a 100644 --- a/src/storages/inMemory/InMemoryStorage.ts +++ b/src/storages/inMemory/InMemoryStorage.ts @@ -4,7 +4,8 @@ import { ImpressionsCacheInMemory } from './ImpressionsCacheInMemory'; import { EventsCacheInMemory } from './EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync } from '../types'; import { ImpressionCountsCacheInMemory } from './ImpressionCountsCacheInMemory'; -import { STORAGE_MEMORY } from '../../utils/constants'; +import { LOCALHOST_MODE, STORAGE_MEMORY } from '../../utils/constants'; +import { TelemetryCacheInMemory } from './TelemetryCacheInMemory'; /** * InMemory storage factory for standalone server-side SplitFactory @@ -19,6 +20,7 @@ export function InMemoryStorageFactory(params: IStorageFactoryParams): IStorageS impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), + telemetry: params.mode !== LOCALHOST_MODE ? new TelemetryCacheInMemory() : undefined, // Always track telemetry in standalone mode on server-side // When using MEMORY we should clean all the caches to leave them empty destroy() { diff --git a/src/storages/inMemory/InMemoryStorageCS.ts b/src/storages/inMemory/InMemoryStorageCS.ts index 4fa9b962..8f1d7d3b 100644 --- a/src/storages/inMemory/InMemoryStorageCS.ts +++ b/src/storages/inMemory/InMemoryStorageCS.ts @@ -4,7 +4,8 @@ import { ImpressionsCacheInMemory } from './ImpressionsCacheInMemory'; import { EventsCacheInMemory } from './EventsCacheInMemory'; import { IStorageSync, IStorageFactoryParams } from '../types'; import { ImpressionCountsCacheInMemory } from './ImpressionCountsCacheInMemory'; -import { STORAGE_MEMORY } from '../../utils/constants'; +import { LOCALHOST_MODE, STORAGE_MEMORY } from '../../utils/constants'; +import { shouldRecordTelemetry, TelemetryCacheInMemory } from './TelemetryCacheInMemory'; /** * InMemory storage factory for standalone client-side SplitFactory @@ -19,6 +20,7 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), + telemetry: params.mode !== LOCALHOST_MODE && shouldRecordTelemetry() ? new TelemetryCacheInMemory() : undefined, // When using MEMORY we should clean all the caches to leave them empty destroy() { @@ -37,6 +39,7 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag impressions: this.impressions, impressionCounts: this.impressionCounts, events: this.events, + telemetry: this.telemetry, // Set a new splits cache to clean it for the client without affecting other clients destroy() { diff --git a/src/storages/inMemory/LatenciesCacheInMemory.ts b/src/storages/inMemory/LatenciesCacheInMemory.ts deleted file mode 100644 index 240b5a8b..00000000 --- a/src/storages/inMemory/LatenciesCacheInMemory.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ILatenciesCacheSync } from '../types'; -import { findLatencyIndex } from '../findLatencyIndex'; - -export class LatenciesCacheInMemory implements ILatenciesCacheSync { - - private counters: Record = {}; - - /** - * Add latencies. - */ - track(metricName: string, latency: number) { - // Initialize if needed - if (this.counters[metricName] === undefined) { - this.counters[metricName] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - ]; - } - - // +1 based on the latency number - this.counters[metricName][findLatencyIndex(latency)]++; - - return true; - } - - /** - * Clear the collector - */ - clear() { - this.counters = {}; - } - - /** - * Get the collected data, used as payload for posting. - */ - state() { - return this.counters; - } - - /** - * Check if the cache is empty. - */ - isEmpty() { - return Object.keys(this.counters).length === 0; - } -} diff --git a/src/storages/inMemory/MySegmentsCacheInMemory.ts b/src/storages/inMemory/MySegmentsCacheInMemory.ts index 02519d18..08a767a1 100644 --- a/src/storages/inMemory/MySegmentsCacheInMemory.ts +++ b/src/storages/inMemory/MySegmentsCacheInMemory.ts @@ -72,4 +72,12 @@ export class MySegmentsCacheInMemory extends AbstractSegmentsCacheSync { return isDiff; } + getRegisteredSegments() { + return Object.keys(this.segmentCache); + } + + getKeysCount() { + return 1; + } + } diff --git a/src/storages/inMemory/SegmentsCacheInMemory.ts b/src/storages/inMemory/SegmentsCacheInMemory.ts index ca86c86d..a7d52b7c 100644 --- a/src/storages/inMemory/SegmentsCacheInMemory.ts +++ b/src/storages/inMemory/SegmentsCacheInMemory.ts @@ -68,6 +68,12 @@ export class SegmentsCacheInMemory extends AbstractSegmentsCacheSync { return Object.keys(this.segmentCache); } + getKeysCount() { + return Object.keys(this.segmentCache).reduce((acum, segmentName) => { + return acum + this.segmentCache[segmentName].size; + }, 0); + } + setChangeNumber(name: string, changeNumber: number) { this.segmentChangeNumber[name] = changeNumber; diff --git a/src/storages/inMemory/TelemetryCacheInMemory.ts b/src/storages/inMemory/TelemetryCacheInMemory.ts new file mode 100644 index 00000000..6a56d26b --- /dev/null +++ b/src/storages/inMemory/TelemetryCacheInMemory.ts @@ -0,0 +1,210 @@ +import { ImpressionDataType, EventDataType, LastSync, HttpErrors, HttpLatencies, StreamingEvent, Method, OperationType, MethodExceptions, MethodLatencies } from '../../sync/submitters/types'; +import { findLatencyIndex } from '../findLatencyIndex'; +import { ITelemetryCacheSync } from '../types'; + +const MAX_STREAMING_EVENTS = 20; +const MAX_TAGS = 10; + +function newBuckets() { + // MAX_LATENCY_BUCKET_COUNT (length) is 23; + return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +} + +const ACCEPTANCE_RANGE = 0.001; + +/** + * Used on client-side. 0.1% of instances will track telemetry + */ +export function shouldRecordTelemetry() { + return Math.random() <= ACCEPTANCE_RANGE; +} + +export class TelemetryCacheInMemory implements ITelemetryCacheSync { + + private timeUntilReady?: number; + + getTimeUntilReady() { + return this.timeUntilReady; + } + + recordTimeUntilReady(ms: number) { + this.timeUntilReady = ms; + } + + private timeUntilReadyFromCache?: number; + + getTimeUntilReadyFromCache() { + return this.timeUntilReadyFromCache; + } + + recordTimeUntilReadyFromCache(ms: number) { + this.timeUntilReadyFromCache = ms; + } + + private notReadyUsage = 0; + + getNonReadyUsage() { + return this.notReadyUsage; + } + + recordNonReadyUsage() { + this.notReadyUsage++; + } + + private impressionStats = [0, 0, 0]; + + getImpressionStats(type: ImpressionDataType) { + return this.impressionStats[type]; + } + + recordImpressionStats(type: ImpressionDataType, count: number) { + this.impressionStats[type] += count; + } + + private eventStats = [0, 0]; + + getEventStats(type: EventDataType) { + return this.eventStats[type]; + } + + recordEventStats(type: EventDataType, count: number) { + this.eventStats[type] += count; + } + + // @ts-expect-error + private lastSync: LastSync = {}; + + getLastSynchronization() { + return this.lastSync; + } + + recordSuccessfulSync(resource: OperationType, timeMs: number) { + this.lastSync[resource] = timeMs; + } + + // @ts-expect-error + private httpErrors: HttpErrors = {}; + + popHttpErrors() { + const result = this.httpErrors; // @ts-expect-error + this.httpErrors = {}; + return result; + } + + recordHttpError(resource: OperationType, status: number) { + if (!this.httpErrors[resource]) this.httpErrors[resource] = {}; + if (!this.httpErrors[resource][status]) { + this.httpErrors[resource][status] = 1; + } else { + this.httpErrors[resource][status]++; + } + } + + // @ts-expect-error + private httpLatencies: HttpLatencies = {}; + + popHttpLatencies() { + const result = this.httpLatencies; // @ts-expect-error + this.httpLatencies = {}; + return result; + } + + recordHttpLatency(resource: OperationType, latencyMs: number) { + if (!this.httpLatencies[resource]) { + this.httpLatencies[resource] = newBuckets(); + } + this.httpLatencies[resource][findLatencyIndex(latencyMs)]++; + } + + private authRejections = 0; + + popAuthRejections() { + const result = this.authRejections; + this.authRejections = 0; + return result; + } + + recordAuthRejections() { + this.authRejections++; + } + + private tokenRefreshes = 0; + + popTokenRefreshes() { + const result = this.tokenRefreshes; + this.tokenRefreshes = 0; + return result; + } + + recordTokenRefreshes() { + this.tokenRefreshes++; + } + + private streamingEvents: StreamingEvent[] = [] + + popStreamingEvents() { + return this.streamingEvents.splice(0); + } + + recordStreamingEvents(streamingEvent: StreamingEvent) { + if (this.streamingEvents.length < MAX_STREAMING_EVENTS) { + this.streamingEvents.push(streamingEvent); + } + } + + private tags: string[] = []; + + popTags() { + return this.tags.splice(0); + } + + addTag(tag: string) { + if (this.tags.length < MAX_TAGS) { + this.tags.push(tag); + } + } + + private sessionLength?: number; + + getSessionLength() { + return this.sessionLength; + } + + recordSessionLength(ms: number) { + this.sessionLength = ms; + } + + // @ts-expect-error + private exceptions: MethodExceptions = {}; + + popExceptions() { + const result = this.exceptions; // @ts-expect-error + this.exceptions = {}; + return result; + } + + recordException(method: Method) { + if (!this.exceptions[method]) { + this.exceptions[method] = 1; + } else { + this.exceptions[method]++; + } + } + + // @ts-expect-error + private latencies: MethodLatencies = {}; + + popLatencies() { + const result = this.latencies; // @ts-expect-error + this.latencies = {}; + return result; + } + + recordLatency(method: Method, latencyMs: number) { + if (!this.latencies[method]) { + this.latencies[method] = newBuckets(); + } + this.latencies[method][findLatencyIndex(latencyMs)]++; + } + +} diff --git a/src/storages/inMemory/__tests__/CountsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/CountsCacheInMemory.spec.ts deleted file mode 100644 index bb8ebcc0..00000000 --- a/src/storages/inMemory/__tests__/CountsCacheInMemory.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CountsCacheInMemory } from '../CountsCacheInMemory'; - -test('COUNT CACHE IN MEMORY / should count metric names incrementatly', () => { - const cache = new CountsCacheInMemory(); - - cache.track('counted-metric-one'); - cache.track('counted-metric-one'); - cache.track('counted-metric-two'); - - const state = cache.state(); - - expect(state['counted-metric-one']).toBe(2); - expect(state['counted-metric-two']).toBe(1); -}); diff --git a/src/storages/inMemory/__tests__/LatenciesCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/LatenciesCacheInMemory.spec.ts deleted file mode 100644 index 18ad0a3a..00000000 --- a/src/storages/inMemory/__tests__/LatenciesCacheInMemory.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { LatenciesCacheInMemory } from '../LatenciesCacheInMemory'; - -test('METRICS (LATENCIES) CACHE IN MEMORY / should count based on ranges', () => { - const metricName = 'testing'; - const c1 = new LatenciesCacheInMemory(); - - c1.track(metricName, 1); - c1.track(metricName, 1.2); - c1.track(metricName, 1.4); - - expect(c1.state()[metricName][0] === 3).toBe(true); // the bucket #0 should have 3 - - c1.track(metricName, 1.5); - expect(c1.state()[metricName][1] === 1).toBe(true); // the bucket #1 should have 1 - - c1.track(metricName, 2.25); - c1.track(metricName, 2.26); - c1.track(metricName, 2.265); - expect(c1.state()[metricName][2] === 3).toBe(true); // the bucket #3 should have 1 - - c1.track(metricName, 985251); - expect(c1.state()[metricName][23] === 1).toBe(true); // the bucket #23 should have 1 - -}); - -test('METRICS (LATENCIES) CACHE IN MEMORY / clear', () => { - const metricName = 'testing'; - const c1 = new LatenciesCacheInMemory(); - - c1.track(metricName, 1); - c1.track(metricName, 1000); - c1.clear(); - expect(c1.isEmpty()).toBe(true); // after call clear, the cache should be empty -}); diff --git a/src/storages/inMemory/__tests__/MySegmentsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/MySegmentsCacheInMemory.spec.ts index 3ac0717c..7236e950 100644 --- a/src/storages/inMemory/__tests__/MySegmentsCacheInMemory.spec.ts +++ b/src/storages/inMemory/__tests__/MySegmentsCacheInMemory.spec.ts @@ -4,11 +4,16 @@ test('MY SEGMENTS CACHE / in memory', () => { const cache = new MySegmentsCacheInMemory(); cache.addToSegment('mocked-segment'); + cache.addToSegment('mocked-segment-2'); - expect(cache.isInSegment('mocked-segment') === true).toBe(true); + expect(cache.isInSegment('mocked-segment')).toBe(true); + expect(cache.getRegisteredSegments()).toEqual(['mocked-segment', 'mocked-segment-2']); + expect(cache.getKeysCount()).toBe(1); cache.removeFromSegment('mocked-segment'); - expect(cache.isInSegment('mocked-segment') === false).toBe(true); + expect(cache.isInSegment('mocked-segment')).toBe(false); + expect(cache.getRegisteredSegments()).toEqual(['mocked-segment-2']); + expect(cache.getKeysCount()).toBe(1); }); diff --git a/src/storages/inMemory/__tests__/SegmentsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/SegmentsCacheInMemory.spec.ts index 4d868849..e6713376 100644 --- a/src/storages/inMemory/__tests__/SegmentsCacheInMemory.spec.ts +++ b/src/storages/inMemory/__tests__/SegmentsCacheInMemory.spec.ts @@ -2,7 +2,7 @@ import { SegmentsCacheInMemory } from '../SegmentsCacheInMemory'; describe('SEGMENTS CACHE IN MEMORY', () => { - test('isInSegment, set/getChangeNumber, add/removeFromSegment', () => { + test('isInSegment, set/getChangeNumber, add/removeFromSegment, getKeysCount', () => { const cache = new SegmentsCacheInMemory(); cache.addToSegment('mocked-segment', [ @@ -26,6 +26,13 @@ describe('SEGMENTS CACHE IN MEMORY', () => { expect(cache.isInSegment('mocked-segment', 'c')).toBe(false); // c expect(cache.isInSegment('mocked-segment', 'd')).toBe(true); // d expect(cache.isInSegment('mocked-segment', 'e')).toBe(true); // e + + // getKeysCount + expect(cache.getKeysCount()).toBe(3); + cache.addToSegment('mocked-segment-2', ['a', 'b', 'c', 'd', 'e']); + expect(cache.getKeysCount()).toBe(8); + cache.clear(); + expect(cache.getKeysCount()).toBe(0); }); test('registerSegment / getRegisteredSegments', async () => { diff --git a/src/storages/inMemory/__tests__/TelemetryCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/TelemetryCacheInMemory.spec.ts new file mode 100644 index 00000000..a3d6d854 --- /dev/null +++ b/src/storages/inMemory/__tests__/TelemetryCacheInMemory.spec.ts @@ -0,0 +1,212 @@ +import { QUEUED, DROPPED, DEDUPED, EVENTS, IMPRESSIONS, IMPRESSIONS_COUNT, MY_SEGMENT, SEGMENT, SPLITS, TELEMETRY, TOKEN, TRACK, TREATMENT, TREATMENTS, TREATMENTS_WITH_CONFIG, TREATMENT_WITH_CONFIG } from '../../../utils/constants'; +import { EventDataType, ImpressionDataType, Method, OperationType, StreamingEvent } from '../../../sync/submitters/types'; +import { TelemetryCacheInMemory } from '../TelemetryCacheInMemory'; + +const impressionDataTypes: ImpressionDataType[] = [QUEUED, DROPPED, DEDUPED]; + +const eventDataTypes: EventDataType[] = [QUEUED, DROPPED]; + +const operationTypes: OperationType[] = [ + SPLITS, + IMPRESSIONS, + IMPRESSIONS_COUNT, + EVENTS, + TELEMETRY, + TOKEN, + SEGMENT, + MY_SEGMENT +]; + +const methods: Method[] = [ + TREATMENT, + TREATMENTS, + TREATMENT_WITH_CONFIG, + TREATMENTS_WITH_CONFIG, + TRACK +]; + +const latencies = [0, 0.500, 1.400, 17.085, 7999.999]; +const latencyBuckets = [2, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + +describe('TELEMETRY CACHE', () => { + const cache = new TelemetryCacheInMemory(); + + test('time until ready', () => { + expect(cache.getTimeUntilReady()).toBe(undefined); + cache.recordTimeUntilReady(100); + expect(cache.getTimeUntilReady()).toBe(100); + }); + + test('time until ready from cache', () => { + expect(cache.getTimeUntilReadyFromCache()).toBe(undefined); + cache.recordTimeUntilReadyFromCache(10); + expect(cache.getTimeUntilReadyFromCache()).toBe(10); + }); + + test('session length', () => { + expect(cache.getSessionLength()).toBe(undefined); + cache.recordSessionLength(10); + expect(cache.getSessionLength()).toBe(10); + }); + + test('not ready usage', () => { + expect(cache.getNonReadyUsage()).toBe(0); + cache.recordNonReadyUsage(); + expect(cache.getNonReadyUsage()).toBe(1); + cache.recordNonReadyUsage(); + cache.recordNonReadyUsage(); + expect(cache.getNonReadyUsage()).toBe(3); + }); + + test('impression stats', () => { + impressionDataTypes.forEach((stat: ImpressionDataType) => { + expect(cache.getImpressionStats(stat)).toBe(0); + cache.recordImpressionStats(stat, 1); + expect(cache.getImpressionStats(stat)).toBe(1); + cache.recordImpressionStats(stat, 10); + expect(cache.getImpressionStats(stat)).toBe(11); + }); + }); + + test('event stats', () => { + eventDataTypes.forEach((stat: EventDataType) => { + expect(cache.getEventStats(stat)).toBe(0); + cache.recordEventStats(stat, 1); + expect(cache.getEventStats(stat)).toBe(1); + cache.recordEventStats(stat, 2); + expect(cache.getEventStats(stat)).toBe(3); + }); + }); + + test('last synchronization', () => { + expect(cache.getLastSynchronization()).toEqual({}); + operationTypes.forEach((operation, index) => { + cache.recordSuccessfulSync(operation, index); + }); + + const expectedLastSync = { 'sp': 0, 'im': 1, 'ic': 2, 'ev': 3, 'te': 4, 'to': 5, 'se': 6, 'ms': 7 }; + expect(cache.getLastSynchronization()).toEqual(expectedLastSync); + + // Overwrite a single operation + cache.recordSuccessfulSync(MY_SEGMENT, 100); + expect(cache.getLastSynchronization()).toEqual({ ...expectedLastSync, 'ms': 100 }); + }); + + test('http errors', () => { + expect(cache.popHttpErrors()).toEqual({}); + operationTypes.forEach((operation) => { + cache.recordHttpError(operation, 400); + cache.recordHttpError(operation, 400); + cache.recordHttpError(operation, 500); + }); + + const httpErrors = { '400': 2, '500': 1 }; + const expectedHttpErrors = { 'sp': httpErrors, 'im': httpErrors, 'ic': httpErrors, 'ev': httpErrors, 'te': httpErrors, 'to': httpErrors, 'se': httpErrors, 'ms': httpErrors }; + expect(cache.popHttpErrors()).toEqual(expectedHttpErrors); + expect(cache.popHttpErrors()).toEqual({}); + + // Set a single http error + cache.recordHttpError(MY_SEGMENT, 400); + expect(cache.popHttpErrors()).toEqual({ 'ms': { 400: 1 } }); + }); + + test('http latencies', () => { + expect(cache.popHttpLatencies()).toEqual({}); + operationTypes.forEach((operation) => { + latencies.forEach((latency) => { + cache.recordHttpLatency(operation, latency); + }); + }); + + const expectedLatencyBuckets = { 'sp': latencyBuckets, 'im': latencyBuckets, 'ic': latencyBuckets, 'ev': latencyBuckets, 'te': latencyBuckets, 'to': latencyBuckets, 'se': latencyBuckets, 'ms': latencyBuckets }; + expect(cache.popHttpLatencies()).toEqual(expectedLatencyBuckets); + expect(cache.popHttpLatencies()).toEqual({}); + }); + + test('auth rejections', () => { + expect(cache.popAuthRejections()).toBe(0); + cache.recordAuthRejections(); + expect(cache.popAuthRejections()).toBe(1); + expect(cache.popAuthRejections()).toBe(0); + cache.recordAuthRejections(); + cache.recordAuthRejections(); + expect(cache.popAuthRejections()).toBe(2); + expect(cache.popAuthRejections()).toBe(0); + }); + + test('token refreshes', () => { + expect(cache.popTokenRefreshes()).toBe(0); + cache.recordTokenRefreshes(); + expect(cache.popTokenRefreshes()).toBe(1); + expect(cache.popTokenRefreshes()).toBe(0); + cache.recordTokenRefreshes(); + cache.recordTokenRefreshes(); + expect(cache.popTokenRefreshes()).toBe(2); + expect(cache.popTokenRefreshes()).toBe(0); + }); + + test('streaming events', () => { + const streamingEvent: StreamingEvent = { e: 10, d: 2, t: 3 }; + + expect(cache.popStreamingEvents()).toEqual([]); + cache.recordStreamingEvents(streamingEvent); + expect(cache.popStreamingEvents()).toEqual([streamingEvent]); + expect(cache.popStreamingEvents()).toEqual([]); + + // MAX_STREAMING_EVENTS === 20 + for (let i = 0; i < 100; i++) { + cache.recordStreamingEvents({ ...streamingEvent, t: i }); + } + const actualStreamingEvents = cache.popStreamingEvents(); + expect(actualStreamingEvents.length).toBe(20); + actualStreamingEvents.forEach((actualStreamingEvent, index) => { + expect(actualStreamingEvent).toEqual({ ...streamingEvent, t: index }); + }); + expect(cache.popStreamingEvents()).toEqual([]); + }); + + test('tags', () => { + expect(cache.popTags()).toEqual([]); + cache.addTag('MY_TAG'); + expect(cache.popTags()).toEqual(['MY_TAG']); + expect(cache.popTags()).toEqual([]); + + // MAX_TAGS === 10 + for (let i = 0; i < 100; i++) { + cache.addTag('TAG_' + i); + } + expect(cache.popTags()).toEqual(['TAG_0', 'TAG_1', 'TAG_2', 'TAG_3', 'TAG_4', 'TAG_5', 'TAG_6', 'TAG_7', 'TAG_8', 'TAG_9']); + expect(cache.popTags()).toEqual([]); + }); + + + test('method exceptions', () => { + expect(cache.popExceptions()).toEqual({}); + methods.forEach((method) => { + cache.recordException(method); + cache.recordException(method); + }); + + const expectedExceptions = { 't': 2, 'ts': 2, 'tc': 2, 'tcs': 2, 'tr': 2 }; + expect(cache.popExceptions()).toEqual(expectedExceptions); + expect(cache.popExceptions()).toEqual({}); + + // Set a single method exception error + cache.recordException(TRACK); + expect(cache.popExceptions()).toEqual({ 'tr': 1 }); + }); + + test('method latencies', () => { + expect(cache.popLatencies()).toEqual({}); + methods.forEach((method) => { + latencies.forEach((latency) => { + cache.recordLatency(method, latency); + }); + }); + + const expectedLatencyBuckets = { 't': latencyBuckets, 'ts': latencyBuckets, 'tc': latencyBuckets, 'tcs': latencyBuckets, 'tr': latencyBuckets }; + expect(cache.popLatencies()).toEqual(expectedLatencyBuckets); + expect(cache.popLatencies()).toEqual({}); + }); + +}); diff --git a/src/storages/inRedis/CountsCacheInRedis.ts b/src/storages/inRedis/CountsCacheInRedis.ts deleted file mode 100644 index cb551fa2..00000000 --- a/src/storages/inRedis/CountsCacheInRedis.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ICountsCacheAsync } from '../types'; -import { KeyBuilderSS } from '../KeyBuilderSS'; -import { Redis } from 'ioredis'; - -export class CountsCacheInRedis implements ICountsCacheAsync { - - private readonly redis: Redis; - private readonly keys: KeyBuilderSS; - - constructor(keys: KeyBuilderSS, redis: Redis) { - this.keys = keys; - this.redis = redis; - } - - track(metricName: string): Promise { - return this.redis.incr(this.keys.buildCountKey(metricName)).catch(() => { - // noop, for telemetry metrics there's no need to throw. - }).then(() => true); - } -} diff --git a/src/storages/inRedis/LatenciesCacheInRedis.ts b/src/storages/inRedis/LatenciesCacheInRedis.ts deleted file mode 100644 index f69b7244..00000000 --- a/src/storages/inRedis/LatenciesCacheInRedis.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ILatenciesCacheAsync } from '../types'; -import { KeyBuilderSS } from '../KeyBuilderSS'; -import { findLatencyIndex } from '../findLatencyIndex'; -import { Redis } from 'ioredis'; - -export class LatenciesCacheInRedis implements ILatenciesCacheAsync { - - private readonly redis: Redis; - private readonly keys: KeyBuilderSS; - - constructor(keys: KeyBuilderSS, redis: Redis) { - this.keys = keys; - this.redis = redis; - } - - track(metricName: string, latency: number): Promise { - const bucketNumber = findLatencyIndex(latency); - - return this.redis.incr(this.keys.buildLatencyKey(metricName, bucketNumber)).catch(() => { - // noop, for telemetry metrics there's no need to throw. - }).then(() => true); - } -} diff --git a/src/storages/inRedis/RedisAdapter.ts b/src/storages/inRedis/RedisAdapter.ts index 0c374f23..45ef7b15 100644 --- a/src/storages/inRedis/RedisAdapter.ts +++ b/src/storages/inRedis/RedisAdapter.ts @@ -8,7 +8,7 @@ import { timeout } from '../../utils/promise/timeout'; const LOG_PREFIX = 'storage:redis-adapter: '; // If we ever decide to fully wrap every method, there's a Commander.getBuiltinCommands from ioredis. -const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'pipeline', 'expire', 'mget', 'lrange', 'ltrim']; +const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'pipeline', 'expire', 'mget', 'lrange', 'ltrim', 'hset']; // Not part of the settings since it'll vary on each storage. We should be removing storage specific logic from elsewhere. const DEFAULT_OPTIONS = { diff --git a/src/storages/inRedis/TelemetryCacheInRedis.ts b/src/storages/inRedis/TelemetryCacheInRedis.ts new file mode 100644 index 00000000..7f5ccd51 --- /dev/null +++ b/src/storages/inRedis/TelemetryCacheInRedis.ts @@ -0,0 +1,36 @@ +import { ILogger } from '../../logger/types'; +import { Method } from '../../sync/submitters/types'; +import { KeyBuilderSS } from '../KeyBuilderSS'; +import { ITelemetryCacheAsync } from '../types'; +import { findLatencyIndex } from '../findLatencyIndex'; +import { Redis } from 'ioredis'; +import { getTelemetryConfigStats } from '../../sync/submitters/telemetrySubmitter'; +import { CONSUMER_MODE, STORAGE_REDIS } from '../../utils/constants'; + +export class TelemetryCacheInRedis implements ITelemetryCacheAsync { + + /** + * Create a Telemetry cache that uses Redis as storage. + * @param log Logger instance. + * @param keys Key builder. + * @param redis Redis client. + */ + constructor(private readonly log: ILogger, private readonly keys: KeyBuilderSS, private readonly redis: Redis) { } + + recordLatency(method: Method, latencyMs: number) { + const [key, field] = this.keys.buildLatencyKey(method, findLatencyIndex(latencyMs)).split('::'); + return this.redis.hincrby(key, field, 1) + .catch(() => { /* Handle rejections for telemetry */ }); + } + recordException(method: Method) { + const [key, field] = this.keys.buildExceptionKey(method).split('::'); + return this.redis.hincrby(key, field, 1) + .catch(() => { /* Handle rejections for telemetry */ }); + } + + recordConfig() { + const [key, field] = this.keys.buildInitKey().split('::'); + const value = JSON.stringify(getTelemetryConfigStats(CONSUMER_MODE, STORAGE_REDIS)); + return this.redis.hset(key, field, value).catch(() => { /* Handle rejections for telemetry */ }); + } +} diff --git a/src/storages/inRedis/__tests__/CountsCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/CountsCacheInRedis.spec.ts deleted file mode 100644 index b8032ac2..00000000 --- a/src/storages/inRedis/__tests__/CountsCacheInRedis.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Redis from 'ioredis'; -import { KeyBuilderSS } from '../../KeyBuilderSS'; -import { CountsCacheInRedis } from '../CountsCacheInRedis'; - -const prefix = 'counts_cache_ut'; -const metadata = { s: 'version', i: 'ip', n: 'hostname' }; - -test('COUNTS CACHE IN REDIS / cover basic behavior', async () => { - const connection = new Redis({}); - const keys = new KeyBuilderSS(prefix, metadata); - const cache = new CountsCacheInRedis(keys, connection); - - await cache.track('counted-metric-one'); - await cache.track('counted-metric-one'); - - const keyOne = keys.buildCountKey('counted-metric-one'); - const keyTwo = keys.buildCountKey('counted-metric-two'); - - let metricOneValue = await connection.get(keyOne); - expect(metricOneValue).toBe('2'); - - await cache.track('counted-metric-two'); - - metricOneValue = await connection.get(keyOne); - let metricTwoValue = await connection.get(keyTwo); - - expect(metricOneValue).toBe('2'); - expect(metricTwoValue).toBe('1'); - - // Clean up - const keysToClean = await connection.keys(`${prefix}*`); - if (keysToClean.length) await connection.del(keysToClean); - - await connection.quit(); -}); diff --git a/src/storages/inRedis/__tests__/LatenciesCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/LatenciesCacheInRedis.spec.ts deleted file mode 100644 index 7567b969..00000000 --- a/src/storages/inRedis/__tests__/LatenciesCacheInRedis.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import Redis from 'ioredis'; -import { KeyBuilderSS } from '../../KeyBuilderSS'; -import { LatenciesCacheInRedis } from '../LatenciesCacheInRedis'; - -const prefix = 'latencies_cache_UT'; -const metadata = { s: 'js_someversion', i: 'some_ip', n: 'some_hostname' }; - -test('METRICS (LATENCIES) CACHE IN REDIS / should count based on ranges', async () => { - const connection = new Redis(); - const keys = new KeyBuilderSS(prefix, metadata); - const cache = new LatenciesCacheInRedis(keys, connection); - const metricName = 'testing'; - - await cache.track(metricName, 1); - await cache.track(metricName, 1.2); - await cache.track(metricName, 1.4); - - expect(await connection.get(keys.buildLatencyKey(metricName, 0))).toBe('3'); // the bucket #0 should have 3 - - await cache.track(metricName, 1.5); - - expect(await connection.get(keys.buildLatencyKey(metricName, 1))).toBe('1'); // the bucket #1 should have 1 - - await cache.track(metricName, 2.25); - await cache.track(metricName, 2.26); - await cache.track(metricName, 2.265); - - expect(await connection.get(keys.buildLatencyKey(metricName, 2))).toBe('3'); // the bucket #2 should have 3 - - await cache.track(metricName, 985251); - - expect(await connection.get(keys.buildLatencyKey(metricName, 23))).toBe('1'); // the bucket #23 should have 1 - - // Clean up post-test - const keysToClean = await connection.keys(`${prefix}*`); - await connection.del(keysToClean); - - await connection.quit(); -}); diff --git a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts index 1e9fd512..ecdf855a 100644 --- a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts +++ b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts @@ -11,7 +11,7 @@ const LOG_PREFIX = 'storage:redis-adapter: '; // Mocking ioredis // The list of methods we're wrapping on a promise (for timeout) on the adapter. -const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'pipeline', 'expire', 'mget']; +const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'pipeline', 'expire', 'mget', 'lrange', 'ltrim', 'hset']; const ioredisMock = reduce([...METHODS_TO_PROMISE_WRAP, 'disconnect'], (acc, methodName) => { acc[methodName] = jest.fn(() => Promise.resolve(methodName)); diff --git a/src/storages/inRedis/__tests__/SegmentsCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/SegmentsCacheInRedis.spec.ts index e230fbc0..15d81e6b 100644 --- a/src/storages/inRedis/__tests__/SegmentsCacheInRedis.spec.ts +++ b/src/storages/inRedis/__tests__/SegmentsCacheInRedis.spec.ts @@ -36,6 +36,7 @@ describe('SEGMENTS CACHE IN REDIS', () => { expect(await cache.isInSegment('mocked-segment', 'd')).toBe(true); expect(await cache.isInSegment('mocked-segment', 'e')).toBe(true); + await cache.clear(); await connection.quit(); }); @@ -53,6 +54,7 @@ describe('SEGMENTS CACHE IN REDIS', () => { ['s1', 's2', 's3', 's4'].forEach(s => expect(segments.indexOf(s) !== -1).toBe(true)); + await cache.clear(); await connection.quit(); }); diff --git a/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts new file mode 100644 index 00000000..258fe682 --- /dev/null +++ b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts @@ -0,0 +1,47 @@ +import Redis from 'ioredis'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { KeyBuilderSS } from '../../KeyBuilderSS'; +import { TelemetryCacheInRedis } from '../TelemetryCacheInRedis'; +import { fakeMetadata } from '../../pluggable/__tests__/ImpressionsCachePluggable.spec'; + +const prefix = 'telemetry_cache_ut'; +const exceptionKey = `${prefix}.telemetry.exceptions`; +const latencyKey = `${prefix}.telemetry.latencies`; +const initKey = `${prefix}.telemetry.init`; +const fieldVersionablePrefix = `${fakeMetadata.s}/${fakeMetadata.n}/${fakeMetadata.i}`; + +test('TELEMETRY CACHE IN REDIS', async () => { + + const keysBuilder = new KeyBuilderSS(prefix, fakeMetadata); + const connection = new Redis(); + const cache = new TelemetryCacheInRedis(loggerMock, keysBuilder, connection); + + // recordException + expect(await cache.recordException('tr')).toBe(1); + expect(await cache.recordException('tr')).toBe(2); + + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe('2'); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatment')).toBe(null); + + // recordLatency + expect(await cache.recordLatency('tr', 1.6)).toBe(1); + expect(await cache.recordLatency('tr', 1.6)).toBe(2); + + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe('2'); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatment/2')).toBe(null); + + // recordConfig + expect(await cache.recordConfig()).toBe(1); + expect(JSON.parse(await connection.hget(initKey, fieldVersionablePrefix) as string)).toEqual({ + oM: 1, + st: 'redis', + aF: 0, + rF: 0 + }); + + // Clean up then end. + await connection.hdel(exceptionKey, fieldVersionablePrefix + '/track'); + await connection.hdel(latencyKey, fieldVersionablePrefix + '/track/2'); + await connection.hdel(initKey, fieldVersionablePrefix); + await connection.quit(); +}); diff --git a/src/storages/inRedis/index.ts b/src/storages/inRedis/index.ts index de72a220..b6022695 100644 --- a/src/storages/inRedis/index.ts +++ b/src/storages/inRedis/index.ts @@ -6,9 +6,8 @@ import { SplitsCacheInRedis } from './SplitsCacheInRedis'; import { SegmentsCacheInRedis } from './SegmentsCacheInRedis'; import { ImpressionsCacheInRedis } from './ImpressionsCacheInRedis'; import { EventsCacheInRedis } from './EventsCacheInRedis'; -import { LatenciesCacheInRedis } from './LatenciesCacheInRedis'; -import { CountsCacheInRedis } from './CountsCacheInRedis'; import { STORAGE_REDIS } from '../../utils/constants'; +import { TelemetryCacheInRedis } from './TelemetryCacheInRedis'; export interface InRedisStorageOptions { prefix?: string @@ -27,10 +26,14 @@ export function InRedisStorage(options: InRedisStorageOptions = {}): IStorageAsy const keys = new KeyBuilderSS(prefix, metadata); const redisClient = new RedisAdapter(log, options.options || {}); + const telemetry = new TelemetryCacheInRedis(log, keys, redisClient); // subscription to Redis connect event in order to emit SDK_READY event on consumer mode redisClient.on('connect', () => { onReadyCb(); + + // Synchronize config + telemetry.recordConfig(); }); return { @@ -38,8 +41,7 @@ export function InRedisStorage(options: InRedisStorageOptions = {}): IStorageAsy segments: new SegmentsCacheInRedis(log, keys, redisClient), impressions: new ImpressionsCacheInRedis(log, keys.buildImpressionsKey(), redisClient, metadata), events: new EventsCacheInRedis(log, keys.buildEventsKey(), redisClient, metadata), - latencies: new LatenciesCacheInRedis(keys, redisClient), - counts: new CountsCacheInRedis(keys, redisClient), + telemetry, // When using REDIS we should: // 1- Disconnect from the storage diff --git a/src/storages/pluggable/TelemetryCachePluggable.ts b/src/storages/pluggable/TelemetryCachePluggable.ts new file mode 100644 index 00000000..24d344f5 --- /dev/null +++ b/src/storages/pluggable/TelemetryCachePluggable.ts @@ -0,0 +1,26 @@ +import { ILogger } from '../../logger/types'; +import { Method } from '../../sync/submitters/types'; +import { KeyBuilderSS } from '../KeyBuilderSS'; +import { IPluggableStorageWrapper, ITelemetryCacheAsync } from '../types'; +import { findLatencyIndex } from '../findLatencyIndex'; + +export class TelemetryCachePluggable implements ITelemetryCacheAsync { + + /** + * Create a Telemetry cache that uses a storage wrapper. + * @param log Logger instance. + * @param keys Key builder. + * @param wrapper Adapted wrapper storage. + */ + constructor(private readonly log: ILogger, private readonly keys: KeyBuilderSS, private readonly wrapper: IPluggableStorageWrapper) { } + + recordLatency(method: Method, latencyMs: number) { + return this.wrapper.incr(this.keys.buildLatencyKey(method, findLatencyIndex(latencyMs))) + .catch(() => { /* Handle rejections for telemetry */ }); + } + recordException(method: Method) { + return this.wrapper.incr(this.keys.buildExceptionKey(method)) + .catch(() => { /* Handle rejections for telemetry */ }); + } + +} diff --git a/src/storages/pluggable/__tests__/TelemetryCachePluggable.spec.ts b/src/storages/pluggable/__tests__/TelemetryCachePluggable.spec.ts new file mode 100644 index 00000000..dd6d3bbb --- /dev/null +++ b/src/storages/pluggable/__tests__/TelemetryCachePluggable.spec.ts @@ -0,0 +1,30 @@ +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { KeyBuilderSS } from '../../KeyBuilderSS'; +import { wrapperMockFactory } from './wrapper.mock'; +import { TelemetryCachePluggable } from '../TelemetryCachePluggable'; +import { fakeMetadata } from './ImpressionsCachePluggable.spec'; + +const prefix = 'telemetry_cache_ut'; +const exceptionKey = `${prefix}.telemetry.exceptions`; +const latencyKey = `${prefix}.telemetry.latencies`; +const fieldVersionablePrefix = `${fakeMetadata.s}/${fakeMetadata.n}/${fakeMetadata.i}`; + +test('TELEMETRY CACHE PLUGGABLE / `recordLatency` and `recordException`', async () => { + + const keysBuilder = new KeyBuilderSS(prefix, fakeMetadata); + const wrapper = wrapperMockFactory(); + const cache = new TelemetryCachePluggable(loggerMock, keysBuilder, wrapper); + + expect(await cache.recordException('tr')).toBe(1); + expect(await cache.recordException('tr')).toBe(2); + + expect(await wrapper.get(exceptionKey + '::' + fieldVersionablePrefix + '/track')).toBe('2'); + expect(await wrapper.get(exceptionKey + '::' + fieldVersionablePrefix + '/treatment')).toBe(null); + + expect(await cache.recordLatency('tr', 1.6)).toBe(1); + expect(await cache.recordLatency('tr', 1.6)).toBe(2); + + expect(await wrapper.get(latencyKey + '::' + fieldVersionablePrefix + '/track/2')).toBe('2'); + expect(await wrapper.get(latencyKey+ '::' + fieldVersionablePrefix + '/treatment/2')).toBe(null); + +}); diff --git a/src/storages/pluggable/index.ts b/src/storages/pluggable/index.ts index e4062132..c0ca4cc3 100644 --- a/src/storages/pluggable/index.ts +++ b/src/storages/pluggable/index.ts @@ -39,6 +39,7 @@ function validatePluggableStorageOptions(options: any) { function wrapperConnect(wrapper: IPluggableStorageWrapper, onReadyCb: (error?: any) => void) { wrapper.connect().then(() => { onReadyCb(); + // At the moment, we don't synchronize config with pluggable storage }).catch((e) => { onReadyCb(e || new Error('Error connecting wrapper')); }); @@ -77,7 +78,8 @@ export function PluggableStorage(options: PluggableStorageOptions): IStorageAsyn impressions: isPartialConsumer ? new ImpressionsCacheInMemory(impressionsQueueSize) : new ImpressionsCachePluggable(log, keys.buildImpressionsKey(), wrapper, metadata), impressionCounts: optimize ? new ImpressionCountsCacheInMemory() : undefined, events: isPartialConsumer ? promisifyEventsTrack(new EventsCacheInMemory(eventsQueueSize)) : new EventsCachePluggable(log, keys.buildEventsKey(), wrapper, metadata), - // @TODO add telemetry cache when required + // @TODO Not using TelemetryCachePluggable yet because it's not supported by the Split Synchronizer, and needs to drop or queue operations while the wrapper is not ready + // telemetry: isPartialConsumer ? new TelemetryCacheInMemory() : new TelemetryCachePluggable(log, keys, wrapper), // Disconnect the underlying storage destroy() { diff --git a/src/storages/types.ts b/src/storages/types.ts index e699561b..ea8616fa 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,6 +1,6 @@ import { MaybeThenable, IMetadata, ISplitFiltersValidation } from '../dtos/types'; import { ILogger } from '../logger/types'; -import { StoredEventWithMetadata, StoredImpressionWithMetadata } from '../sync/submitters/types'; +import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent } from '../sync/submitters/types'; import { SplitIO, ImpressionDTO, SDKMode } from '../types'; /** @@ -262,6 +262,7 @@ export interface ISegmentsCacheSync extends ISegmentsCacheBase { isInSegment(name: string, key?: string): boolean registerSegments(names: string[]): boolean getRegisteredSegments(): string[] + getKeysCount(): number // only used for telemetry setChangeNumber(name: string, changeNumber: number): boolean getChangeNumber(name: string): number resetSegments(names: string[]): boolean // only for Sync Client-Side @@ -331,11 +332,13 @@ export interface IRecorderCacheProducerAsync { export interface IImpressionsCacheAsync extends IImpressionsCacheBase, IRecorderCacheProducerAsync { // Consumer API method, used by impressions tracker (in standalone and consumer modes) to push data into. + // The result promise can reject. track(data: ImpressionDTO[]): Promise } export interface IEventsCacheAsync extends IEventsCacheBase, IRecorderCacheProducerAsync { // Consumer API method, used by events tracker (in standalone and consumer modes) to push data into. + // The result promise cannot reject. track(data: SplitIO.EventData, size?: number): Promise } @@ -354,31 +357,86 @@ export interface IImpressionCountsCacheSync extends IRecorderCacheProducerSync> { - track(metricName: string, latency: number): boolean - isEmpty(): boolean - clear(): void - state(): Record +export interface ITelemetryInitConsumerSync { + getTimeUntilReady(): number | undefined; + getTimeUntilReadyFromCache(): number | undefined; + getNonReadyUsage(): number; + // 'active factories' and 'redundant factories' are not tracked in the storage. They are derived from `usedKeysMap` } -export interface ILatenciesCacheAsync { - track(metricName: string, latency: number): Promise +export interface ITelemetryRuntimeConsumerSync { + getImpressionStats(type: ImpressionDataType): number; + getEventStats(type: EventDataType): number; + getLastSynchronization(): LastSync; + popHttpErrors(): HttpErrors; + popHttpLatencies(): HttpLatencies; + popAuthRejections(): number; + popTokenRefreshes(): number; + popStreamingEvents(): Array; + popTags(): Array | undefined; + getSessionLength(): number | undefined; } -export interface ICountsCacheSync extends IRecorderCacheProducerSync> { - track(metricName: string): boolean - isEmpty(): boolean - clear(): void - state(): Record +export interface ITelemetryEvaluationConsumerSync { + popExceptions(): MethodExceptions; + popLatencies(): MethodLatencies; +} + +export interface ITelemetryStorageConsumerSync extends ITelemetryInitConsumerSync, ITelemetryRuntimeConsumerSync, ITelemetryEvaluationConsumerSync { } + +export interface ITelemetryInitProducerSync { + recordTimeUntilReady(ms: number): void; + recordTimeUntilReadyFromCache(ms: number): void; + recordNonReadyUsage(): void; + // 'active factories' and 'redundant factories' are not tracked in the storage. They are derived from `usedKeysMap` +} + +export interface ITelemetryRuntimeProducerSync { + addTag(tag: string): void; + recordImpressionStats(type: ImpressionDataType, count: number): void; + recordEventStats(type: EventDataType, count: number): void; + recordSuccessfulSync(resource: OperationType, timeMs: number): void; + recordHttpError(resource: OperationType, status: number): void; + recordHttpLatency(resource: OperationType, latencyMs: number): void; + recordAuthRejections(): void; + recordTokenRefreshes(): void; + recordStreamingEvents(streamingEvent: StreamingEvent): void; + recordSessionLength(ms: number): void; } -export interface ICountsCacheAsync { - track(metricName: string): Promise +export interface ITelemetryEvaluationProducerSync { + recordLatency(method: Method, latencyMs: number): void; + recordException(method: Method): void; } +export interface ITelemetryStorageProducerSync extends ITelemetryInitProducerSync, ITelemetryRuntimeProducerSync, ITelemetryEvaluationProducerSync { } + +export interface ITelemetryCacheSync extends ITelemetryStorageConsumerSync, ITelemetryStorageProducerSync { } + +/** + * Telemetry storage interface for consumer mode. + * Methods are async because data is stored in Redis or a pluggable storage. + */ + +export interface ITelemetryEvaluationConsumerAsync { + popExceptions(): Promise; + popLatencies(): Promise; +} + +export interface ITelemetryEvaluationProducerAsync { + recordLatency(method: Method, latencyMs: number): Promise; + recordException(method: Method): Promise; +} + +// ATM it only implements the producer API, used by the SDK in consumer mode. +// @TODO implement consumer API for JS Synchronizer. +export interface ITelemetryCacheAsync extends ITelemetryEvaluationProducerAsync { } + /** * Storages */ @@ -388,37 +446,33 @@ export interface IStorageBase< TSegmentsCache extends ISegmentsCacheBase, TImpressionsCache extends IImpressionsCacheBase, TEventsCache extends IEventsCacheBase, - TLatenciesCache extends ILatenciesCacheSync | ILatenciesCacheAsync, - TCountsCache extends ICountsCacheSync | ICountsCacheAsync, + TTelemetryCache extends ITelemetryCacheSync | ITelemetryCacheAsync > { splits: TSplitsCache, segments: TSegmentsCache, impressions: TImpressionsCache, impressionCounts?: IImpressionCountsCacheSync, events: TEventsCache, - latencies?: TLatenciesCache, - counts?: TCountsCache, + telemetry?: TTelemetryCache destroy(): void | Promise, shared?: (matchingKey: string, onReadyCb: (error?: any) => void) => this } -export type IStorageSync = IStorageBase< +export interface IStorageSync extends IStorageBase< ISplitsCacheSync, ISegmentsCacheSync, IImpressionsCacheSync, IEventsCacheSync, - ILatenciesCacheSync, - ICountsCacheSync -> + ITelemetryCacheSync + > { } -export type IStorageAsync = IStorageBase< +export interface IStorageAsync extends IStorageBase< ISplitsCacheAsync, ISegmentsCacheAsync, IImpressionsCacheAsync | IImpressionsCacheSync, IEventsCacheAsync | IEventsCacheSync, - ILatenciesCacheAsync, - ICountsCacheAsync -> + ITelemetryCacheAsync + > { } /** StorageFactory */ @@ -429,14 +483,12 @@ export interface IStorageFactoryParams { impressionsQueueSize?: number, eventsQueueSize?: number, optimize?: boolean /* whether create the `impressionCounts` cache (OPTIMIZED impression mode) or not (DEBUG impression mode) */, + mode: SDKMode, // ATM, only used by InLocalStorage matchingKey?: string, /* undefined on server-side SDKs */ splitFiltersValidation?: ISplitFiltersValidation, - // ATM, only used by PluggableStorage - mode?: SDKMode, - // This callback is invoked when the storage is ready to be used. Error-first callback style: if an error is passed, // it means that the storge fail to connect and shouldn't be used. // It is meant for emitting SDK_READY event in consumer mode, and for synchronizer to wait before using the storage. diff --git a/src/sync/offline/syncManagerOffline.ts b/src/sync/offline/syncManagerOffline.ts index 253493f2..cd5c435d 100644 --- a/src/sync/offline/syncManagerOffline.ts +++ b/src/sync/offline/syncManagerOffline.ts @@ -1,9 +1,10 @@ -import { ISyncManager, ISyncManagerCS, ISyncManagerFactoryParams } from '../types'; +import { ISyncManager, ISyncManagerCS } from '../types'; import { fromObjectSyncTaskFactory } from './syncTasks/fromObjectSyncTask'; import { objectAssign } from '../../utils/lang/objectAssign'; import { ISplitsParser } from './splitsParser/types'; import { IReadinessManager } from '../../readiness/types'; import { SDK_SEGMENTS_ARRIVED } from '../../readiness/constants'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; function flush() { return Promise.resolve(); @@ -17,7 +18,7 @@ function flush() { */ export function syncManagerOfflineFactory( splitsParserFactory: () => ISplitsParser -): (params: ISyncManagerFactoryParams) => ISyncManagerCS { +): (params: ISdkFactoryContextSync) => ISyncManagerCS { /** * SyncManager factory for modular SDK @@ -26,7 +27,7 @@ export function syncManagerOfflineFactory( settings, readiness, storage, - }: ISyncManagerFactoryParams): ISyncManagerCS { + }: ISdkFactoryContextSync): ISyncManagerCS { return objectAssign( fromObjectSyncTaskFactory(splitsParserFactory(), storage, readiness, settings), diff --git a/src/sync/polling/pollingManagerCS.ts b/src/sync/polling/pollingManagerCS.ts index 364829a5..e8e98142 100644 --- a/src/sync/polling/pollingManagerCS.ts +++ b/src/sync/polling/pollingManagerCS.ts @@ -7,20 +7,20 @@ import { splitsSyncTaskFactory } from './syncTasks/splitsSyncTask'; import { getMatching } from '../../utils/key'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../../readiness/constants'; import { POLLING_SMART_PAUSING, POLLING_START, POLLING_STOP } from '../../logger/constants'; -import { ISyncManagerFactoryParams } from '../types'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; /** * Expose start / stop mechanism for polling data from services. * For client-side API with multiple clients. */ export function pollingManagerCSFactory( - params: ISyncManagerFactoryParams + params: ISdkFactoryContextSync ): IPollingManagerCS { const { splitApi, storage, readiness, settings } = params; const log = settings.log; - const splitsSyncTask: ISplitsSyncTask = splitsSyncTaskFactory(splitApi.fetchSplitChanges, storage, readiness, settings); + const splitsSyncTask: ISplitsSyncTask = splitsSyncTaskFactory(splitApi.fetchSplitChanges, storage, readiness, settings, true); // Map of matching keys to their corresponding MySegmentsSyncTask. const mySegmentsSyncTasks: Record = {}; diff --git a/src/sync/polling/pollingManagerSS.ts b/src/sync/polling/pollingManagerSS.ts index 2c7d3ec9..90f252a4 100644 --- a/src/sync/polling/pollingManagerSS.ts +++ b/src/sync/polling/pollingManagerSS.ts @@ -3,13 +3,13 @@ import { segmentsSyncTaskFactory } from './syncTasks/segmentsSyncTask'; import { IPollingManager, ISegmentsSyncTask, ISplitsSyncTask } from './types'; import { thenable } from '../../utils/promise/thenable'; import { POLLING_START, POLLING_STOP, LOG_PREFIX_SYNC_POLLING } from '../../logger/constants'; -import { ISyncManagerFactoryParams } from '../types'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; /** * Expose start / stop mechanism for pulling data from services. */ export function pollingManagerSSFactory( - params: ISyncManagerFactoryParams + params: ISdkFactoryContextSync ): IPollingManager { const { splitApi, storage, readiness, settings } = params; diff --git a/src/sync/polling/syncTasks/splitsSyncTask.ts b/src/sync/polling/syncTasks/splitsSyncTask.ts index 6807a0b4..a4568d6d 100644 --- a/src/sync/polling/syncTasks/splitsSyncTask.ts +++ b/src/sync/polling/syncTasks/splitsSyncTask.ts @@ -15,6 +15,7 @@ export function splitsSyncTaskFactory( storage: IStorageSync, readiness: IReadinessManager, settings: ISettings, + isClientSide?: boolean ): ISplitsSyncTask { return syncTaskFactory( settings.log, @@ -26,6 +27,7 @@ export function splitsSyncTaskFactory( readiness.splits, settings.startup.requestTimeoutBeforeReady, settings.startup.retriesOnFailureBeforeReady, + isClientSide ), settings.scheduler.featuresRefreshRate, 'splitChangesUpdater', diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index 60cc9b52..a1cb72b6 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -10,6 +10,7 @@ import fetchMock from '../../../../__tests__/testUtils/fetchMock'; import { settingsSplitApi } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; import { EventEmitter } from '../../../../utils/MinEvents'; import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; +import { telemetryTrackerFactory } from '../../../../trackers/telemetryTracker'; const activeSplitWithSegments = { name: 'Split1', @@ -57,7 +58,7 @@ test('splitChangesUpdater / compute splits mutation', () => { test('splitChangesUpdater / factory', (done) => { fetchMock.once('*', { status: 200, body: splitChangesMock1 }); // @ts-ignore - const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => fetchMock, EventEmitter }); + const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => fetchMock, EventEmitter }, telemetryTrackerFactory()); const splitChangesFetcher = splitChangesFetcherFactory(splitApi.fetchSplitChanges); const splitsCache = new SplitsCacheInMemory(); diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 486cbe00..0028309f 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -33,10 +33,6 @@ export function mySegmentsUpdaterFactory( function _promiseDecorator(promise: Promise) { if (startingUp) promise = timeout(requestTimeoutBeforeReady, promise); return promise; - - // @TODO telemetry - // NOTE: We only collect metrics on startup. - // mySegmentsPromise = tracker.start(tracker.TaskNames.MY_SEGMENTS_FETCH, startingUp ? metricCollectors : false, mySegmentsPromise); } // @TODO if allowing pluggable storages, handle async execution diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index 90a1bc06..846bc481 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -1,7 +1,7 @@ import { ISegmentChangesFetcher } from '../fetchers/types'; import { ISegmentsCacheBase } from '../../../storages/types'; import { IReadinessManager } from '../../../readiness/types'; -import { ISegmentChangesResponse, MaybeThenable } from '../../../dtos/types'; +import { MaybeThenable } from '../../../dtos/types'; import { findIndex } from '../../../utils/lang'; import { SDK_SEGMENTS_ARRIVED } from '../../../readiness/constants'; import { ILogger } from '../../../logger/types'; @@ -30,14 +30,6 @@ export function segmentChangesUpdaterFactory( let readyOnAlreadyExistentState = true; - /** telemetry decorator for `segmentChangesFetcher` promise */ - function _promiseDecorator(promise: Promise) { - return promise; - // @TODO handle telemetry? - // const collectMetrics = startingUp || isNode; // If we are on the browser, only collect this metric for first fetch. On node do it always. - // splitsPromise = tracker.start(tracker.TaskNames.SPLITS_FETCH, collectMetrics ? metricCollectors : false, splitsPromise); - } - /** * Segments updater returns a promise that resolves with a `false` boolean value if it fails at least to fetch a segment or synchronize it with the storage. * Thus, a false result doesn't imply that SDK_SEGMENTS_ARRIVED was not emitted. @@ -67,7 +59,7 @@ export function segmentChangesUpdaterFactory( // if fetchOnlyNew flag, avoid processing already fetched segments if (fetchOnlyNew && since !== -1) return -1; - return segmentChangesFetcher(since, segmentName, noCache, _promiseDecorator).then(function (changes) { + return segmentChangesFetcher(since, segmentName, noCache).then(function (changes) { let changeNumber = -1; const results: MaybeThenable[] = []; changes.forEach(x => { diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 822dbf13..aa2ecabc 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -93,18 +93,15 @@ export function splitChangesUpdaterFactory( splitsEventEmitter?: ISplitsEventEmitter, requestTimeoutBeforeReady: number = 0, retriesOnFailureBeforeReady: number = 0, + isClientSide?: boolean ): ISplitChangesUpdater { let startingUp = true; - /** timeout and telemetry decorator for `splitChangesFetcher` promise */ + /** timeout decorator for `splitChangesFetcher` promise */ function _promiseDecorator(promise: Promise) { if (startingUp && requestTimeoutBeforeReady) promise = timeout(requestTimeoutBeforeReady, promise); return promise; - - // @TODO telemetry - // const collectMetrics = startingUp || isNode; // If we are on the browser, only collect this metric for first fetch. On node do it always. - // splitsPromise = tracker.start(tracker.TaskNames.SPLITS_FETCH, collectMetrics ? metricCollectors : false, splitsPromise); } /** @@ -144,7 +141,7 @@ export function splitChangesUpdaterFactory( if (splitsEventEmitter) { // To emit SDK_SPLITS_ARRIVED for server-side SDK, we must check that all registered segments have been fetched - return Promise.resolve(!splitsEventEmitter.splitsArrived || (since !== splitChanges.till && checkAllSegmentsExist(segments))) + return Promise.resolve(!splitsEventEmitter.splitsArrived || (since !== splitChanges.till && (isClientSide || checkAllSegmentsExist(segments)))) .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { // emit SDK events diff --git a/src/sync/streaming/AuthClient/__tests__/index.spec.ts b/src/sync/streaming/AuthClient/__tests__/index.spec.ts index 70604400..d27f5b4d 100644 --- a/src/sync/streaming/AuthClient/__tests__/index.spec.ts +++ b/src/sync/streaming/AuthClient/__tests__/index.spec.ts @@ -4,13 +4,14 @@ import { authDataResponseSample, authDataSample, jwtSampleInvalid, jwtSampleNoCh import fetchMock from '../../../../__tests__/testUtils/fetchMock'; import { settingsSplitApi } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; import { EventEmitter } from '../../../../utils/MinEvents'; +import { telemetryTrackerFactory } from '../../../../trackers/telemetryTracker'; // module to test import { authenticateFactory, hashUserKey } from '../index'; const authorizationKey = settingsSplitApi.core.authorizationKey; const authUrl = settingsSplitApi.urls.auth; // @ts-ignore -const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => fetchMock, EventEmitter }); +const splitApi = splitApiFactory(settingsSplitApi, { getFetch: () => fetchMock, EventEmitter }, telemetryTrackerFactory()); const authenticate = authenticateFactory(splitApi.fetchAuth); test('hashUserKey', () => { diff --git a/src/sync/streaming/SSEHandler/NotificationKeeper.ts b/src/sync/streaming/SSEHandler/NotificationKeeper.ts index 03dae152..a07c6761 100644 --- a/src/sync/streaming/SSEHandler/NotificationKeeper.ts +++ b/src/sync/streaming/SSEHandler/NotificationKeeper.ts @@ -1,7 +1,11 @@ +import { ITelemetryTracker } from '../../../trackers/types'; +import { CONNECTION_ESTABLISHED, DISABLED, ENABLED, OCCUPANCY_PRI, OCCUPANCY_SEC, PAUSED, STREAMING_STATUS } from '../../../utils/constants'; +import { StreamingEventType } from '../../submitters/types'; import { ControlType, PUSH_SUBSYSTEM_UP, PUSH_NONRETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN } from '../constants'; import { IPushEventEmitter } from '../types'; const CONTROL_CHANNEL_REGEXS = [/control_pri$/, /control_sec$/]; +const STREAMING_EVENT_TYPES: StreamingEventType[] = [OCCUPANCY_PRI, OCCUPANCY_SEC]; /** * Factory of notification keeper, which process OCCUPANCY and CONTROL notifications and emits the corresponding push events. @@ -9,7 +13,7 @@ const CONTROL_CHANNEL_REGEXS = [/control_pri$/, /control_sec$/]; * @param pushEmitter emitter for events related to streaming support */ // @TODO update logic to handle OCCUPANCY for any region and rename according to new spec (e.g.: PUSH_SUBSYSTEM_UP --> PUSH_SUBSYSTEM_UP) -export function notificationKeeperFactory(pushEmitter: IPushEventEmitter) { +export function notificationKeeperFactory(pushEmitter: IPushEventEmitter, telemetryTracker: ITelemetryTracker) { let channels = CONTROL_CHANNEL_REGEXS.map(regex => ({ regex, @@ -30,6 +34,7 @@ export function notificationKeeperFactory(pushEmitter: IPushEventEmitter) { return { handleOpen() { + telemetryTracker.streamingEvent(CONNECTION_ESTABLISHED); pushEmitter.emit(PUSH_SUBSYSTEM_UP); }, @@ -41,6 +46,8 @@ export function notificationKeeperFactory(pushEmitter: IPushEventEmitter) { for (let i = 0; i < channels.length; i++) { const c = channels[i]; if (c.regex.test(channel)) { + telemetryTracker.streamingEvent(STREAMING_EVENT_TYPES[i], publishers); + if (timestamp > c.oTime) { c.oTime = timestamp; c.hasPublishers = publishers !== 0; @@ -76,11 +83,14 @@ export function notificationKeeperFactory(pushEmitter: IPushEventEmitter) { if (timestamp > c.cTime) { c.cTime = timestamp; if (controlType === ControlType.STREAMING_DISABLED) { + telemetryTracker.streamingEvent(STREAMING_STATUS, DISABLED); pushEmitter.emit(PUSH_NONRETRYABLE_ERROR); } else if (hasPublishers) { if (controlType === ControlType.STREAMING_PAUSED && hasResumed) { + telemetryTracker.streamingEvent(STREAMING_STATUS, PAUSED); pushEmitter.emit(PUSH_SUBSYSTEM_DOWN); } else if (controlType === ControlType.STREAMING_RESUMED && !hasResumed) { + telemetryTracker.streamingEvent(STREAMING_STATUS, ENABLED); pushEmitter.emit(PUSH_SUBSYSTEM_UP); } // nothing to do when hasPublishers === false: diff --git a/src/sync/streaming/SSEHandler/__tests__/index.spec.ts b/src/sync/streaming/SSEHandler/__tests__/index.spec.ts index fea55027..9651d232 100644 --- a/src/sync/streaming/SSEHandler/__tests__/index.spec.ts +++ b/src/sync/streaming/SSEHandler/__tests__/index.spec.ts @@ -34,12 +34,14 @@ const controlStreamingDisabledSec = { ...controlStreamingDisabled, data: control // streaming reset message, from `{orgHash}_{envHash}_control` channel import streamingReset from '../../../../__tests__/mocks/message.STREAMING_RESET.json'; +import { ABLY_ERROR, NON_REQUESTED, SSE_CONNECTION_ERROR } from '../../../../utils/constants'; const pushEmitter = { emit: jest.fn() }; +const telemetryTracker = { streamingEvent: jest.fn() }; test('`handleOpen` and `handlerMessage` for OCCUPANCY notifications (NotificationKeeper)', () => { pushEmitter.emit.mockClear(); - const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); // handleOpen @@ -86,7 +88,7 @@ test('`handleOpen` and `handlerMessage` for OCCUPANCY notifications (Notificatio test('`handlerMessage` for CONTROL notifications (NotificationKeeper)', () => { pushEmitter.emit.mockClear(); - const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); sseHandler.handleOpen(); // CONTROL messages @@ -119,7 +121,7 @@ test('`handlerMessage` for CONTROL notifications (NotificationKeeper)', () => { sseHandler.handleMessage(controlStreamingDisabledSec); // testing STREAMING_DISABLED with second region expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_NONRETRYABLE_ERROR); // must emit PUSH_NONRETRYABLE_ERROR if received a STREAMING_DISABLED control message - const sseHandler2 = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler2 = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); sseHandler2.handleOpen(); sseHandler2.handleMessage(controlStreamingPausedSec); // testing STREAMING_PAUSED with second region @@ -131,7 +133,7 @@ test('`handlerMessage` for CONTROL notifications (NotificationKeeper)', () => { }); test('`handlerMessage` for update notifications (NotificationProcessor) and streaming reset', () => { - const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); sseHandler.handleOpen(); pushEmitter.emit.mockClear(); @@ -173,38 +175,44 @@ test('`handlerMessage` for update notifications (NotificationProcessor) and stre }); test('handleError', () => { - const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); sseHandler.handleOpen(); pushEmitter.emit.mockClear(); const error = 'some error'; sseHandler.handleError(error); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_RETRYABLE_ERROR); // A network error must emit PUSH_RETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(SSE_CONNECTION_ERROR, NON_REQUESTED); const errorWithData = { data: '{ "message": "error message"}' }; sseHandler.handleError(errorWithData); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_RETRYABLE_ERROR); // An error without Ably code must emit PUSH_RETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(SSE_CONNECTION_ERROR, NON_REQUESTED); const errorWithBadData = { data: '{"message"error"' }; sseHandler.handleError(errorWithBadData); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_RETRYABLE_ERROR); // An error that cannot be parsed must emit PUSH_RETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(SSE_CONNECTION_ERROR, NON_REQUESTED); const ably4XXRecoverableError = { data: '{"message":"Token expired","code":40142,"statusCode":401}' }; sseHandler.handleError(ably4XXRecoverableError); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_RETRYABLE_ERROR); // An Ably recoverable error must emit PUSH_RETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(ABLY_ERROR, 40142); const ably4XXNonRecoverableError = { data: '{"message":"Token expired","code":42910,"statusCode":429}' }; sseHandler.handleError(ably4XXNonRecoverableError); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_NONRETRYABLE_ERROR); // An Ably non-recoverable error must emit PUSH_NONRETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(ABLY_ERROR, 42910); const ably5XXError = { data: '{"message":"...","code":50000,"statusCode":500}' }; sseHandler.handleError(ably5XXError); expect(pushEmitter.emit).toHaveBeenLastCalledWith(PUSH_RETRYABLE_ERROR); // An Ably recoverable error must emit PUSH_RETRYABLE_ERROR + expect(telemetryTracker.streamingEvent).toHaveBeenLastCalledWith(ABLY_ERROR, 50000); }); test('handlerMessage: ignore invalid events', () => { - const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter); + const sseHandler = SSEHandlerFactory(loggerMock, pushEmitter, telemetryTracker); sseHandler.handleOpen(); pushEmitter.emit.mockClear(); diff --git a/src/sync/streaming/SSEHandler/index.ts b/src/sync/streaming/SSEHandler/index.ts index b0b46767..d8f20b32 100644 --- a/src/sync/streaming/SSEHandler/index.ts +++ b/src/sync/streaming/SSEHandler/index.ts @@ -6,18 +6,8 @@ import { ISseEventHandler } from '../SSEClient/types'; import { INotificationError, INotificationMessage } from './types'; import { ILogger } from '../../../logger/types'; import { STREAMING_PARSING_ERROR_FAILS, ERROR_STREAMING_SSE, STREAMING_PARSING_MESSAGE_FAILS, STREAMING_NEW_MESSAGE } from '../../../logger/constants'; - -function isRetryableError(error: INotificationError) { - if (error.parsedData && error.parsedData.code) { - const code = error.parsedData.code; - // 401 errors due to invalid or expired token (e.g., if refresh token coudn't be executed) - if (40140 <= code && code <= 40149) return true; - // Others 4XX errors (e.g., bad request from the SDK) - if (40000 <= code && code <= 49999) return false; - } - // network errors or 5XX HTTP errors - return true; -} +import { ABLY_ERROR, NON_REQUESTED, SSE_CONNECTION_ERROR } from '../../../utils/constants'; +import { ITelemetryTracker } from '../../../trackers/types'; /** * Factory for SSEHandler, which processes SSEClient messages and emits the corresponding push events. @@ -25,9 +15,26 @@ function isRetryableError(error: INotificationError) { * @param log factory logger * @param pushEmitter emitter for events related to streaming support */ -export function SSEHandlerFactory(log: ILogger, pushEmitter: IPushEventEmitter): ISseEventHandler { +export function SSEHandlerFactory(log: ILogger, pushEmitter: IPushEventEmitter, telemetryTracker: ITelemetryTracker): ISseEventHandler { - const notificationKeeper = notificationKeeperFactory(pushEmitter); + const notificationKeeper = notificationKeeperFactory(pushEmitter, telemetryTracker); + + function isRetryableError(error: INotificationError): boolean { + if (error.parsedData && error.parsedData.code) { + // Ably error + const code = error.parsedData.code; + telemetryTracker.streamingEvent(ABLY_ERROR, code); + + // 401 errors due to invalid or expired token (e.g., if refresh token coudn't be executed) + if (40140 <= code && code <= 40149) return true; + // Others 4XX errors (e.g., bad request from the SDK) + if (40000 <= code && code <= 49999) return false; + } else { + // network errors or 5XX HTTP errors + telemetryTracker.streamingEvent(SSE_CONNECTION_ERROR, NON_REQUESTED); + } + return true; + } return { handleOpen() { diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index ec5ff916..9392401b 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -18,7 +18,8 @@ import { isInBitmap, parseBitmap, parseKeyList } from './mySegmentsV2utils'; import { ISet, _Set } from '../../utils/lang/sets'; import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64'; import { IAuthTokenPushEnabled } from './AuthClient/types'; -import { ISyncManagerFactoryParams } from '../types'; +import { TOKEN_REFRESH, AUTH_REJECTION } from '../../utils/constants'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; /** * PushManager factory: @@ -26,11 +27,11 @@ import { ISyncManagerFactoryParams } from '../types'; * - for client-side, with support for multiple clients, if key is provided in settings */ export function pushManagerFactory( - params: ISyncManagerFactoryParams, + params: ISdkFactoryContextSync, pollingManager: IPollingManager, ): IPushManager | undefined { - const { settings, storage, splitApi, readiness, platform } = params; + const { settings, storage, splitApi, readiness, platform, telemetryTracker } = params; // `userKey` is the matching key of main client in client-side SDK. // It can be used to check if running on client-side or server-side SDK. @@ -49,7 +50,7 @@ export function pushManagerFactory( // init feedback loop const pushEmitter = new platform.EventEmitter() as IPushEventEmitter; - const sseHandler = SSEHandlerFactory(log, pushEmitter); + const sseHandler = SSEHandlerFactory(log, pushEmitter, telemetryTracker); sseClient.setEventHandler(sseHandler); // init workers @@ -101,6 +102,8 @@ export function pushManagerFactory( if (disconnected) return; sseClient.open(authData); }, connDelay * 1000); + + telemetryTracker.streamingEvent(TOKEN_REFRESH, decodedToken.exp); } function connectPush() { @@ -137,6 +140,7 @@ export function pushManagerFactory( // Handle 4XX HTTP errors: 401 (invalid API Key) or 400 (using incorrect API Key, i.e., client-side API Key on server-side) if (error.statusCode >= 400 && error.statusCode < 500) { + telemetryTracker.streamingEvent(AUTH_REJECTION); pushEmitter.emit(PUSH_NONRETRYABLE_ERROR); return; } @@ -179,7 +183,7 @@ export function pushManagerFactory( stopWorkers(); }); - /** Fallbacking without retry due to: STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE and Authentication errors */ + /** Fallback to polling without retry due to: STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE and Authentication errors */ pushEmitter.on(PUSH_NONRETRYABLE_ERROR, function handleNonRetryableError() { disabled = true; @@ -188,7 +192,7 @@ export function pushManagerFactory( pushEmitter.emit(PUSH_SUBSYSTEM_DOWN); // no harm if polling already }); - /** Fallbacking with retry due to recoverable SSE and Authentication errors */ + /** Fallback to polling with retry due to recoverable SSE and Authentication errors */ pushEmitter.on(PUSH_RETRYABLE_ERROR, function handleRetryableError() { // HTTP or network error in SSE connection // SSE connection is closed to avoid repeated errors due to retries @@ -316,7 +320,7 @@ export function pushManagerFactory( }, // true/false if start or stop was called last respectively - isRunning(){ + isRunning() { return disconnected === false; }, diff --git a/src/sync/submitters/__tests__/eventsSyncTask.spec.ts b/src/sync/submitters/__tests__/eventsSubmitter.spec.ts similarity index 73% rename from src/sync/submitters/__tests__/eventsSyncTask.spec.ts rename to src/sync/submitters/__tests__/eventsSubmitter.spec.ts index 59b647c1..16b26bc1 100644 --- a/src/sync/submitters/__tests__/eventsSyncTask.spec.ts +++ b/src/sync/submitters/__tests__/eventsSubmitter.spec.ts @@ -1,24 +1,33 @@ -import { eventsSyncTaskFactory } from '../eventsSyncTask'; +import { eventsSubmitterFactory } from '../eventsSubmitter'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -describe('Events submitter (eventsSyncTask)', () => { +describe('Events submitter', () => { let __onFullQueueCb: () => void; - const postEventsBulkMock = jest.fn(); const eventsCacheMock = { isEmpty: jest.fn(() => true), setOnFullQueueCb: jest.fn(function (onFullQueueCb) { __onFullQueueCb = onFullQueueCb; }) }; + const params = { + settings: { + log: loggerMock, + scheduler: { eventsPushRate: 30000 }, + startup: { eventsFirstPushWindow: 0 } + }, + splitApi: { postEventsBulkMock: jest.fn() }, + storage: { events: eventsCacheMock } + }; beforeEach(() => { eventsCacheMock.isEmpty.mockClear(); }); test('with eventsFirstPushWindow', async () => { - const eventsFirstPushWindow = 20; // @ts-ignore - const eventsSubmitter = eventsSyncTaskFactory(loggerMock, postEventsBulkMock, eventsCacheMock, 30000, eventsFirstPushWindow); + const eventsFirstPushWindow = 20; + params.settings.startup.eventsFirstPushWindow = eventsFirstPushWindow; // @ts-ignore + const eventsSubmitter = eventsSubmitterFactory(params); eventsSubmitter.start(); expect(eventsSubmitter.isRunning()).toEqual(true); // Submitter should be flagged as running @@ -40,8 +49,9 @@ describe('Events submitter (eventsSyncTask)', () => { }); test('without eventsFirstPushWindow', async () => { - // @ts-ignore - const eventsSubmitter = eventsSyncTaskFactory(loggerMock, postEventsBulkMock, eventsCacheMock, 30000); + const eventsFirstPushWindow = 0; + params.settings.startup.eventsFirstPushWindow = eventsFirstPushWindow; // @ts-ignore + const eventsSubmitter = eventsSubmitterFactory(params); eventsSubmitter.start(); expect(eventsSubmitter.isRunning()).toEqual(true); // Submitter should be flagged as running diff --git a/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts b/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts new file mode 100644 index 00000000..bbe74a59 --- /dev/null +++ b/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts @@ -0,0 +1,71 @@ +import { telemetrySubmitterFactory } from '../telemetrySubmitter'; +import { InMemoryStorageFactory } from '../../../storages/inMemory/InMemoryStorage'; +import { SDK_READY, SDK_READY_FROM_CACHE } from '../../../readiness/constants'; +import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { ISyncTask } from '../../types'; + +describe('Telemetry submitter', () => { + + const telemetryRefreshRate = 100; // 100 ms + const postMetricsUsage = jest.fn(() => Promise.resolve()); + const postMetricsConfig = jest.fn(() => Promise.resolve()); + const readinessGateCallbacks: Record void> = {}; + const params = { + settings: { ...fullSettings, scheduler: { ...fullSettings.scheduler, telemetryRefreshRate } }, + splitApi: { postMetricsUsage, postMetricsConfig }, // @ts-ignore + storage: InMemoryStorageFactory({}), + platform: { now: () => 123 }, // by returning a fixed timestamp, all latencies are equal to 0 + sdkReadinessManager: { incInternalReadyCbCount: jest.fn(), }, + readiness: { + gate: { + once: jest.fn((e: string, cb: () => void) => { + readinessGateCallbacks[e] = cb; + }) + } + } + }; + + test('submits metrics/usage periodically', async () => { // @ts-ignore + const telemetrySubmitter = telemetrySubmitterFactory(params) as ISyncTask; + const popLatenciesSpy = jest.spyOn(params.storage.telemetry!, 'popLatencies'); + + telemetrySubmitter.start(); + expect(telemetrySubmitter.isRunning()).toEqual(true); // Submitter should be flagged as running + expect(telemetrySubmitter.isExecuting()).toEqual(false); // but not executed immediatelly (first push window) + expect(popLatenciesSpy).toBeCalledTimes(0); + + // Await first push + await new Promise(res => setTimeout(res, params.settings.scheduler.telemetryRefreshRate + 10)); + // after the first push, telemetry cache should have been used to create the request payload + expect(popLatenciesSpy).toBeCalledTimes(1); + expect(postMetricsUsage).toBeCalledWith(JSON.stringify({ + lS: {}, mL: {}, mE: {}, hE: {}, hL: {}, tR: 0, aR: 0, iQ: 0, iDe: 0, iDr: 0, spC: 0, seC: 0, skC: 0, eQ: 0, eD: 0, sE: [], t: [] + })); + + expect(telemetrySubmitter.isRunning()).toEqual(true); + telemetrySubmitter.stop(); + expect(telemetrySubmitter.isRunning()).toEqual(false); + }); + + test('submits metrics/config when SDK is ready', async () => { // @ts-ignore + const telemetrySubmitter = telemetrySubmitterFactory(params) as ISyncTask; + const recordTimeUntilReadyFromCacheSpy = jest.spyOn(params.storage.telemetry!, 'recordTimeUntilReadyFromCache'); + const recordTimeUntilReadySpy = jest.spyOn(params.storage.telemetry!, 'recordTimeUntilReady'); + + telemetrySubmitter.start(); + + readinessGateCallbacks[SDK_READY_FROM_CACHE](); + expect(recordTimeUntilReadyFromCacheSpy).toBeCalledTimes(1); + + readinessGateCallbacks[SDK_READY](); + expect(recordTimeUntilReadySpy).toBeCalledTimes(1); + + expect(postMetricsConfig).toBeCalledWith(JSON.stringify({ + oM: 0, st: 'memory', aF: 0, rF: 0, sE: true, rR: { sp: 0.001, ms: 0.001, im: 0.001, ev: 0.001, te: 0.1 }, uO: { s: true, e: true, a: true, st: true, t: true }, iQ: 1, eQ: 1, iM: 0, iL: false, hP: false, tR: 0, tC: 0, nR: 0, t: [], i: ['NoopIntegration'], uC: 0 + })); + + // Stop submitter, to not execute the 1st periodic metrics/usage POST + telemetrySubmitter.stop(); + }); + +}); diff --git a/src/sync/submitters/eventsSubmitter.ts b/src/sync/submitters/eventsSubmitter.ts new file mode 100644 index 00000000..998b7aba --- /dev/null +++ b/src/sync/submitters/eventsSubmitter.ts @@ -0,0 +1,35 @@ +import { submitterFactory, firstPushWindowDecorator } from './submitter'; +import { SUBMITTERS_PUSH_FULL_QUEUE } from '../../logger/constants'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; + +const DATA_NAME = 'events'; + +/** + * Submitter that periodically posts tracked events + */ +export function eventsSubmitterFactory(params: ISdkFactoryContextSync) { + + const { + settings: { log, scheduler: { eventsPushRate }, startup: { eventsFirstPushWindow } }, + splitApi: { postEventsBulk }, + storage: { events }, + } = params; + + // don't retry events. + let submitter = submitterFactory(log, postEventsBulk, events, eventsPushRate, DATA_NAME); + + // Set a timer for the first push window of events. + if (eventsFirstPushWindow > 0) submitter = firstPushWindowDecorator(submitter, eventsFirstPushWindow); + + // register events submitter to be executed when events cache is full + events.setOnFullQueueCb(() => { + if (submitter.isRunning()) { + log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); + submitter.execute(); + } + // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. + // Data will be sent when submitter is resumed. + }); + + return submitter; +} diff --git a/src/sync/submitters/eventsSyncTask.ts b/src/sync/submitters/eventsSyncTask.ts deleted file mode 100644 index 7c84374b..00000000 --- a/src/sync/submitters/eventsSyncTask.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { IEventsCacheSync } from '../../storages/types'; -import { IPostEventsBulk } from '../../services/types'; -import { ISyncTask, ITimeTracker } from '../types'; -import { submitterSyncTaskFactory } from './submitterSyncTask'; -import { ILogger } from '../../logger/types'; -import { SUBMITTERS_PUSH_FULL_QUEUE } from '../../logger/constants'; - -const DATA_NAME = 'events'; - -/** - * Sync task that periodically posts tracked events - */ -export function eventsSyncTaskFactory( - log: ILogger, - postEventsBulk: IPostEventsBulk, - eventsCache: IEventsCacheSync, - eventsPushRate: number, - eventsFirstPushWindow: number, - latencyTracker?: ITimeTracker -): ISyncTask { - - // don't retry events. - const syncTask = submitterSyncTaskFactory(log, postEventsBulk, eventsCache, eventsPushRate, DATA_NAME, latencyTracker); - - // Set a timer for the first push window of events. - // Not implemented in the base submitter or sync task, since this feature is only used by the events submitter. - if (eventsFirstPushWindow > 0) { - let running = false; - let stopEventPublisherTimeout: ReturnType; - const originalStart = syncTask.start; - syncTask.start = () => { - running = true; - stopEventPublisherTimeout = setTimeout(originalStart, eventsFirstPushWindow); - }; - const originalStop = syncTask.stop; - syncTask.stop = () => { - running = false; - clearTimeout(stopEventPublisherTimeout); - originalStop(); - }; - syncTask.isRunning = () => { - return running; - }; - } - - // register events submitter to be executed when events cache is full - eventsCache.setOnFullQueueCb(() => { - if (syncTask.isRunning()) { - log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); - syncTask.execute(); - } - // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. - // Data will be sent when submitter is resumed. - }); - - return syncTask; -} diff --git a/src/sync/submitters/impressionCountsSyncTask.ts b/src/sync/submitters/impressionCountsSubmitter.ts similarity index 50% rename from src/sync/submitters/impressionCountsSyncTask.ts rename to src/sync/submitters/impressionCountsSubmitter.ts index a24ea41e..48131021 100644 --- a/src/sync/submitters/impressionCountsSyncTask.ts +++ b/src/sync/submitters/impressionCountsSubmitter.ts @@ -1,9 +1,6 @@ -import { ISyncTask, ITimeTracker } from '../types'; -import { IPostTestImpressionsCount } from '../../services/types'; -import { IImpressionCountsCacheSync } from '../../storages/types'; -import { submitterSyncTaskFactory } from './submitterSyncTask'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; +import { submitterFactory } from './submitter'; import { ImpressionCountsPayload } from './types'; -import { ILogger } from '../../logger/types'; /** * Converts `impressionCounts` data from cache into request payload. @@ -32,15 +29,18 @@ export function fromImpressionCountsCollector(impressionsCount: Record { + impressions.setOnFullQueueCb(() => { if (syncTask.isRunning()) { log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); syncTask.execute(); diff --git a/src/sync/submitters/metricsSyncTask.ts b/src/sync/submitters/metricsSyncTask.ts deleted file mode 100644 index a5264b14..00000000 --- a/src/sync/submitters/metricsSyncTask.ts +++ /dev/null @@ -1,49 +0,0 @@ - -import { forOwn } from '../../utils/lang'; -import { ICountsCacheSync, ILatenciesCacheSync } from '../../storages/types'; -import { IPostMetricsCounters, IPostMetricsTimes } from '../../services/types'; -import { ISyncTask, ITimeTracker } from '../types'; -import { submitterSyncTaskFactory } from './submitterSyncTask'; -import { ILogger } from '../../logger/types'; - -// extract POST payload object from cache -function fromCache(propertyName: 'latencies' | 'delta') { - return (data: Record): any[] => { - const result: any[] = []; - - forOwn(data, (value, key) => { - result.push({ name: key, [propertyName]: value }); - }); - - return result; - }; -} - -/** - * Sync task that periodically posts telemetry counts - */ -export function countsSyncTaskFactory( - log: ILogger, - postMetricsCounters: IPostMetricsCounters, - countsCache: ICountsCacheSync, - metricsRefreshRate: number, - latencyTracker?: ITimeTracker -): ISyncTask { - - return submitterSyncTaskFactory(log, postMetricsCounters, countsCache, metricsRefreshRate, 'count metrics', latencyTracker, fromCache('delta')); -} - -/** - * Sync task that periodically posts telemetry latencies - */ -export function latenciesSyncTaskFactory( - log: ILogger, - postMetricsLatencies: IPostMetricsTimes, - latenciesCache: ILatenciesCacheSync, - metricsRefreshRate: number, - latencyTracker?: ITimeTracker -): ISyncTask { - - // don't retry metrics. - return submitterSyncTaskFactory(log, postMetricsLatencies, latenciesCache, metricsRefreshRate, 'latency metrics', latencyTracker, fromCache('latencies'), 0, true); -} diff --git a/src/sync/submitters/submitterSyncTask.ts b/src/sync/submitters/submitter.ts similarity index 50% rename from src/sync/submitters/submitterSyncTask.ts rename to src/sync/submitters/submitter.ts index a17df77d..a1312c7d 100644 --- a/src/sync/submitters/submitterSyncTask.ts +++ b/src/sync/submitters/submitter.ts @@ -1,23 +1,22 @@ import { syncTaskFactory } from '../syncTask'; -import { ISyncTask, ITimeTracker } from '../types'; +import { ISyncTask } from '../types'; import { IRecorderCacheProducerSync } from '../../storages/types'; import { ILogger } from '../../logger/types'; import { SUBMITTERS_PUSH, SUBMITTERS_PUSH_FAILS, SUBMITTERS_PUSH_RETRY } from '../../logger/constants'; import { IResponse } from '../../services/types'; /** - * Base function to create submitter sync tasks, such as ImpressionsSyncTask and EventsSyncTask + * Base function to create submitters, such as ImpressionsSubmitter and EventsSubmitter */ -export function submitterSyncTaskFactory( +export function submitterFactory( log: ILogger, postClient: (body: string) => Promise, sourceCache: IRecorderCacheProducerSync, postRate: number, dataName: string, - latencyTracker?: ITimeTracker, fromCacheToPayload?: (cacheData: TState) => any, maxRetries: number = 0, - debugLogs?: boolean + debugLogs?: boolean // true for telemetry submitters ): ISyncTask<[], void> { let retries = 0; @@ -26,33 +25,52 @@ export function submitterSyncTaskFactory( if (sourceCache.isEmpty()) return Promise.resolve(); const data = sourceCache.state(); - - const dataCount: number | '' = typeof data.length === 'number' ? data.length : ''; - log[debugLogs ? 'debug' : 'info'](SUBMITTERS_PUSH, [dataCount, dataName]); - const latencyTrackerStop = latencyTracker && latencyTracker.start(); + // @ts-ignore + const dataCountMessage = typeof data.length === 'number' ? `${data.length} ${dataName}` : dataName; + log[debugLogs ? 'debug' : 'info'](SUBMITTERS_PUSH, [dataCountMessage]); const jsonPayload = JSON.stringify(fromCacheToPayload ? fromCacheToPayload(data) : data); if (!maxRetries) sourceCache.clear(); - const postPromise = postClient(jsonPayload).then(() => { + return postClient(jsonPayload).then(() => { retries = 0; sourceCache.clear(); // we clear the queue if request successes. }).catch(err => { if (!maxRetries) { - log.warn(SUBMITTERS_PUSH_FAILS, [dataCount, dataName, err]); + log[debugLogs ? 'debug' : 'warn'](SUBMITTERS_PUSH_FAILS, [dataCountMessage, err]); } else if (retries === maxRetries) { retries = 0; sourceCache.clear(); // we clear the queue if request fails after retries. - log.warn(SUBMITTERS_PUSH_FAILS, [dataCount, dataName, err]); + log[debugLogs ? 'debug' : 'warn'](SUBMITTERS_PUSH_FAILS, [dataCountMessage, err]); } else { retries++; - log.warn(SUBMITTERS_PUSH_RETRY, [dataCount, dataName, err]); + log[debugLogs ? 'debug' : 'warn'](SUBMITTERS_PUSH_RETRY, [dataCountMessage, err]); } }); - - // if latencyTracker provided, attach stop callback to postEventsPromise - return latencyTrackerStop ? postPromise.then(latencyTrackerStop).catch(latencyTrackerStop) : postPromise; } return syncTaskFactory(log, postData, postRate, dataName + ' submitter'); } + +/** + * Decorates a provided submitter with a first execution window + */ +export function firstPushWindowDecorator(submitter: ISyncTask, firstPushWindow: number) { + let running = false; + let stopEventPublisherTimeout: ReturnType; + const originalStart = submitter.start; + submitter.start = () => { + running = true; + stopEventPublisherTimeout = setTimeout(originalStart, firstPushWindow); + }; + const originalStop = submitter.stop; + submitter.stop = () => { + running = false; + clearTimeout(stopEventPublisherTimeout); + originalStop(); + }; + submitter.isRunning = () => { + return running; + }; + return submitter; +} diff --git a/src/sync/submitters/submitterManager.ts b/src/sync/submitters/submitterManager.ts index 51440a25..523e5ab5 100644 --- a/src/sync/submitters/submitterManager.ts +++ b/src/sync/submitters/submitterManager.ts @@ -1,18 +1,21 @@ import { syncTaskComposite } from '../syncTaskComposite'; -import { eventsSyncTaskFactory } from './eventsSyncTask'; -import { impressionsSyncTaskFactory } from './impressionsSyncTask'; -import { impressionCountsSyncTaskFactory } from './impressionCountsSyncTask'; -import { ISyncManagerFactoryParams } from '../types'; +import { eventsSubmitterFactory } from './eventsSubmitter'; +import { impressionsSubmitterFactory } from './impressionsSubmitter'; +import { impressionCountsSubmitterFactory } from './impressionCountsSubmitter'; +import { telemetrySubmitterFactory } from './telemetrySubmitter'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; -export function submitterManagerFactory(params: ISyncManagerFactoryParams) { +export function submitterManagerFactory(params: ISdkFactoryContextSync) { - const { settings, storage, splitApi } = params; - const log = settings.log; const submitters = [ - impressionsSyncTaskFactory(log, splitApi.postTestImpressionsBulk, storage.impressions, settings.scheduler.impressionsRefreshRate, settings.core.labelsEnabled), - eventsSyncTaskFactory(log, splitApi.postEventsBulk, storage.events, settings.scheduler.eventsPushRate, settings.startup.eventsFirstPushWindow) - // @TODO add telemetry submitter + impressionsSubmitterFactory(params), + eventsSubmitterFactory(params) ]; - if (storage.impressionCounts) submitters.push(impressionCountsSyncTaskFactory(log, splitApi.postTestImpressionsCount, storage.impressionCounts)); + + const impressionCountsSubmitter = impressionCountsSubmitterFactory(params); + if (impressionCountsSubmitter) submitters.push(impressionCountsSubmitter); + const telemetrySubmitter = telemetrySubmitterFactory(params); + if (telemetrySubmitter) submitters.push(telemetrySubmitter); + return syncTaskComposite(submitters); } diff --git a/src/sync/submitters/telemetrySubmitter.ts b/src/sync/submitters/telemetrySubmitter.ts new file mode 100644 index 00000000..8e208e59 --- /dev/null +++ b/src/sync/submitters/telemetrySubmitter.ts @@ -0,0 +1,159 @@ +import { ISegmentsCacheSync, ISplitsCacheSync, ITelemetryCacheSync } from '../../storages/types'; +import { submitterFactory, firstPushWindowDecorator } from './submitter'; +import { TelemetryUsageStatsPayload, TelemetryConfigStatsPayload, TelemetryConfigStats } from './types'; +import { QUEUED, DEDUPED, DROPPED, CONSUMER_MODE, CONSUMER_ENUM, STANDALONE_MODE, CONSUMER_PARTIAL_MODE, STANDALONE_ENUM, CONSUMER_PARTIAL_ENUM, OPTIMIZED, DEBUG, DEBUG_ENUM, OPTIMIZED_ENUM, CONSENT_GRANTED, CONSENT_DECLINED, CONSENT_UNKNOWN } from '../../utils/constants'; +import { SDK_READY, SDK_READY_FROM_CACHE } from '../../readiness/constants'; +import { ConsentStatus, ISettings, SDKMode } from '../../types'; +import { base } from '../../utils/settingsValidation'; +import { usedKeysMap } from '../../utils/inputValidation/apiKey'; +import { timer } from '../../utils/timeTracker/timer'; +import { ISdkFactoryContextSync } from '../../sdkFactory/types'; +import { objectAssign } from '../../utils/lang/objectAssign'; + +/** + * Converts data from telemetry cache into /metrics/usage request payload. + */ +export function telemetryCacheStatsAdapter(telemetry: ITelemetryCacheSync, splits: ISplitsCacheSync, segments: ISegmentsCacheSync) { + return { + isEmpty() { return false; }, // There is always data in telemetry cache + clear() { }, // No-op + + // @TODO consider moving inside telemetry cache for code size reduction + state(): TelemetryUsageStatsPayload { + return { + lS: telemetry.getLastSynchronization(), + mL: telemetry.popLatencies(), + mE: telemetry.popExceptions(), + hE: telemetry.popHttpErrors(), + hL: telemetry.popHttpLatencies(), + tR: telemetry.popTokenRefreshes(), + aR: telemetry.popAuthRejections(), + iQ: telemetry.getImpressionStats(QUEUED), + iDe: telemetry.getImpressionStats(DEDUPED), + iDr: telemetry.getImpressionStats(DROPPED), + spC: splits.getSplitNames().length, + seC: segments.getRegisteredSegments().length, + skC: segments.getKeysCount(), + sL: telemetry.getSessionLength(), + eQ: telemetry.getEventStats(QUEUED), + eD: telemetry.getEventStats(DROPPED), + sE: telemetry.popStreamingEvents(), + t: telemetry.popTags(), + }; + } + }; +} + +const OPERATION_MODE_MAP = { + [STANDALONE_MODE]: STANDALONE_ENUM, + [CONSUMER_MODE]: CONSUMER_ENUM, + [CONSUMER_PARTIAL_MODE]: CONSUMER_PARTIAL_ENUM +} as Record; + +const IMPRESSIONS_MODE_MAP = { + [OPTIMIZED]: OPTIMIZED_ENUM, + [DEBUG]: DEBUG_ENUM +} as Record; + +const USER_CONSENT_MAP = { + [CONSENT_UNKNOWN]: 1, + [CONSENT_GRANTED]: 2, + [CONSENT_DECLINED]: 3 +} as Record; + +function getActiveFactories() { + return Object.keys(usedKeysMap).length; +} + +function getRedundantActiveFactories() { + return Object.keys(usedKeysMap).reduce((acum, apiKey) => { + return acum + usedKeysMap[apiKey] - 1; + }, 0); +} + +export function getTelemetryConfigStats(mode: SDKMode, storageType: string): TelemetryConfigStats { + return { + oM: OPERATION_MODE_MAP[mode], // @ts-ignore lower case of storage type + st: storageType.toLowerCase(), + aF: getActiveFactories(), + rF: getRedundantActiveFactories(), + }; +} + +/** + * Converts data from telemetry cache and settings into /metrics/config request payload. + */ +export function telemetryCacheConfigAdapter(telemetry: ITelemetryCacheSync, settings: ISettings) { + return { + isEmpty() { return false; }, + clear() { }, + + state(): TelemetryConfigStatsPayload { + const { urls, scheduler } = settings; + const isClientSide = settings.core.key !== undefined; + + return objectAssign(getTelemetryConfigStats(settings.mode, settings.storage.type), { + sE: settings.streamingEnabled, + rR: { + sp: scheduler.featuresRefreshRate / 1000, + se: isClientSide ? undefined : scheduler.segmentsRefreshRate / 1000, + ms: isClientSide ? scheduler.segmentsRefreshRate / 1000 : undefined, + im: scheduler.impressionsRefreshRate / 1000, + ev: scheduler.eventsPushRate / 1000, + te: scheduler.telemetryRefreshRate / 1000, + }, // refreshRates + uO: { + s: urls.sdk !== base.urls.sdk, + e: urls.events !== base.urls.events, + a: urls.auth !== base.urls.auth, + st: urls.streaming !== base.urls.streaming, + t: urls.telemetry !== base.urls.telemetry, + }, // urlOverrides + iQ: scheduler.impressionsQueueSize, + eQ: scheduler.eventsQueueSize, + iM: IMPRESSIONS_MODE_MAP[settings.sync.impressionsMode], + iL: settings.impressionListener ? true : false, + hP: false, // @TODO proxy not supported + tR: telemetry.getTimeUntilReady() as number, + tC: telemetry.getTimeUntilReadyFromCache(), + nR: telemetry.getNonReadyUsage(), + t: telemetry.popTags(), + i: settings.integrations && settings.integrations.map(int => int.type), + uC: settings.userConsent ? USER_CONSENT_MAP[settings.userConsent] : 0 + }); + } + }; +} + +/** + * Submitter that periodically posts telemetry data + */ +export function telemetrySubmitterFactory(params: ISdkFactoryContextSync) { + const { storage: { splits, segments, telemetry }, platform: { now } } = params; + if (!telemetry || !now) return; // No submitter created if telemetry cache is not defined + + const { settings, settings: { log, scheduler: { telemetryRefreshRate } }, splitApi, readiness, sdkReadinessManager } = params; + const startTime = timer(now); + + const submitter = firstPushWindowDecorator( + submitterFactory(log, splitApi.postMetricsUsage, telemetryCacheStatsAdapter(telemetry, splits, segments), telemetryRefreshRate, 'telemetry stats', undefined, 0, true), + telemetryRefreshRate + ); + + readiness.gate.once(SDK_READY_FROM_CACHE, () => { + telemetry.recordTimeUntilReadyFromCache(startTime()); + }); + + sdkReadinessManager.incInternalReadyCbCount(); + readiness.gate.once(SDK_READY, () => { + telemetry.recordTimeUntilReady(startTime()); + + // Post config data when the SDK is ready and if the telemetry submitter was started + if (submitter.isRunning()) { + const postMetricsConfigTask = submitterFactory(log, splitApi.postMetricsConfig, telemetryCacheConfigAdapter(telemetry, settings), 0, 'telemetry config', undefined, 0, true); + postMetricsConfigTask.execute(); + } + }); + + return submitter; +} diff --git a/src/sync/submitters/types.ts b/src/sync/submitters/types.ts index 6ab697c1..f4eb8c7b 100644 --- a/src/sync/submitters/types.ts +++ b/src/sync/submitters/types.ts @@ -62,3 +62,132 @@ export type StoredEventWithMetadata = { /** Stored event */ e: SplitIO.EventData } + +/** + * Telemetry usage stats + */ + +export type QUEUED = 0; +export type DROPPED = 1; +export type DEDUPED = 2; +export type ImpressionDataType = QUEUED | DROPPED | DEDUPED +export type EventDataType = QUEUED | DROPPED; + +export type SPLITS = 'sp'; +export type IMPRESSIONS = 'im'; +export type IMPRESSIONS_COUNT = 'ic'; +export type EVENTS = 'ev'; +export type TELEMETRY = 'te'; +export type TOKEN = 'to'; +export type SEGMENT = 'se'; +export type MY_SEGMENT = 'ms'; +export type OperationType = SPLITS | IMPRESSIONS | IMPRESSIONS_COUNT | EVENTS | TELEMETRY | TOKEN | SEGMENT | MY_SEGMENT; + +export type LastSync = Record +export type HttpErrors = Record +export type HttpLatencies = Record> + +export type TREATMENT = 't'; +export type TREATMENTS = 'ts'; +export type TREATMENT_WITH_CONFIG = 'tc'; +export type TREATMENTS_WITH_CONFIG = 'tcs'; +export type TRACK = 'tr'; +export type Method = TREATMENT | TREATMENTS | TREATMENT_WITH_CONFIG | TREATMENTS_WITH_CONFIG | TRACK; + +export type MethodLatencies = Record>; + +export type MethodExceptions = Record; + +export type CONNECTION_ESTABLISHED = 0; +export type OCCUPANCY_PRI = 10; +export type OCCUPANCY_SEC = 20; +export type STREAMING_STATUS = 30; +export type SSE_CONNECTION_ERROR = 40; +export type TOKEN_REFRESH = 50; +export type ABLY_ERROR = 60; +export type SYNC_MODE_UPDATE = 70; +export type StreamingEventType = CONNECTION_ESTABLISHED | OCCUPANCY_PRI | OCCUPANCY_SEC | STREAMING_STATUS | SSE_CONNECTION_ERROR | TOKEN_REFRESH | ABLY_ERROR | SYNC_MODE_UPDATE; + +export type StreamingEvent = { + e: StreamingEventType, // eventType + d?: number, // eventData + t: number, // timestamp +} + +// 'metrics/usage' JSON request body +export type TelemetryUsageStatsPayload = { + lS: LastSync, // lastSynchronization + mL: MethodLatencies, // clientMethodLatencies + mE: MethodExceptions, // methodExceptions + hE: HttpErrors, // httpErrors + hL: HttpLatencies, // httpLatencies + tR: number, // tokenRefreshes + aR: number, // authRejections + iQ: number, // impressionsQueued + iDe: number, // impressionsDeduped + iDr: number, // impressionsDropped + spC: number, // splitCount + seC: number, // segmentCount + skC: number, // segmentKeyCount + sL?: number, // sessionLengthMs + eQ: number, // eventsQueued + eD: number, // eventsDropped + sE: Array, // streamingEvents + t?: Array, // tags +} + +/** + * Telemetry config stats + */ + +export type STANDALONE_ENUM = 0; +export type CONSUMER_ENUM = 1; +export type CONSUMER_PARTIAL_ENUM = 2; +export type OperationMode = STANDALONE_ENUM | CONSUMER_ENUM | CONSUMER_PARTIAL_ENUM + +export type OPTIMIZED_ENUM = 0; +export type DEBUG_ENUM = 1; +export type ImpressionsMode = OPTIMIZED_ENUM | DEBUG_ENUM; + +export type RefreshRates = { + sp: number, // splits + se?: number, // segments + ms?: number, // mySegments + im: number, // impressions + ev: number, // events + te: number, // telemetry +} + +export type UrlOverrides = { + s: boolean, // sdkUrl + e: boolean, // events + a: boolean, // auth + st: boolean, // stream + t: boolean, // telemetry +} + +// 'telemetry.init' Redis/Pluggable key +export type TelemetryConfigStats = { + oM: OperationMode, // operationMode + st: 'memory' | 'redis' | 'pluggable' | 'localstorage', // storage + aF: number, // activeFactories + rF: number, // redundantActiveFactories + t?: Array, // tags +} + +// 'metrics/config' JSON request body +export type TelemetryConfigStatsPayload = TelemetryConfigStats & { + sE: boolean, // streamingEnabled + rR: RefreshRates, // refreshRates + uO: UrlOverrides, // urlOverrides + iQ: number, // impressionsQueueSize + eQ: number, // eventsQueueSize + iM: ImpressionsMode, // impressionsMode + iL: boolean, // impressionsListenerEnabled + hP: boolean, // httpProxyDetected + tR: number, // timeUntilSDKReady + tC?: number, // timeUntilSDKReadyFromCache + nR: number, // SDKNotReadyUsage + i?: Array, // integrations + uC: number, // userConsent +} diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 235c18c8..61f0603d 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -1,4 +1,4 @@ -import { ISyncManagerCS, ISyncManagerFactoryParams } from './types'; +import { ISyncManagerCS } from './types'; import { submitterManagerFactory } from './submitters/submitterManager'; import { IReadinessManager } from '../readiness/types'; import { IStorageSync } from '../storages/types'; @@ -7,6 +7,8 @@ import { IPollingManager, IPollingManagerCS } from './polling/types'; import { PUSH_SUBSYSTEM_UP, PUSH_SUBSYSTEM_DOWN } from './streaming/constants'; import { SYNC_START_POLLING, SYNC_CONTINUE_POLLING, SYNC_STOP_POLLING } from '../logger/constants'; import { isConsentGranted } from '../consent'; +import { POLLING, STREAMING, SYNC_MODE_UPDATE } from '../utils/constants'; +import { ISdkFactoryContextSync } from '../sdkFactory/types'; /** * Online SyncManager factory. @@ -17,16 +19,16 @@ import { isConsentGranted } from '../consent'; * @param pushManagerFactory optional to build a SyncManager with or without streaming support */ export function syncManagerOnlineFactory( - pollingManagerFactory?: (params: ISyncManagerFactoryParams) => IPollingManager, - pushManagerFactory?: (params: ISyncManagerFactoryParams, pollingManager: IPollingManager) => IPushManager | undefined, -): (params: ISyncManagerFactoryParams) => ISyncManagerCS { + pollingManagerFactory?: (params: ISdkFactoryContextSync) => IPollingManager, + pushManagerFactory?: (params: ISdkFactoryContextSync, pollingManager: IPollingManager) => IPushManager | undefined, +): (params: ISdkFactoryContextSync) => ISyncManagerCS { /** * SyncManager factory for modular SDK */ - return function (params: ISyncManagerFactoryParams): ISyncManagerCS { + return function (params: ISdkFactoryContextSync): ISyncManagerCS { - const { settings, settings: { log, streamingEnabled } } = params; + const { settings, settings: { log, streamingEnabled }, telemetryTracker } = params; /** Polling Manager */ const pollingManager = pollingManagerFactory && pollingManagerFactory(params); @@ -48,13 +50,17 @@ export function syncManagerOnlineFactory( } else { log.info(SYNC_START_POLLING); pollingManager!.start(); + telemetryTracker.streamingEvent(SYNC_MODE_UPDATE, POLLING); } } function stopPollingAndSyncAll() { log.info(SYNC_STOP_POLLING); // if polling, stop - if (pollingManager!.isRunning()) pollingManager!.stop(); + if (pollingManager!.isRunning()) { + pollingManager!.stop(); + telemetryTracker.streamingEvent(SYNC_MODE_UPDATE, STREAMING); + } // fetch splits and segments. There is no need to catch this promise (it is always resolved) pollingManager!.syncAll(); diff --git a/src/sync/types.ts b/src/sync/types.ts index 1a70a5c6..22f51eb9 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -1,8 +1,5 @@ import { IReadinessManager } from '../readiness/types'; -import { IPlatform } from '../sdkFactory/types'; -import { ISplitApi } from '../services/types'; import { IStorageSync } from '../storages/types'; -import { ISettings } from '../types'; import { IPollingManager } from './polling/types'; import { IPushManager } from './streaming/types'; @@ -36,10 +33,6 @@ export interface ISyncTask extends ITask isExecuting(): boolean } -export interface ITimeTracker { - start(): () => void // start tracking time and return a function to call for stopping the tracking -} - /** SyncManager */ export interface ISyncManager extends ITask { @@ -52,11 +45,3 @@ export interface ISyncManager extends ITask { export interface ISyncManagerCS extends ISyncManager { shared(matchingKey: string, readinessManager: IReadinessManager, storage: IStorageSync): ISyncManager | undefined } - -export interface ISyncManagerFactoryParams { - settings: ISettings, - readiness: IReadinessManager, - storage: IStorageSync, - splitApi: ISplitApi, - platform: IPlatform -} diff --git a/src/trackers/__tests__/eventTracker.spec.ts b/src/trackers/__tests__/eventTracker.spec.ts index d770d899..18dba632 100644 --- a/src/trackers/__tests__/eventTracker.spec.ts +++ b/src/trackers/__tests__/eventTracker.spec.ts @@ -1,4 +1,5 @@ import { SplitIO } from '../../types'; +import { QUEUED } from '../../utils/constants'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; import { eventTrackerFactory } from '../eventTracker'; @@ -8,6 +9,10 @@ const fakeEventsCache = { track: jest.fn() }; +const fakeTelemetryCache = { + recordEventStats: jest.fn() +}; + const fakeIntegrationsManager = { handleEvent: jest.fn() }; @@ -45,13 +50,14 @@ describe('Event Tracker', () => { } } }); - - const tracker = eventTrackerFactory(fullSettings, fakeEventsCache, fakeIntegrationsManager); + // @ts-ignore + const tracker = eventTrackerFactory(fullSettings, fakeEventsCache, fakeIntegrationsManager, fakeTelemetryCache); const result1 = tracker.track(fakeEvent, 1); expect(fakeEventsCache.track.mock.calls[0]).toEqual([fakeEvent, 1]); // Should be present in the event cache. expect(fakeIntegrationsManager.handleEvent).not.toBeCalled(); // The integration manager handleEvent method should not be executed synchronously. expect(result1).toBe(true); // Should return the value of the event cache. + expect(fakeTelemetryCache.recordEventStats).toBeCalledWith(QUEUED, 1); await new Promise(res => setTimeout(res)); expect(fakeIntegrationsManager.handleEvent.mock.calls[0]).toEqual([fakeEvent]); // A copy of the tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. @@ -79,6 +85,7 @@ describe('Event Tracker', () => { expect(fakeIntegrationsManager.handleEvent.mock.calls[1]).toEqual([fakeEvent]); // A copy of tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. expect(fakeIntegrationsManager.handleEvent.mock.calls[1][0]).not.toBe(fakeEvent); // Should not send the original event. + expect(fakeTelemetryCache.recordEventStats).toBeCalledTimes(1); // Only the first of the 3 track calls is using sync events cache }); test('Should track or not events depending on user consent status', () => { diff --git a/src/trackers/__tests__/impressionsTracker.spec.ts b/src/trackers/__tests__/impressionsTracker.spec.ts index 0a35db12..5ca63895 100644 --- a/src/trackers/__tests__/impressionsTracker.spec.ts +++ b/src/trackers/__tests__/impressionsTracker.spec.ts @@ -4,12 +4,16 @@ import { impressionObserverSSFactory } from '../impressionObserver/impressionObs import { impressionObserverCSFactory } from '../impressionObserver/impressionObserverCS'; import { ImpressionDTO } from '../../types'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { DEDUPED, QUEUED } from '../../utils/constants'; /* Mocks */ const fakeImpressionsCache = { track: jest.fn() }; +const fakeTelemetryCache = { + recordImpressionStats: jest.fn() +}; const fakeListener = { logImpression: jest.fn() }; @@ -168,8 +172,8 @@ describe('Impressions Tracker', () => { impression2.time = Date.now(); impression3.time = Date.now(); - const impressionCountsCache = new ImpressionCountsCacheInMemory(); - const tracker = impressionsTrackerFactory(fakeSettings, fakeImpressionsCache, undefined, impressionObserverCSFactory(), impressionCountsCache); + const impressionCountsCache = new ImpressionCountsCacheInMemory(); // @ts-ignore + const tracker = impressionsTrackerFactory(fakeSettings, fakeImpressionsCache, undefined, impressionObserverCSFactory(), impressionCountsCache, fakeTelemetryCache); expect(fakeImpressionsCache.track).not.toBeCalled(); // cache method should not be called by just creating a tracker @@ -185,6 +189,8 @@ describe('Impressions Tracker', () => { expect(lastArgs[0][1].feature).toBe('qc_team_2'); expect(Object.keys(impressionCountsCache.state()).length).toBe(2); + expect(fakeTelemetryCache.recordImpressionStats.mock.calls).toEqual([[QUEUED, 2], [DEDUPED, 1]]); + }); test('Should track or not impressions depending on user consent status', () => { diff --git a/src/trackers/__tests__/telemetryTracker.spec.ts b/src/trackers/__tests__/telemetryTracker.spec.ts new file mode 100644 index 00000000..469ac77f --- /dev/null +++ b/src/trackers/__tests__/telemetryTracker.spec.ts @@ -0,0 +1,95 @@ +import { EXCEPTION, SDK_NOT_READY } from '../../utils/labels'; +import { nearlyEqual } from '../../__tests__/testUtils'; +import { telemetryTrackerFactory } from '../telemetryTracker'; + +describe('Telemetry Tracker', () => { + + const fakeNow = jest.fn(() => { return Date.now(); }); + const fakeTelemetryCache = { + recordLatency: jest.fn(), + recordException: jest.fn(), + recordNonReadyUsage: jest.fn(), + recordHttpLatency: jest.fn(), + recordHttpError: jest.fn(), + recordSuccessfulSync: jest.fn(), + recordSessionLength: jest.fn(), + recordStreamingEvents: jest.fn(), + }; + + const tracker = telemetryTrackerFactory(fakeTelemetryCache, fakeNow); + const startTimestamp = Date.now(); + + test('trackEval', async () => { + + let stopTracker = tracker.trackEval('t'); + stopTracker(); + + stopTracker = tracker.trackEval('ts'); + stopTracker(EXCEPTION); + + stopTracker = tracker.trackEval('tc'); + stopTracker(SDK_NOT_READY); + + stopTracker = tracker.trackEval('tcs'); + + await new Promise(res => setTimeout(res, 100)); + stopTracker(); + + expect(fakeTelemetryCache.recordException).toBeCalledTimes(1); + expect(fakeTelemetryCache.recordNonReadyUsage).toBeCalledTimes(1); + expect(fakeTelemetryCache.recordLatency).toBeCalledTimes(3); + + const latency = fakeTelemetryCache.recordLatency.mock.calls[2][1]; + expect(nearlyEqual(latency, 100)).toBeTruthy(); // last tracked latency is around 100 ms + }); + + test('trackHttp', async () => { + + let stopTracker = tracker.trackHttp('ev'); + stopTracker(); + + stopTracker = tracker.trackHttp('ic'); // @ts-ignore + stopTracker({ statusCode: 400 }); + + stopTracker = tracker.trackHttp('im'); + + await new Promise(res => setTimeout(res, 100)); + stopTracker(); + + expect(fakeTelemetryCache.recordHttpError).toBeCalledTimes(1); + expect(fakeTelemetryCache.recordSuccessfulSync).toBeCalledTimes(2); + expect(fakeTelemetryCache.recordHttpLatency).toBeCalledTimes(3); + + const latency = fakeTelemetryCache.recordHttpLatency.mock.calls[2][1]; + expect(nearlyEqual(latency, 100)).toBeTruthy(); // last tracked latency is around 100 ms + }); + + test('trackSessionLength', () => { + tracker.sessionLength(); + expect(fakeTelemetryCache.recordSessionLength).toBeCalledTimes(1); + + const expectedSessionLength = Date.now() - startTimestamp; + const sessionLength = fakeTelemetryCache.recordSessionLength.mock.calls[0][0]; + expect(nearlyEqual(sessionLength, expectedSessionLength)).toBeTruthy(); + }); + + test('streamingEvent', () => { + tracker.streamingEvent(10, 1); + expect(fakeTelemetryCache.recordStreamingEvents).toBeCalledTimes(1); + }); + +}); + +test('Telemetry Tracker no-op', () => { + // The instance must implement the TelemetryTracker API even if no cache is provided + const tracker = telemetryTrackerFactory(); + + const stopEvalTracker = tracker.trackEval('tr'); + expect(stopEvalTracker()).toBe(undefined); + + const stopHttpTracker = tracker.trackHttp('ev'); + expect(stopHttpTracker()).toBe(undefined); + + expect(tracker.sessionLength()).toBe(undefined); + expect(tracker.streamingEvent(10, 1)).toBe(undefined); +}); diff --git a/src/trackers/eventTracker.ts b/src/trackers/eventTracker.ts index 7ac851a9..878800d3 100644 --- a/src/trackers/eventTracker.ts +++ b/src/trackers/eventTracker.ts @@ -1,10 +1,10 @@ import { objectAssign } from '../utils/lang/objectAssign'; import { thenable } from '../utils/promise/thenable'; -import { IEventsCacheBase } from '../storages/types'; +import { IEventsCacheBase, ITelemetryCacheAsync, ITelemetryCacheSync } from '../storages/types'; import { IEventsHandler, IEventTracker } from './types'; import { ISettings, SplitIO } from '../types'; import { EVENTS_TRACKER_SUCCESS, ERROR_EVENTS_TRACKER } from '../logger/constants'; -import { CONSENT_DECLINED } from '../utils/constants'; +import { CONSENT_DECLINED, DROPPED, QUEUED } from '../utils/constants'; import { isStorageSync } from './impressionObserver/utils'; /** @@ -16,7 +16,8 @@ import { isStorageSync } from './impressionObserver/utils'; export function eventTrackerFactory( settings: ISettings, eventsCache: IEventsCacheBase, - integrationsManager?: IEventsHandler + integrationsManager?: IEventsHandler, + telemetryCache?: ITelemetryCacheSync | ITelemetryCacheAsync ): IEventTracker { const log = settings.log; @@ -57,6 +58,9 @@ export function eventTrackerFactory( if (thenable(tracked)) { return tracked.then(queueEventsCallback.bind(null, eventData)); } else { + // Record when eventsCache is sync only (standalone mode) + // @TODO we are not dropping events on full queue yet, so `tracked` is always true ATM + if (telemetryCache) (telemetryCache as ITelemetryCacheSync).recordEventStats(tracked ? QUEUED : DROPPED, 1); return queueEventsCallback(eventData, tracked); } } diff --git a/src/trackers/impressionsTracker.ts b/src/trackers/impressionsTracker.ts index c2335531..61b8e2cb 100644 --- a/src/trackers/impressionsTracker.ts +++ b/src/trackers/impressionsTracker.ts @@ -1,12 +1,12 @@ import { objectAssign } from '../utils/lang/objectAssign'; import { thenable } from '../utils/promise/thenable'; import { truncateTimeFrame } from '../utils/time'; -import { IImpressionCountsCacheSync, IImpressionsCacheBase } from '../storages/types'; +import { IImpressionCountsCacheSync, IImpressionsCacheBase, ITelemetryCacheSync, ITelemetryCacheAsync } from '../storages/types'; import { IImpressionsHandler, IImpressionsTracker } from './types'; import { SplitIO, ImpressionDTO, ISettings } from '../types'; import { IImpressionObserver } from './impressionObserver/types'; import { IMPRESSIONS_TRACKER_SUCCESS, ERROR_IMPRESSIONS_TRACKER, ERROR_IMPRESSIONS_LISTENER } from '../logger/constants'; -import { CONSENT_DECLINED } from '../utils/constants'; +import { CONSENT_DECLINED, DEDUPED, QUEUED } from '../utils/constants'; /** * Impressions tracker stores impressions in cache and pass them to the listener and integrations manager if provided. @@ -25,7 +25,8 @@ export function impressionsTrackerFactory( // if observer is provided, it implies `shouldAddPreviousTime` flag (i.e., if impressions previous time should be added or not) observer?: IImpressionObserver, // if countsCache is provided, it implies `isOptimized` flag (i.e., if impressions should be deduped or not) - countsCache?: IImpressionCountsCacheSync + countsCache?: IImpressionCountsCacheSync, + telemetryCache?: ITelemetryCacheSync | ITelemetryCacheAsync ): IImpressionsTracker { const { log, impressionListener, runtime: { ip, hostname }, version } = settings; @@ -65,6 +66,13 @@ export function impressionsTrackerFactory( }).catch(err => { log.error(ERROR_IMPRESSIONS_TRACKER, [impressionsCount, err]); }); + } else { + // Record when impressionsCache is sync only (standalone mode) + // @TODO we are not dropping impressions on full queue yet, so DROPPED stats are not recorded + if (telemetryCache) { + (telemetryCache as ITelemetryCacheSync).recordImpressionStats(QUEUED, impressionsToStore.length); + (telemetryCache as ITelemetryCacheSync).recordImpressionStats(DEDUPED, impressions.length - impressionsToStore.length); + } } // @TODO next block might be handled by the integration manager. In that case, the metadata object doesn't need to be passed in the constructor diff --git a/src/trackers/telemetryTracker.ts b/src/trackers/telemetryTracker.ts new file mode 100644 index 00000000..ab0e1d9e --- /dev/null +++ b/src/trackers/telemetryTracker.ts @@ -0,0 +1,63 @@ +import { ITelemetryCacheSync, ITelemetryCacheAsync } from '../storages/types'; +import { EXCEPTION, SDK_NOT_READY } from '../utils/labels'; +import { ITelemetryTracker } from './types'; +import { timer } from '../utils/timeTracker/timer'; +import { TOKEN_REFRESH, AUTH_REJECTION } from '../utils/constants'; + +export function telemetryTrackerFactory( + telemetryCache?: ITelemetryCacheSync | ITelemetryCacheAsync, + now?: () => number +): ITelemetryTracker { + + if (telemetryCache && now) { + const startTime = timer(now); + + return { + trackEval(method) { + const evalTime = timer(now); + + return (label) => { + switch (label) { + case EXCEPTION: + telemetryCache.recordException(method); + return; // Don't track latency on exceptions + case SDK_NOT_READY: // @ts-ignore ITelemetryCacheAsync doesn't implement the method + if (telemetryCache.recordNonReadyUsage) telemetryCache.recordNonReadyUsage(); + } + telemetryCache.recordLatency(method, evalTime()); + }; + }, + trackHttp(operation) { + const httpTime = timer(now); + + return (error) => { + (telemetryCache as ITelemetryCacheSync).recordHttpLatency(operation, httpTime()); + if (error && error.statusCode) (telemetryCache as ITelemetryCacheSync).recordHttpError(operation, error.statusCode); + else (telemetryCache as ITelemetryCacheSync).recordSuccessfulSync(operation, Date.now()); + }; + }, + sessionLength() { // @ts-ignore ITelemetryCacheAsync doesn't implement the method + if (telemetryCache.recordSessionLength) telemetryCache.recordSessionLength(startTime()); + }, + streamingEvent(e, d) { + if (e === AUTH_REJECTION) { + (telemetryCache as ITelemetryCacheSync).recordAuthRejections(); + } else { + (telemetryCache as ITelemetryCacheSync).recordStreamingEvents({ + e, d, t: Date.now() + }); + if (e === TOKEN_REFRESH) (telemetryCache as ITelemetryCacheSync).recordTokenRefreshes(); + } + } + }; + + } else { // If there is not `telemetryCache` or `now` time tracker, return a no-op telemetry tracker + const noopTrack = () => () => { }; + return { + trackEval: noopTrack, + trackHttp: noopTrack, + sessionLength: () => { }, + streamingEvent: () => { }, + }; + } +} diff --git a/src/trackers/types.ts b/src/trackers/types.ts index dd00ec13..2ba70832 100644 --- a/src/trackers/types.ts +++ b/src/trackers/types.ts @@ -1,5 +1,7 @@ import { SplitIO, ImpressionDTO } from '../types'; +import { StreamingEventType, Method, OperationType } from '../sync/submitters/types'; import { IEventsCacheBase } from '../storages/types'; +import { NetworkError } from '../services/types'; /** Events tracker */ @@ -18,3 +20,25 @@ export interface IImpressionsHandler { export interface IImpressionsTracker { track(impressions: ImpressionDTO[], attributes?: SplitIO.Attributes): void } + +/** Telemetry tracker */ +export type AUTH_REJECTION = 80; + +export interface ITelemetryTracker { + /** + * Creates a telemetry evaluator tracker, to record Latencies, Exceptions and NonReadyUsage of client operations (getTreatments and track method calls) + */ + trackEval(method: Method): (label?: string) => void + /** + * Creates a telemetry runtime tracker, to record Latencies and Exceptions of HTTP requests + */ + trackHttp(method: OperationType): (error?: NetworkError) => void + /** + * Records session length + */ + sessionLength(): void + /** + * Records streaming event + */ + streamingEvent(e: StreamingEventType | AUTH_REJECTION, d?: number): void +} diff --git a/src/types.ts b/src/types.ts index 6c330eec..dd2cb085 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,11 @@ import { ISplitFiltersValidation } from './dtos/types'; import { IIntegration, IIntegrationFactoryParams } from './integrations/types'; import { ILogger } from './logger/types'; +import { ISdkFactoryContext } from './sdkFactory/types'; /* eslint-disable no-use-before-define */ import { IStorageFactoryParams, IStorageSync, IStorageAsync, IStorageSyncFactory, IStorageAsyncFactory } from './storages/types'; -import { ISyncManagerFactoryParams, ISyncManagerCS } from './sync/types'; +import { ISyncManagerCS } from './sync/types'; /** * Reduced version of NodeJS.EventEmitter interface with the minimal methods used by the SDK @@ -79,7 +80,11 @@ export interface ISettings { featuresRefreshRate: number, impressionsRefreshRate: number, impressionsQueueSize: number, - metricsRefreshRate: number, + /** + * @deprecated + */ + metricsRefreshRate?: number, + telemetryRefreshRate: number, segmentsRefreshRate: number, offlineRefreshRate: number, eventsPushRate: number, @@ -93,12 +98,16 @@ export interface ISettings { eventsFirstPushWindow: number }, readonly storage: IStorageSyncFactory | IStorageAsyncFactory, - readonly integrations?: Array<(params: IIntegrationFactoryParams) => IIntegration | void>, + readonly integrations: Array<{ + readonly type: string, + (params: IIntegrationFactoryParams): IIntegration | void + }>, readonly urls: { events: string, sdk: string, auth: string, - streaming: string + streaming: string, + telemetry: string }, readonly debug: boolean | LogLevel | ILogger, readonly version: string, @@ -273,8 +282,15 @@ interface INodeBasicSettings extends ISharedSettings { * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. * @property {number} metricsRefreshRate * @default 120 + * @deprecated This parameter is ignored now. */ metricsRefreshRate?: number, + /** + * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. + * @property {number} telemetryRefreshRate + * @default 3600 + */ + telemetryRefreshRate?: number, /** * The SDK polls Split servers for changes to segment definitions. This parameter controls this polling period in seconds. * @property {number} segmentsRefreshRate @@ -607,7 +623,7 @@ export namespace SplitIO { */ export type LocalhostFactory = { type: 'LocalhostFromObject' | 'LocalhostFromFile' - (params: ISyncManagerFactoryParams): ISyncManagerCS + (params: ISdkFactoryContext): ISyncManagerCS } /** * Impression listener interface. This is the interface that needs to be implemented @@ -664,7 +680,13 @@ export namespace SplitIO { * @property {string} streaming * @default 'https://streaming.split.io' */ - streaming?: string + streaming?: string, + /** + * String property to override the base URL where the SDK will post telemetry data. + * @property {string} telemetry + * @default 'https://telemetry.split.io/api' + */ + telemetry?: string }; /** * SplitFilter type. @@ -794,8 +816,15 @@ export namespace SplitIO { * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. * @property {number} metricsRefreshRate * @default 120 + * @deprecated This parameter is ignored now. */ metricsRefreshRate?: number, + /** + * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. + * @property {number} telemetryRefreshRate + * @default 3600 + */ + telemetryRefreshRate?: number, /** * The SDK polls Split servers for changes to segment definitions. This parameter controls this polling period in seconds. * @property {number} segmentsRefreshRate diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index 243accd6..4925e71a 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -37,3 +37,48 @@ export const STORAGE_PLUGGABLE: StorageType = 'PLUGGABLE'; export const CONSENT_GRANTED = 'GRANTED'; // The user has granted consent for tracking events and impressions export const CONSENT_DECLINED = 'DECLINED'; // The user has declined consent for tracking events and impressions export const CONSENT_UNKNOWN = 'UNKNOWN'; // The user has neither granted nor declined consent for tracking events and impressions + +// Telemetry +export const QUEUED = 0; +export const DROPPED = 1; +export const DEDUPED = 2; + +export const STANDALONE_ENUM = 0; +export const CONSUMER_ENUM = 1; +export const CONSUMER_PARTIAL_ENUM = 2; + +export const OPTIMIZED_ENUM = 0; +export const DEBUG_ENUM = 1; + +export const SPLITS = 'sp'; +export const IMPRESSIONS = 'im'; +export const IMPRESSIONS_COUNT = 'ic'; +export const EVENTS = 'ev'; +export const TELEMETRY = 'te'; +export const TOKEN = 'to'; +export const SEGMENT = 'se'; +export const MY_SEGMENT = 'ms'; + +export const TREATMENT = 't'; +export const TREATMENTS = 'ts'; +export const TREATMENT_WITH_CONFIG = 'tc'; +export const TREATMENTS_WITH_CONFIG = 'tcs'; +export const TRACK = 'tr'; + +export const CONNECTION_ESTABLISHED = 0; +export const OCCUPANCY_PRI = 10; +export const OCCUPANCY_SEC = 20; +export const STREAMING_STATUS = 30; +export const SSE_CONNECTION_ERROR = 40; +export const TOKEN_REFRESH = 50; +export const ABLY_ERROR = 60; +export const SYNC_MODE_UPDATE = 70; +export const AUTH_REJECTION = 80; + +export const STREAMING = 0; +export const POLLING = 1; +export const REQUESTED = 0; +export const NON_REQUESTED = 1; +export const DISABLED = 0; +export const ENABLED = 1; +export const PAUSED = 2; diff --git a/src/utils/inputValidation/apiKey.ts b/src/utils/inputValidation/apiKey.ts index 86175189..cef0fa0f 100644 --- a/src/utils/inputValidation/apiKey.ts +++ b/src/utils/inputValidation/apiKey.ts @@ -21,7 +21,8 @@ export function validateApiKey(log: ILogger, maybeApiKey: any): string | false { return apiKey; } -const usedKeysMap: Record = {}; +// Exported for telemetry +export const usedKeysMap: Record = {}; /** validates the given api key and also warns if it is in use */ export function validateAndTrackApiKey(log: ILogger, maybeApiKey: any): string | false { diff --git a/src/utils/lang/index.ts b/src/utils/lang/index.ts index bd7c7a51..0a828dda 100644 --- a/src/utils/lang/index.ts +++ b/src/utils/lang/index.ts @@ -71,7 +71,7 @@ export function forOwn(obj: { [key: string]: T }, iteratee: (value: T, key: s * Safely retrieve the specified prop from obj. If we can't retrieve * that property value, we return the default value. */ -export function get(obj: any, prop: any, val: any): any { +export function get(obj: any, prop: any, val?: any): any { let res = val; try { // No risks nor lots of checks. diff --git a/src/utils/murmur3/utfx.ts b/src/utils/murmur3/utfx.ts index 9bc44270..fd9125f4 100644 --- a/src/utils/murmur3/utfx.ts +++ b/src/utils/murmur3/utfx.ts @@ -8,8 +8,7 @@ */ export interface utfx { - encodeUTF16toUTF8(src: () => number | null, dst: (...args: number[]) => string | undefined): void, - + encodeUTF16toUTF8(src: () => number | null, dst: (...args: number[]) => string | undefined): void } diff --git a/src/utils/settingsValidation/__tests__/index.spec.ts b/src/utils/settingsValidation/__tests__/index.spec.ts index 3f799970..e82d1d99 100644 --- a/src/utils/settingsValidation/__tests__/index.spec.ts +++ b/src/utils/settingsValidation/__tests__/index.spec.ts @@ -37,34 +37,41 @@ describe('settingsValidation', () => { events: 'https://events.split.io/api', auth: 'https://auth.split.io/api', streaming: 'https://streaming.split.io', + telemetry: 'https://telemetry.split.io/api', }); expect(settings.sync.impressionsMode).toBe(OPTIMIZED); }); - test('override with defaults', () => { - const settings = settingsValidation({ - core: { - authorizationKey: 'dummy token' - }, - sync: { - impressionsMode: 'some', - } - }, minimalSettingsParams); + test('override with default impressionMode if provided one is invalid', () => { + const config = { + core: { authorizationKey: 'dummy token' }, + sync: { impressionsMode: 'some' } + }; + let settings = settingsValidation(config, minimalSettingsParams); expect(settings.sync.impressionsMode).toBe(OPTIMIZED); + expect(settings.scheduler.impressionsRefreshRate).toBe(300000); // Default + + settings = settingsValidation({ ...config, scheduler: { impressionsRefreshRate: 10 } }, minimalSettingsParams); + + expect(settings.sync.impressionsMode).toBe(OPTIMIZED); + expect(settings.scheduler.impressionsRefreshRate).toBe(10000); }); test('impressionsMode should be configurable', () => { - const settings = settingsValidation({ - core: { - authorizationKey: 'dummy token' - }, - sync: { - impressionsMode: DEBUG - } - }, minimalSettingsParams); + const config = { + core: { authorizationKey: 'dummy token' }, + sync: { impressionsMode: DEBUG } + }; + let settings = settingsValidation(config, minimalSettingsParams); expect(settings.sync.impressionsMode).toEqual(DEBUG); + expect(settings.scheduler.impressionsRefreshRate).toBe(60000); // Different default for DEBUG impressionsMode + + settings = settingsValidation({ ...config, scheduler: { impressionsRefreshRate: 10 } }, minimalSettingsParams); + + expect(settings.sync.impressionsMode).toBe(DEBUG); + expect(settings.scheduler.impressionsRefreshRate).toBe(10000); }); test('urls should be configurable', () => { @@ -73,6 +80,7 @@ describe('settingsValidation', () => { events: 'events-url', auth: 'auth-url', streaming: 'streaming-url', + telemetry: 'telemetry-url', }; const settings = settingsValidation({ @@ -90,8 +98,9 @@ describe('settingsValidation', () => { const locatorSchedulerFeaturesRefreshRate = _.property('scheduler.featuresRefreshRate'); const locatorSchedulerSegmentsRefreshRate = _.property('scheduler.segmentsRefreshRate'); - const locatorSchedulerMetricsRefreshRate = _.property('scheduler.metricsRefreshRate'); + const locatorSchedulerTelemetryRefreshRate = _.property('scheduler.telemetryRefreshRate'); const locatorSchedulerImpressionsRefreshRate = _.property('scheduler.impressionsRefreshRate'); + const locatorSchedulerEventsPushRate = _.property('scheduler.eventsPushRate'); const locatorUrlsSDK = _.property('urls.sdk'); const locatorUrlsEvents = _.property('urls.events'); @@ -125,8 +134,9 @@ describe('settingsValidation', () => { expect(locatorSchedulerFeaturesRefreshRate(settings) !== undefined).toBe(true); // scheduler.featuresRefreshRate should be present expect(locatorSchedulerSegmentsRefreshRate(settings) !== undefined).toBe(true); // scheduler.segmentsRefreshRate should be present - expect(locatorSchedulerMetricsRefreshRate(settings)).toBe(120 * 1000); // scheduler.metricsRefreshRate should be present + expect(locatorSchedulerTelemetryRefreshRate(settings)).toBe(3600 * 1000); // scheduler.telemetryRefreshRate should be present expect(locatorSchedulerImpressionsRefreshRate(settings) !== undefined).toBe(true); // scheduler.impressionsRefreshRate should be present + expect(locatorSchedulerEventsPushRate(settings) !== undefined).toBe(true); // scheduler.eventsPushRate should be present expect(locatorUrlsSDK(settings) !== undefined).toBe(true); // urls.sdk should be present expect(locatorUrlsEvents(settings) !== undefined).toBe(true); // urls.events should be present @@ -239,6 +249,20 @@ describe('settingsValidation', () => { expect(settings.core.key).toEqual(false); // key is validated expect(settings.core.trafficType).toEqual(true); // traffic type is ignored }); + + // Not implemented yet + // test('validate min values', () => { + // const settings = settingsValidation({ + // scheduler: { + // telemetryRefreshRate: 0, + // impressionsRefreshRate: 'invalid', + // } + // }, minimalSettingsParams); + + // expect(settings.scheduler.telemetryRefreshRate).toBe(60000); + // expect(settings.scheduler.impressionsRefreshRate).toBe(60000); + // }); + }); test('SETTINGS / urls should be correctly assigned', () => { diff --git a/src/utils/settingsValidation/__tests__/settings.mocks.ts b/src/utils/settingsValidation/__tests__/settings.mocks.ts index ff160297..908bfdc4 100644 --- a/src/utils/settingsValidation/__tests__/settings.mocks.ts +++ b/src/utils/settingsValidation/__tests__/settings.mocks.ts @@ -37,6 +37,9 @@ export const settingsWithKeyObject = { log: loggerMock }; +function NoopIntegration() {} +NoopIntegration.type = 'NoopIntegration'; + export const fullSettings: ISettings = { core: { authorizationKey: 'aaaabbbbcccc1234', @@ -47,7 +50,7 @@ export const fullSettings: ISettings = { scheduler: { featuresRefreshRate: 1, impressionsRefreshRate: 1, - metricsRefreshRate: 1, + telemetryRefreshRate: 1, segmentsRefreshRate: 1, offlineRefreshRate: 1, eventsPushRate: 1, @@ -63,7 +66,7 @@ export const fullSettings: ISettings = { }, features: 'path/to/file', storage: InMemoryStorageCSFactory, - integrations: [() => { }], // A no-op integration + integrations: [NoopIntegration], mode: 'standalone', debug: false, streamingEnabled: true, @@ -86,7 +89,8 @@ export const fullSettings: ISettings = { events: 'events', sdk: 'sdk', auth: 'auth', - streaming: 'streaming' + streaming: 'streaming', + telemetry: 'telemetry' }, log: loggerMock, userConsent: undefined @@ -110,7 +114,8 @@ export const settingsSplitApi = { events: 'events', sdk: 'sdk', auth: 'auth', - streaming: 'streaming' + streaming: 'streaming', + telemetry: 'telemetry' }, sync: { impressionsMode: 'DEBUG' diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 5bb1125b..2269721f 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -1,14 +1,16 @@ -import { merge } from '../lang'; +import { merge, get } from '../lang'; import { mode } from './mode'; import { validateSplitFilters } from './splitFilters'; -import { STANDALONE_MODE, OPTIMIZED, LOCALHOST_MODE } from '../constants'; +import { STANDALONE_MODE, OPTIMIZED, LOCALHOST_MODE, DEBUG } from '../constants'; import { validImpressionsMode } from './impressionsMode'; import { ISettingsValidationParams } from './types'; import { ISettings } from '../../types'; import { validateKey } from '../inputValidation/key'; import { validateTrafficType } from '../inputValidation/trafficType'; +import { ERROR_MIN_CONFIG_PARAM } from '../../logger/constants'; -const base = { +// Exported for telemetry +export const base = { // Define which kind of object you want to retrieve from SplitFactory mode: STANDALONE_MODE, @@ -30,10 +32,10 @@ const base = { featuresRefreshRate: 30, // fetch segments updates each 60 sec segmentsRefreshRate: 60, - // publish metrics each 120 sec - metricsRefreshRate: 120, - // publish evaluations each 60 sec - impressionsRefreshRate: 60, + // publish telemetry stats each 3600 secs (1 hour) + telemetryRefreshRate: 3600, + // publish evaluations each 300 sec (default value for OPTIMIZED impressions mode) + impressionsRefreshRate: 300, // fetch offline changes each 15 sec offlineRefreshRate: 15, // publish events every 60 seconds after the first flush @@ -55,6 +57,8 @@ const base = { auth: 'https://auth.split.io/api', // Streaming Server streaming: 'https://streaming.split.io', + // Telemetry Server + telemetry: 'https://telemetry.split.io/api', }, // Defines which kind of storage we should instanciate. @@ -109,14 +113,30 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV const log = logger(withDefaults); // @ts-ignore, modify readonly prop withDefaults.log = log; + // ensure a valid impressionsMode + withDefaults.sync.impressionsMode = validImpressionsMode(log, withDefaults.sync.impressionsMode); + + function validateMinValue(paramName: string, actualValue: number, minValue: number) { + if (actualValue >= minValue) return actualValue; + // actualValue is not a number or is lower than minValue + log.error(ERROR_MIN_CONFIG_PARAM, [paramName, minValue]); + return minValue; + } + // Scheduler periods const { scheduler, startup } = withDefaults; scheduler.featuresRefreshRate = fromSecondsToMillis(scheduler.featuresRefreshRate); scheduler.segmentsRefreshRate = fromSecondsToMillis(scheduler.segmentsRefreshRate); - scheduler.metricsRefreshRate = fromSecondsToMillis(scheduler.metricsRefreshRate); - scheduler.impressionsRefreshRate = fromSecondsToMillis(scheduler.impressionsRefreshRate); scheduler.offlineRefreshRate = fromSecondsToMillis(scheduler.offlineRefreshRate); scheduler.eventsPushRate = fromSecondsToMillis(scheduler.eventsPushRate); + scheduler.telemetryRefreshRate = fromSecondsToMillis(validateMinValue('telemetryRefreshRate', scheduler.telemetryRefreshRate, 60)); + + // Default impressionsRefreshRate for DEBUG mode is 60 secs + if (get(config, 'scheduler.impressionsRefreshRate') === undefined && withDefaults.sync.impressionsMode === DEBUG) scheduler.impressionsRefreshRate = 60; + scheduler.impressionsRefreshRate = fromSecondsToMillis(scheduler.impressionsRefreshRate); + + // Log deprecation for old telemetry param + if (scheduler.metricsRefreshRate) log.warn('`metricsRefreshRate` will be deprecated soon. For configuring telemetry rates, update `telemetryRefreshRate` value in configs'); // Startup periods startup.requestTimeoutBeforeReady = fromSecondsToMillis(startup.requestTimeoutBeforeReady); @@ -176,9 +196,6 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV withDefaults.sync.splitFilters = splitFiltersValidation.validFilters; withDefaults.sync.__splitFiltersValidation = splitFiltersValidation; - // ensure a valid impressionsMode - withDefaults.sync.impressionsMode = validImpressionsMode(log, withDefaults.sync.impressionsMode); - // ensure a valid user consent value // @ts-ignore, modify readonly prop withDefaults.userConsent = consent(withDefaults); diff --git a/src/utils/settingsValidation/url.ts b/src/utils/settingsValidation/url.ts index 796f5423..4dd0179e 100644 --- a/src/utils/settingsValidation/url.ts +++ b/src/utils/settingsValidation/url.ts @@ -1,5 +1,6 @@ import { ISettings } from '../../types'; +const telemetryEndpointMatcher = /^\/v1\/metrics\/(config|usage)/; const eventsEndpointMatcher = /^\/(testImpressions|metrics|events)/; const authEndpointMatcher = /^\/v2\/auth/; const streamingEndpointMatcher = /^\/(sse|event-stream)/; @@ -13,6 +14,9 @@ const streamingEndpointMatcher = /^\/(sse|event-stream)/; * @return complete url */ export function url(settings: ISettings, target: string) { + if (telemetryEndpointMatcher.test(target)) { + return `${settings.urls.telemetry}${target}`; + } if (eventsEndpointMatcher.test(target)) { return `${settings.urls.events}${target}`; } diff --git a/src/utils/timeTracker/__tests__/index.spec.ts b/src/utils/timeTracker/__tests__/index.spec.ts index e4036fd8..da026034 100644 --- a/src/utils/timeTracker/__tests__/index.spec.ts +++ b/src/utils/timeTracker/__tests__/index.spec.ts @@ -1,11 +1,8 @@ import { timer } from '../timer'; -import { TrackerAPI } from '../index'; -import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -import { IResponse } from '../../../services/types'; test('TIMER / should count the time between two tasks', (done) => { const timerDuration = Math.floor(Math.random() * 1000); // In millis - const stopTimer = timer(); + const stopTimer = timer(Date.now); setTimeout(() => { const elapsedTime = stopTimer(); @@ -15,48 +12,3 @@ test('TIMER / should count the time between two tasks', (done) => { done(); }, timerDuration); }); - -describe('TIME TRACKER', () => { - - test('should have the correct API', () => { - expect(typeof TrackerAPI.start).toBe('function'); // It should have the correct API. - expect(typeof TrackerAPI.stop).toBe('function'); // It should have the correct API. - expect(typeof TrackerAPI.TaskNames).toBe('object'); // It should have the correct API. - }); - - test('start() / should return the correct type', () => { - const promise = new Promise(res => { - setTimeout(res, 1000); - }); - const startNormal = TrackerAPI.start(loggerMock, TrackerAPI.TaskNames.SDK_READY); - const startNormalFake = TrackerAPI.start(loggerMock, 'fakeTask3'); - const startWithPromise = TrackerAPI.start(loggerMock, 'fakeTask4', undefined, promise); - - expect(typeof startNormal).toBe('function'); // If we call start without a promise, it will return the stop function, - // @ts-expect-error - expect(typeof startNormal.setCollectorForTask).toBe('function'); // that has a function as well for setting the collector at a defered time, because it has a registered cb but no collector received. - // @ts-expect-error - expect(typeof startNormalFake.setCollectorForTask).toBe('undefined'); // If no callback is registered for the task, no collectors setup function is attached to returned one. - // @ts-expect-error - expect(typeof startWithPromise.then).toBe('function'); // But if we pass a promise, we will get a promise back, with the necessary callbacks already handled. - }); - - test('stop() / should stop the timer and return the time, if any', () => { - TrackerAPI.start(loggerMock, 'test_task'); - - // creating two tasks with the same task name - const stopFromStart = TrackerAPI.start(loggerMock, 'fakeTask5') as () => number; - const stopFromStart2 = TrackerAPI.start(loggerMock, 'fakeTask5') as () => number; - - const stopNotExistentTask = TrackerAPI.stop(loggerMock, 'not_existent'); - const stopNotExistentTaskAndModifier = TrackerAPI.stop(loggerMock, 'test_task', 'mod'); - - expect(typeof stopNotExistentTask).toBe('undefined'); // If we try to stop a timer that does not exist, we get undefined. - expect(typeof stopNotExistentTaskAndModifier).toBe('undefined'); // If we try to stop a timer that does not exist, we get undefined. - expect(typeof stopFromStart()).toBe('number'); // But if we stop an existing task from the startUnique() returned function, we get a number. - expect(typeof stopFromStart()).toBe('undefined'); // if we stop the same task again, we get undefined. - - expect(typeof stopFromStart2()).toBe('number'); // But if we stop another task created with the same task name, we get a number. - }); - -}); diff --git a/src/utils/timeTracker/index.ts b/src/utils/timeTracker/index.ts deleted file mode 100644 index a09289b8..00000000 --- a/src/utils/timeTracker/index.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { uniqueId } from '../lang'; -import { timer } from './timer'; -import { thenable } from '../promise/thenable'; -import { ILogger } from '../../logger/types'; -import { IResponse } from '../../services/types'; - -// Based on ProducerMetricsCollector and ClientCollector classes -interface MetricsCollector { - // ProducerMetricsCollector API - countException(): void, - count(status: number): void, - latency(ms: number): void, - - // ClientCollector API - ready(ms: number): void, - getTreatment(ms: number): void, - getTreatments(ms: number): void, - getTreatmentWithConfig(ms: number): void, - getTreatmentsWithConfig(ms: number): void, - - [method: string]: (ms: number) => void, -} - -// Map we will use for storing timers data -const timers: Record void), - timer: () => number -}> = {}; - -// Tasks constants -const CONSTANTS = { - SDK_READY: 'Getting ready - Split SDK', - SDK_GET_TREATMENT: 'SDK - Get Treatment', - SDK_GET_TREATMENTS: 'SDK - Get Treatments', - SDK_GET_TREATMENT_WITH_CONFIG: 'SDK - Get Treatment with config', - SDK_GET_TREATMENTS_WITH_CONFIG: 'SDK - Get Treatments with config', - SPLITS_READY: 'Getting ready - Splits', - SEGMENTS_READY: 'Getting ready - Segments', - METRICS_PUSH: 'Pushing - Metrics', - IMPRESSIONS_PUSH: 'Pushing - Impressions', - EVENTS_PUSH: 'Pushing - Events', - MY_SEGMENTS_FETCH: 'Fetching - My Segments', - SEGMENTS_FETCH: 'Fetching - Segments', - SPLITS_FETCH: 'Fetching - Splits' -}; -// Tasks callbacks, if any -const CALLBACKS = { - [CONSTANTS.SDK_READY]: { - collector: 'client', - method: 'ready' - }, - [CONSTANTS.SDK_GET_TREATMENT]: { - collector: 'client', - method: 'getTreatment' - }, - [CONSTANTS.SDK_GET_TREATMENTS]: { - collector: 'client', - method: 'getTreatments' - }, - [CONSTANTS.SDK_GET_TREATMENT_WITH_CONFIG]: { - collector: 'client', - method: 'getTreatmentWithConfig' - }, - [CONSTANTS.SDK_GET_TREATMENTS_WITH_CONFIG]: { - collector: 'client', - method: 'getTreatmentsWithConfig' - }, - [CONSTANTS.MY_SEGMENTS_FETCH]: { - collector: 'mySegments', - method: 'latency' - }, - [CONSTANTS.SEGMENTS_FETCH]: { - collector: 'segmentChanges', - method: 'latency' - }, - [CONSTANTS.SPLITS_FETCH]: { - collector: 'splitChanges', - method: 'latency' - } -}; -/** - * Generates the timer keys using the task name and a modifier, if any. - * @param {string} task - The task name - * @param {number | string} modifier - (optional) The modifier, if any. - * @return {string} The generated timer key - */ -function generateTimerKey(task: string, modifier?: number | string) { return modifier ? task + modifier : task; } -/** - * Given the collectors map, it returns the specific collector for a given task. - * - * @param {string} task - The task name - * @param {Object} collectors - The collectors map - */ -function getCollectorForTask(task: string, collectors?: Record): false | MetricsCollector { - const callbackData = CALLBACKS[task]; - - if (callbackData && collectors) return collectors[callbackData.collector]; - - return false; -} -/** - * Given a collector and a task, returns the callback function that should be called when we stop the timer. - * - * @param {string} task - The task name - * @param {Object} collector - The collector object for the task - */ -function getCallbackForTask(task: string, collector: MetricsCollector | false): ((ms: number) => void) | false { - const callbackData = CALLBACKS[task]; - - if (callbackData && collector) return collector[callbackData.method]; - - return false; -} - -// Our "time tracker" API -export const TrackerAPI = { - /** - * "Private" method, used to attach count/countException and stop callbacks to a promise. - * - * @param {ILogger} log - Logger. - * @param {Promise} promise - The promise we want to attach the callbacks. - * @param {string} task - The name of the task. - * @param {number | string} modifier - (optional) The modifier for the task, if any. - */ - __attachToPromise(log: ILogger, promise: Promise, task: string, collector: false | MetricsCollector, modifier?: number | string) { - return promise.then(resp => { - this.stop(log, task, modifier); - - if (collector && collector.count) collector.count(resp.status); - - return resp; - }) - .catch(err => { - this.stop(log, task, modifier); - - if (collector && collector.countException) collector.countException(); - - throw err; - }); - }, - /** - * Starts tracking the time for a given task. All tasks tracked are considered "unique" because - * there may be multiple SDK instances tracking a "generic" task, making any task non-generic. - * - * @param {ILogger} log - Logger. - * @param {string} task - The task we are starting. - * @param {Object} collectors - The collectors map. - * @param {Promise} promise - (optional) The promise we are tracking. - * @return {Function | Promise} The stop function for this specific task or the promise received with the callbacks registered. - */ - start(log: ILogger, task: string, collectors?: Record, promise?: Promise, now?: () => number): Promise | (() => number) { - const taskUniqueId = uniqueId(); - const taskCollector = getCollectorForTask(task, collectors); - let result; - - // If we are registering a promise with this task, we should count the status and the exceptions as well - // as stopping the task when the promise resolves. Then return the promise - if (thenable(promise)) { - result = this.__attachToPromise(log, promise, task, taskCollector, taskUniqueId); - } else { - // If not, we return the stop function, as it will be stopped manually. - result = this.stop.bind(this, log, task, taskUniqueId); - if (CALLBACKS[task] && !taskCollector) { - // and provide a way for a defered setup of the collector, if needed. - // @ts-expect-error - result.setCollectorForTask = this.setCollectorForTask.bind(this, task, taskUniqueId); - } - } - - // We start the timer, with an uniqueId attached to it's name, and save tracking info for this task. - const trackingKey = generateTimerKey(task, taskUniqueId); - const cb = getCallbackForTask(task, taskCollector); - timers[trackingKey] = { - cb, - timer: timer(now) - }; - - return result as () => number; - }, - /** - * Setup the collector for a task that reports metrics. - * - * @param {string} task - The task name - * @param {number | string} taskUniqueId - The unique identifier for this task - * @param {Object} collectors - The collectors map. - */ - setCollectorForTask(task: string, taskUniqueId: number | string, collectors: Record) { - const taskCollector = getCollectorForTask(task, collectors); - - if (taskCollector) { - const trackingKey = generateTimerKey(task, taskUniqueId); - timers[trackingKey].cb = getCallbackForTask(task, taskCollector); - } - }, - /** - * Stops the tracking of a given task. - * - * @param {ILogger} log - Logger. - * @param {string} task - The task we are starting. - * @param {number | string} modifier - (optional) The modifier for that specific task. - */ - stop(log: ILogger, task: string, modifier?: number | string) { - const timerName = generateTimerKey(task, modifier); - const timerData = timers[timerName]; - if (timerData) { - // Stop the timer and round result for readability. - const et = timerData.timer(); - log.debug(`[TIME TRACKER]: [${task}] took ${et}ms to finish.`); - - // Check if we have a tracker callback. - if (timerData.cb) { - // If we have a callback, we call it with the elapsed time of the task and then delete the reference. - timerData.cb(et); - } - - // Remove the task tracking reference. - delete timers[timerName]; - - return et; - } - }, - /** - * The constants shortcut for the task names. - */ - TaskNames: CONSTANTS -}; diff --git a/src/utils/timeTracker/now/__tests__/now.spec.ts b/src/utils/timeTracker/now/__tests__/now.spec.ts index ded2c815..5097d894 100644 --- a/src/utils/timeTracker/now/__tests__/now.spec.ts +++ b/src/utils/timeTracker/now/__tests__/now.spec.ts @@ -16,15 +16,21 @@ limitations under the License. import { now as nowBrowser } from '../browser'; import { now as nowNode } from '../node'; +import { nearlyEqual } from '../../../../__tests__/testUtils/index'; [nowBrowser, nowNode].forEach(now => { - test('NOW / should generate a value each time you call it', () => { - let n1 = now(); - let n2 = now(); - let n3 = now(); + test('NOW / should generate a value each time you call it', async () => { + const n1 = now(); + const n2 = now(); + + const delay = 200; + await new Promise(res => setTimeout(res, delay)); + const n3 = now(); expect(Number.isFinite(n1)).toBe(true); // is a finite value? expect(Number.isFinite(n2)).toBe(true); // is a finite value? expect(Number.isFinite(n3)).toBe(true); // is a finite value? + expect(nearlyEqual(n1, n2)).toBe(true); + expect(nearlyEqual(n1 + delay, n3)).toBe(true); }); }); diff --git a/src/utils/timeTracker/now/browser.ts b/src/utils/timeTracker/now/browser.ts index 5c939606..635157f5 100644 --- a/src/utils/timeTracker/now/browser.ts +++ b/src/utils/timeTracker/now/browser.ts @@ -1,4 +1,4 @@ -// @TODO migrate to Browser SDK package eventually +// Can be used on any runtime, since it fallbacks to `Date.now` if `performance.now` is not available function nowFactory() { // eslint-disable-next-line if (typeof performance === 'object' && typeof performance.now === 'function') { diff --git a/src/utils/timeTracker/now/node.ts b/src/utils/timeTracker/now/node.ts index fe5c5f27..59d81a0f 100644 --- a/src/utils/timeTracker/now/node.ts +++ b/src/utils/timeTracker/now/node.ts @@ -1,7 +1,6 @@ -// @TODO migrate to Node SDK package eventually export function now() { // eslint-disable-next-line no-undef let time = process.hrtime(); - return time[0] * 1e3 + time[1] * 1e-6; // convert it to milis + return time[0] * 1e3 + time[1] * 1e-6; // convert it to millis } diff --git a/src/utils/timeTracker/timer.ts b/src/utils/timeTracker/timer.ts index 425f993a..ee7ca8bb 100644 --- a/src/utils/timeTracker/timer.ts +++ b/src/utils/timeTracker/timer.ts @@ -1,7 +1,7 @@ -export function timer(now?: () => number) { - const st = now ? now() : Date.now(); +export function timer(now: () => number) { + const st = now(); return function stop() { - return Math.round(now ? now() : Date.now() - st); + return Math.round(now() - st); }; }