diff --git a/package.json b/package.json index cf21e229fd..204f37e542 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "packageManager": "yarn@3.4.1", "//": "Pin jsonc-parser because v3.3.0 breaks rollup-plugin-esbuild", "resolutions": { - "jsonc-parser": "3.2.0" + "jsonc-parser": "3.2.0", + "parse5": "7.2.1" } } diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index c1c280d483..6fef4c6519 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -42,6 +42,7 @@ export default class TestHarnessWebSocket { 'anonymous-redaction', 'strongly-typed', 'client-prereq-events', + 'client-per-context-summaries', ]; break; diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index d61a02f273..92686c2a7d 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -71,7 +71,7 @@ "eslint-plugin-jest": "^27.6.3", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "29.7.0", "prettier": "^3.0.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", diff --git a/packages/sdk/react-native/example/App.tsx b/packages/sdk/react-native/example/App.tsx index fad2c67a35..274d171bd6 100644 --- a/packages/sdk/react-native/example/App.tsx +++ b/packages/sdk/react-native/example/App.tsx @@ -8,8 +8,9 @@ import { import Welcome from './src/welcome'; -const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, { +const featureClient = new ReactNativeLDClient("mob-8b772ad8-5d5b-435f-982a-900fa5db47e6", AutoEnvAttributes.Enabled, { debug: true, + eventsUri: 'https://eb5d04264133.ngrok.app', applicationInfo: { id: 'ld-rn-test-app', version: '0.0.1', diff --git a/packages/sdk/react-native/example/src/welcome.tsx b/packages/sdk/react-native/example/src/welcome.tsx index f726fe8659..baf70ffba1 100644 --- a/packages/sdk/react-native/example/src/welcome.tsx +++ b/packages/sdk/react-native/example/src/welcome.tsx @@ -24,7 +24,7 @@ export default function Welcome() { return ( - Welcome to LaunchDarkly + Welcome to LaunchDarkly!! {flagKey}: {`${flagValue}`} diff --git a/packages/shared/common/__tests__/Context.test.ts b/packages/shared/common/__tests__/Context.test.ts index 2bb450e47b..ab9928e119 100644 --- a/packages/shared/common/__tests__/Context.test.ts +++ b/packages/shared/common/__tests__/Context.test.ts @@ -1,5 +1,6 @@ import AttributeReference from '../src/AttributeReference'; import Context from '../src/Context'; +import { setupCrypto } from './setupCrypto'; // A sample of invalid characters. const invalidSampleChars = [ @@ -325,3 +326,372 @@ describe('given a multi context', () => { expect(Context.toLDContext(input)).toEqual(expected); }); }); + +describe('given mock crypto', () => { + const crypto = setupCrypto(); + + it('hashes two equal contexts the same', async () => { + const a = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }, + customer: { + key: 'testKey', + name: 'testName', + bird: 'party parrot', + chicken: 'hen', + }, + }); + + const b = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }, + customer: { + key: 'testKey', + name: 'testName', + bird: 'party parrot', + chicken: 'hen', + }, + }); + expect(await a.hash(crypto)).toEqual(await b.hash(crypto)); + }); + + it('hashes legacy and non-legacy equivalent contexts the same', async () => { + const legacy = Context.fromLDContext({ + key: 'testKey', + name: 'testName', + custom: { cat: 'calico', dog: 'lab' }, + anonymous: true, + privateAttributeNames: ['cat', 'dog'], + }); + + const nonLegacy = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['cat', 'dog'], + }, + }); + + expect(await legacy.hash(crypto)).toEqual(await nonLegacy.hash(crypto)); + }); + + it('hashes single context and multi-context with one kind the same', async () => { + const single = Context.fromLDContext({ + kind: 'org', + key: 'testKey', + name: 'testName', + value: 'testValue', + }); + + const multi = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + value: 'testValue', + }, + }); + + expect(await single.hash(crypto)).toEqual(await multi.hash(crypto)); + }); + + it('handles shared references without getting stuck', async () => { + const sharedObject = { value: 'shared' }; + const context = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + shared: sharedObject, + }, + user: { + key: 'testKey', + shared: sharedObject, + }, + }); + + const hash = await context.hash(crypto); + expect(hash).toBeDefined(); + }); + + it('returns undefined for contexts with cycles', async () => { + const cyclicObject: any = { value: 'cyclic' }; + cyclicObject.self = cyclicObject; + + const context = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + cyclic: cyclicObject, + }); + + expect(await context.hash(crypto)).toBeUndefined(); + }); + + it('handles nested objects correctly', async () => { + const context = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + value: 'deep', + }, + }, + }, + }); + + const hash = await context.hash(crypto); + expect(hash).toBeDefined(); + }); + + it('handles arrays correctly', async () => { + const context = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + array: [1, 2, 3], + nestedArray: [ + [1, 2], + [3, 4], + ], + }); + + const hash = await context.hash(crypto); + expect(hash).toBeDefined(); + }); + + it('handles primitive values correctly', async () => { + const context = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + string: 'test', + number: 42, + boolean: true, + nullValue: null, + undefinedValue: undefined, + }); + + const hash = await context.hash(crypto); + expect(hash).toBeDefined(); + }); + + it('includes private attributes in hash calculation', async () => { + const baseContext = { + kind: 'user', + key: 'testKey', + name: 'testName', + nested: { + value: 'testValue', + }, + }; + + const contextWithPrivate = Context.fromLDContext({ + ...baseContext, + _meta: { + privateAttributes: ['name', 'nested/value'], + }, + }); + + const contextWithoutPrivate = Context.fromLDContext(baseContext); + + const hashWithPrivate = await contextWithPrivate.hash(crypto); + const hashWithoutPrivate = await contextWithoutPrivate.hash(crypto); + + // The hashes should be different because private attributes are included in the hash + expect(hashWithPrivate).not.toEqual(hashWithoutPrivate); + }); + + it('uses the keys the keys of attributes in the hash', async () => { + const a = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + a: 'b', + }); + + const b = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + b: 'b', + }); + + const hashA = await a.hash(crypto); + const hashB = await b.hash(crypto); + expect(hashA).not.toBe(hashB); + }); + + it('uses the keys of nested objects inside the hash', async () => { + const a = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + value: 'deep', + }, + }, + }, + }); + + const b = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + sub1: { + sub2: { + value: 'deep', + }, + }, + }, + }); + + const hashA = await a.hash(crypto); + const hashB = await b.hash(crypto); + expect(hashA).not.toBe(hashB); + }); + + it('it uses the values of nested array in calculations', async () => { + const a = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + array: [1, 2, 3], + nestedArray: [ + [1, 2], + [3, 4], + ], + }); + + const b = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + array: [1, 2, 3], + nestedArray: [ + [2, 1], + [3, 4], + ], + }); + + const hashA = await a.hash(crypto); + const hashB = await b.hash(crypto); + expect(hashA).not.toBe(hashB); + }); + + it('uses the values of nested objects inside the hash', async () => { + const a = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + value: 'deep', + }, + }, + }, + }); + + const b = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + value: 'deeper', + }, + }, + }, + }); + + const hashA = await a.hash(crypto); + const hashB = await b.hash(crypto); + expect(hashA).not.toBe(hashB); + }); + + it('hashes _meta in attributes', async () => { + const a = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + _meta: { test: 'a' }, + }, + }, + }, + }); + + const b = Context.fromLDContext({ + kind: 'user', + key: 'testKey', + nested: { + level1: { + level2: { + _meta: { test: 'b' }, + }, + }, + }, + }); + + const hashA = await a.hash(crypto); + const hashB = await b.hash(crypto); + expect(hashA).not.toBe(hashB); + }); + + it('produces the same value for the given context', async () => { + // This isn't so much a test as it is a detection of change. + // If this test failed, and you didn't expect it, then you probably need to make sure your + // change makes sense. + const complexContext = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + nestedArray: [ + [1, 2], + [3, 4], + ], + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }, + customer: { + key: 'testKey', + name: 'testName', + bird: 'party parrot', + chicken: 'hen', + nested: { + level1: { + level2: { + value: 'deep', + _meta: { thisShouldBeInTheHash: true }, + }, + }, + }, + }, + }); + expect(await complexContext.hash(crypto)).toBe( + 'customerbirdchickenkeynamenestedorganonymouscatdogkeynamenestedArraya/b/ccatcustom/dog01length201length24301length221testNametestKeylabcalicotruelevel1level2_metavaluedeepthisShouldBeInTheHashtruetestNametestKeyhenparty parrot', + ); + }); +}); diff --git a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts index 1fd713a9f7..fcf50fff00 100644 --- a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts @@ -7,10 +7,6 @@ describe('given an event summarizer', () => { const summarizer = new EventSummarizer(); const context = Context.fromLDContext({ key: 'key' }); - beforeEach(() => { - summarizer.clearSummary(); - }); - it('does nothing for an identify event.', () => { const beforeSummary = summarizer.getSummary(); summarizer.summarizeEvent(new InputIdentifyEvent(context)); @@ -300,4 +296,25 @@ describe('given an event summarizer', () => { }; expect(data.features).toEqual(expectedFeatures); }); + + it('automatically clears summaries after getSummary() is called', () => { + const event = { + kind: 'feature', + creationDate: 1000, + key: 'key1', + version: 11, + context, + variation: 1, + value: 100, + default: 111, + }; + + summarizer.summarizeEvent(event as any); + summarizer.getSummary(); + + const secondSummary = summarizer.getSummary(); + expect(secondSummary.features).toEqual({}); + expect(secondSummary.startDate).toBe(0); + expect(secondSummary.endDate).toBe(0); + }); }); diff --git a/packages/shared/common/__tests__/setupCrypto.ts b/packages/shared/common/__tests__/setupCrypto.ts index 72b2d13efc..79be779609 100644 --- a/packages/shared/common/__tests__/setupCrypto.ts +++ b/packages/shared/common/__tests__/setupCrypto.ts @@ -1,14 +1,43 @@ +/* eslint-disable max-classes-per-file */ import { Hasher } from '../src/api'; +class MockHasher implements Hasher { + private _state: string[] = []; + + update(value: string): Hasher { + this._state.push(value); + return this; + } + + digest(): string { + const result = this._state.join(''); + this._state = []; // Reset state after digest + return result; + } +} + +class MockAsyncHasher implements Hasher { + private _state: string[] = []; + + update(value: string): Hasher { + this._state.push(value); + return this; + } + + async asyncDigest(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(this._state.join('')); + }, 1); + }); + } +} + export const setupCrypto = () => { let counter = 0; - const hasher = { - update: jest.fn((): Hasher => hasher), - digest: jest.fn(() => '1234567890123456'), - }; return { - createHash: jest.fn(() => hasher), + createHash: jest.fn(() => new MockHasher()), createHmac: jest.fn(), randomUUID: jest.fn(() => { counter += 1; @@ -18,3 +47,8 @@ export const setupCrypto = () => { }), }; }; + +export const setupAsyncCrypto = () => ({ + ...setupCrypto(), + createHash: jest.fn(() => new MockAsyncHasher()), +}); diff --git a/packages/shared/common/src/AttributeReference.ts b/packages/shared/common/src/AttributeReference.ts index 4973818317..6ac4e4e826 100644 --- a/packages/shared/common/src/AttributeReference.ts +++ b/packages/shared/common/src/AttributeReference.ts @@ -144,4 +144,8 @@ export default class AttributeReference { this._components.every((value, index) => value === other.getComponent(index)) ); } + + public get components() { + return [...this._components]; + } } diff --git a/packages/shared/common/src/Context.ts b/packages/shared/common/src/Context.ts index 6b4a0bca7e..dccc0ab135 100644 --- a/packages/shared/common/src/Context.ts +++ b/packages/shared/common/src/Context.ts @@ -1,6 +1,7 @@ /* eslint-disable no-underscore-dangle */ // eslint-disable-next-line max-classes-per-file import type { + Crypto, LDContext, LDContextCommon, LDMultiKindContext, @@ -465,4 +466,71 @@ export default class Context { public get legacy(): boolean { return this._wasLegacy; } + + public async hash(crypto: Crypto): Promise { + if (!this.valid) { + return undefined; + } + + const hasher = crypto.createHash('sha256'); + + const stack: { + target: any; + visited: any[]; + }[] = []; + + const kinds = this.kinds.sort(); + + kinds.forEach((kind) => { + hasher.update(kind); + const context = this._contextForKind(kind)!; + Object.getOwnPropertyNames(context) + .sort() + .forEach((key) => { + // Handled using private attributes. + if (key === '_meta') { + return; + } + hasher.update(key); + stack.push({ + target: context[key], + visited: [context], + }); + }); + + const sortedAttributes = this.privateAttributes(kind) + .map((attr) => attr.components.join('/')) + .sort(); + sortedAttributes.forEach((attr) => hasher.update(attr)); + }); + + while (stack.length > 0) { + const { target, visited } = stack.pop()!; + if (visited.includes(target)) { + return undefined; + } + visited.push(target); + if (typeof target === 'object' && target !== null && target !== undefined) { + Object.getOwnPropertyNames(target) + .sort() + .forEach((key) => { + hasher.update(key); + stack.push({ + target: target[key], + visited: [...visited, target], + }); + }); + } else { + hasher.update(String(target)); + } + } + + if (hasher.digest) { + return hasher.digest('hex'); + } + + // The hasher must have either digest or asyncDigest. + const digest = await hasher.asyncDigest!('hex'); + return digest; + } } diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index e6963e5efe..17a6608d23 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -8,12 +8,17 @@ import { ClientContext } from '../../options'; import { LDHeaders } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import EventSender from './EventSender'; -import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; +import EventSummarizer from './EventSummarizer'; import { isFeature, isIdentify, isMigration } from './guards'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import LDEventSummarizer, { + LDMultiEventSummarizer, + SummarizedFlagsEvent, +} from './LDEventSummarizer'; import LDInvalidSDKKeyError from './LDInvalidSDKKeyError'; +import MultiEventSummarizer from './MultiEventSummarizer'; import shouldSample from './sampling'; type FilteredContext = any; @@ -86,7 +91,7 @@ type DiagnosticEvent = any; interface MigrationOutputEvent extends Omit { // Make the sampling ratio optional so we can omit it when it is one. samplingRatio?: number; - // Context is optional because contextKeys is supported for backwards compatbility and may be provided instead of context. + // Context is optional because contextKeys is supported for backwards compatibility and may be provided instead of context. context?: FilteredContext; } @@ -106,9 +111,13 @@ export interface EventProcessorOptions { diagnosticRecordingInterval: number; } +function isMultiEventSummarizer(summarizer: unknown): summarizer is LDMultiEventSummarizer { + return (summarizer as LDMultiEventSummarizer).getSummaries !== undefined; +} + export default class EventProcessor implements LDEventProcessor { private _eventSender: EventSender; - private _summarizer = new EventSummarizer(); + private _summarizer: LDMultiEventSummarizer | LDEventSummarizer; private _queue: OutputEvent[] = []; private _lastKnownPastTime = 0; private _droppedEvents = 0; @@ -133,6 +142,7 @@ export default class EventProcessor implements LDEventProcessor { private readonly _contextDeduplicator?: LDContextDeduplicator, private readonly _diagnosticsManager?: DiagnosticsManager, start: boolean = true, + summariesPerContext: boolean = false, ) { this._capacity = _config.eventsCapacity; this._logger = clientContext.basicConfiguration.logger; @@ -143,6 +153,15 @@ export default class EventProcessor implements LDEventProcessor { _config.privateAttributes.map((ref) => new AttributeReference(ref)), ); + if (summariesPerContext) { + this._summarizer = new MultiEventSummarizer( + clientContext.platform.crypto, + this._contextFilter, + ); + } else { + this._summarizer = new EventSummarizer(); + } + if (start) { this.start(); } @@ -210,11 +229,20 @@ export default class EventProcessor implements LDEventProcessor { const eventsToFlush = this._queue; this._queue = []; - const summary = this._summarizer.getSummary(); - this._summarizer.clearSummary(); - if (Object.keys(summary.features).length) { - eventsToFlush.push(summary); + if (isMultiEventSummarizer(this._summarizer)) { + const summaries = await this._summarizer.getSummaries(); + + summaries.forEach((summary) => { + if (Object.keys(summary.features).length) { + eventsToFlush.push(summary); + } + }); + } else { + const summary = this._summarizer.getSummary(); + if (Object.keys(summary.features).length) { + eventsToFlush.push(summary); + } } if (!eventsToFlush.length) { diff --git a/packages/shared/common/src/internal/events/EventSummarizer.ts b/packages/shared/common/src/internal/events/EventSummarizer.ts index 2e34f2e674..9bd68a21ea 100644 --- a/packages/shared/common/src/internal/events/EventSummarizer.ts +++ b/packages/shared/common/src/internal/events/EventSummarizer.ts @@ -1,6 +1,13 @@ +import Context from '../../Context'; +import ContextFilter from '../../ContextFilter'; import { isFeature } from './guards'; import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; +import LDEventSummarizer, { + FlagCounter, + FlagSummary, + SummarizedFlagsEvent, +} from './LDEventSummarizer'; import SummaryCounter from './SummaryCounter'; function counterKey(event: InputEvalEvent) { @@ -12,37 +19,7 @@ function counterKey(event: InputEvalEvent) { /** * @internal */ -export interface FlagCounter { - value: any; - count: number; - variation?: number; - version?: number; - unknown?: boolean; -} - -/** - * @internal - */ -export interface FlagSummary { - default: any; - counters: FlagCounter[]; - contextKinds: string[]; -} - -/** - * @internal - */ -export interface SummarizedFlagsEvent { - startDate: number; - endDate: number; - features: Record; - kind: 'summary'; -} - -/** - * @internal - */ -export default class EventSummarizer { +export default class EventSummarizer implements LDEventSummarizer { private _startDate = 0; private _endDate = 0; @@ -51,8 +28,18 @@ export default class EventSummarizer { private _contextKinds: Record> = {}; + private _context?: Context; + + constructor( + private readonly _singleContext: boolean = false, + private readonly _contextFilter?: ContextFilter, + ) {} + summarizeEvent(event: InputEvent) { if (isFeature(event) && !event.excludeFromSummaries) { + if (!this._context) { + this._context = event.context; + } const countKey = counterKey(event); const counter = this._counters[countKey]; let kinds = this._contextKinds[event.key]; @@ -116,15 +103,21 @@ export default class EventSummarizer { {}, ); - return { + const event: SummarizedFlagsEvent = { startDate: this._startDate, endDate: this._endDate, features, kind: 'summary', + context: + this._context !== undefined && this._singleContext + ? this._contextFilter?.filter(this._context) + : undefined, }; + this._clearSummary(); + return event; } - clearSummary() { + private _clearSummary() { this._startDate = 0; this._endDate = 0; this._counters = {}; diff --git a/packages/shared/common/src/internal/events/LDEventSummarizer.ts b/packages/shared/common/src/internal/events/LDEventSummarizer.ts new file mode 100644 index 0000000000..d0d80da059 --- /dev/null +++ b/packages/shared/common/src/internal/events/LDEventSummarizer.ts @@ -0,0 +1,66 @@ +import InputEvent from './InputEvent'; + +/** + * @internal + */ +export interface FlagCounter { + value: any; + count: number; + variation?: number; + version?: number; + unknown?: boolean; +} + +/** + * @internal + */ +export interface FlagSummary { + default: any; + counters: FlagCounter[]; + contextKinds: string[]; +} + +/** + * @internal + */ +export interface SummarizedFlagsEvent { + startDate: number; + endDate: number; + features: Record; + kind: 'summary'; + context?: any; +} + +/** + * Interface for summarizing feature flag evaluations bucketed by the context. + */ +export interface LDMultiEventSummarizer { + /** + * Processes an event for summarization if it is a feature flag event and not excluded from summaries. + * @param event The event to potentially summarize + */ + summarizeEvent(event: InputEvent): void; + + /** + * Gets the current summary of processed events. + * @returns A summary of all processed feature flag events + */ + getSummaries(): Promise; +} + +/** + * Interface for summarizing feature flag evaluation events. + */ +export default interface LDEventSummarizer { + /** + * Processes an event for summarization if it is a feature flag event and not excluded from summaries. + * @param event The event to potentially summarize + */ + summarizeEvent(event: InputEvent): void; + + /** + * Gets the current summary of processed events. + * @returns A summary of all processed feature flag events + */ + getSummary(): SummarizedFlagsEvent; +} diff --git a/packages/shared/common/src/internal/events/MultiEventSummarizer.test.ts b/packages/shared/common/src/internal/events/MultiEventSummarizer.test.ts new file mode 100644 index 0000000000..8fa5c9b828 --- /dev/null +++ b/packages/shared/common/src/internal/events/MultiEventSummarizer.test.ts @@ -0,0 +1,76 @@ +import { setupAsyncCrypto, setupCrypto } from '../../../__tests__/setupCrypto'; +import Context from '../../Context'; +import ContextFilter from '../../ContextFilter'; +import InputEvalEvent from './InputEvalEvent'; +import InputIdentifyEvent from './InputIdentifyEvent'; +import MultiEventSummarizer from './MultiEventSummarizer'; + +// Test with both sync and async crypto implementations +describe.each([ + ['sync', setupCrypto()], + ['async', setupAsyncCrypto()], +])('with mocked crypto and hasher/%s', (_name, crypto) => { + let summarizer: MultiEventSummarizer; + + beforeEach(() => { + const contextFilter = new ContextFilter(false, []); + summarizer = new MultiEventSummarizer(crypto, contextFilter); + }); + + test('creates new summarizer for new context hash', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user1' }); + const event = new InputEvalEvent(true, context, 'flag-key', 'value', 'default', 1, 0, true); + + summarizer.summarizeEvent(event); + + const summaries = await summarizer.getSummaries(); + expect(summaries).toHaveLength(1); + }); + + test('uses existing summarizer for same context hash', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user1' }); + const event1 = new InputEvalEvent(true, context, 'flag-key', 'value1', 'default', 1, 0, true); + const event2 = new InputEvalEvent(true, context, 'flag-key', 'value2', 'default', 1, 0, true); + + summarizer.summarizeEvent(event1); + summarizer.summarizeEvent(event2); + + const summaries = await summarizer.getSummaries(); + expect(summaries).toHaveLength(1); + }); + + test('ignores non-feature events', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user1' }); + const event = new InputIdentifyEvent(context); + + summarizer.summarizeEvent(event); + + const summaries = await summarizer.getSummaries(); + expect(summaries).toHaveLength(0); + }); + + test('handles multiple different contexts', async () => { + const context1 = Context.fromLDContext({ kind: 'user', key: 'user1' }); + const context2 = Context.fromLDContext({ kind: 'user', key: 'user2' }); + const event1 = new InputEvalEvent(true, context1, 'flag-key', 'value1', 'default', 1, 0, true); + const event2 = new InputEvalEvent(true, context2, 'flag-key', 'value2', 'default', 1, 0, true); + + summarizer.summarizeEvent(event1); + summarizer.summarizeEvent(event2); + + const summaries = await summarizer.getSummaries(); + expect(summaries).toHaveLength(2); + }); + + test('automatically clears summaries when summarized', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user1' }); + const event = new InputEvalEvent(true, context, 'flag-key', 'value', 'default', 1, 0, true); + + summarizer.summarizeEvent(event); + + const summariesA = await summarizer.getSummaries(); + const summariesB = await summarizer.getSummaries(); + expect(summariesA).toHaveLength(1); + expect(summariesB).toHaveLength(0); + }); +}); diff --git a/packages/shared/common/src/internal/events/MultiEventSummarizer.ts b/packages/shared/common/src/internal/events/MultiEventSummarizer.ts new file mode 100644 index 0000000000..8981cf1723 --- /dev/null +++ b/packages/shared/common/src/internal/events/MultiEventSummarizer.ts @@ -0,0 +1,57 @@ +import { Crypto } from '../../api'; +import ContextFilter from '../../ContextFilter'; +import EventSummarizer from './EventSummarizer'; +import { isFeature } from './guards'; +import InputEvent from './InputEvent'; +import { LDMultiEventSummarizer, SummarizedFlagsEvent } from './LDEventSummarizer'; + +export default class MultiEventSummarizer implements LDMultiEventSummarizer { + constructor( + private readonly _crypto: Crypto, + private readonly _contextFilter: ContextFilter, + ) {} + private _summarizers: Record = {}; + private _pendingPromises: Promise[] = []; + + summarizeEvent(event: InputEvent) { + // The event is summarized asynchronously, but the promise is created synchronously, this means that all events + // which have been requested to be summarized will be in the next flush. + const promise = (async () => { + if (isFeature(event)) { + const hash = await event.context.hash(this._crypto); + if (!hash) { + return; + } + // It is important that async operations do not happen between checking that the summarizer + // exists and having it summarize the event. + // If it did, then that event could be lost. + let summarizer = this._summarizers[hash]; + if (!summarizer) { + this._summarizers[hash] = new EventSummarizer(true, this._contextFilter); + summarizer = this._summarizers[hash]; + } + + summarizer.summarizeEvent(event); + } + })(); + this._pendingPromises.push(promise); + promise.finally(() => { + const index = this._pendingPromises.indexOf(promise); + if (index !== -1) { + this._pendingPromises.splice(index, 1); + } + }); + } + + async getSummaries(): Promise { + // Wait for any pending summarizations to complete + // Additional tasks queued while waiting will not be waited for. + await Promise.all([...this._pendingPromises]); + + // It is important not to put any async operations between caching the summarizers and clearing them. + // If we did then summerizers added during the async operation would be lost. + const summarizersToFlush = this._summarizers; + this._summarizers = {}; + return Object.values(summarizersToFlush).map((summarizer) => summarizer.getSummary()); + } +} diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index ab4887925c..bc8e2e05a3 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -17,6 +17,7 @@ const createEventProcessor = ( undefined, diagnosticsManager, false, + true, ); }