diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index 662fe6c..0af6294 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -13,34 +13,22 @@ export type NullableHoldoutVariationType = HoldoutVariationEnum | null; */ export interface IAssignmentEvent { - /** - * An Eppo allocation key - */ + /** * An Eppo allocation key */ allocation: string | null; - /** - * An Eppo experiment key - */ + /** * An Eppo experiment key */ experiment: string | null; - /** - * An Eppo feature flag key - */ + /** * An Eppo feature flag key */ featureFlag: string; - /** - * The assigned variation - */ + /** * The assigned variation */ variation: string | null; - /** - * The entity or user that was assigned to a variation - */ + /** * The entity or user that was assigned to a variation */ subject: string; - /** - * The time the subject was exposed to the variation. - */ + /** * The time the subject was exposed to the variation. */ timestamp: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -49,15 +37,14 @@ export interface IAssignmentEvent { metaData?: Record; - /** - * The format of the flag. - */ + /** * The format of the flag. */ format: string; - /** - * The flag evaluation details. Null if the flag was precomputed. - */ + /** * The flag evaluation details. Null if the flag was precomputed. */ evaluationDetails: IFlagEvaluationDetails | null; + + /** * The entity ID of the subject, if present. */ + entityId?: number | null; } /** diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 60292b5..7f19649 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -1288,11 +1288,21 @@ export default class EppoClient { }); } - private maybeLogAssignment(result: FlagEvaluation) { - const { flagKey, format, subjectKey, allocationKey, subjectAttributes, variation } = result; + private maybeLogAssignment(result: FlagEvaluation & { entityId?: number }) { + const { + flagKey, + format, + subjectKey, + allocationKey = null, + subjectAttributes, + variation, + flagEvaluationDetails, + extraLogging = {}, + entityId, + } = result; const event: IAssignmentEvent = { - ...(result.extraLogging ?? {}), - allocation: allocationKey ?? null, + ...extraLogging, + allocation: allocationKey, experiment: allocationKey ? `${flagKey}-${allocationKey}` : null, featureFlag: flagKey, format, @@ -1301,7 +1311,8 @@ export default class EppoClient { timestamp: new Date().toISOString(), subjectAttributes, metaData: this.buildLoggerMetadata(), - evaluationDetails: result.flagEvaluationDetails, + evaluationDetails: flagEvaluationDetails, + entityId, }; if (variation && allocationKey) { diff --git a/src/eppo-assignment-logger.spec.ts b/src/eppo-assignment-logger.spec.ts new file mode 100644 index 0000000..734253a --- /dev/null +++ b/src/eppo-assignment-logger.spec.ts @@ -0,0 +1,129 @@ +import { IAssignmentEvent } from './assignment-logger'; +import EppoClient from './client/eppo-client'; +import { IConfigurationStore } from './configuration-store/configuration-store'; +import { EppoAssignmentLogger } from './eppo-assignment-logger'; +import { Flag } from './interfaces'; + +jest.mock('./client/eppo-client'); + +describe('EppoAssignmentLogger', () => { + let mockEppoClient: jest.Mocked; + let logger: EppoAssignmentLogger; + + beforeEach(() => { + jest.clearAllMocks(); + mockEppoClient = new EppoClient({ + flagConfigurationStore: {} as IConfigurationStore, + }) as jest.Mocked; + mockEppoClient.track = jest.fn(); + logger = new EppoAssignmentLogger(mockEppoClient); + }); + + it('should log assignment events correctly', () => { + // Arrange + const assignmentEvent: IAssignmentEvent = { + subject: 'user-123', + experiment: 'experiment-abc', + variant: 'control', + entityId: 456, + holdoutKey: 'holdout-xyz', + holdoutVariation: 'holdout-variant-1', + allocation: 'allocation-1', + featureFlag: 'feature-flag-1', + variation: 'variation-1', + timestamp: new Date().toISOString(), + subjectAttributes: {}, + format: 'json', + evaluationDetails: null, + }; + + // Act + logger.logAssignment(assignmentEvent); + + // Assert + expect(mockEppoClient.track).toHaveBeenCalledTimes(1); + expect(mockEppoClient.track).toHaveBeenCalledWith('__eppo_assignment', { + subject: 'user-123', + experiment: 'experiment-abc', + variation: 'variation-1', + entity_id: 456, + holdout: 'holdout-xyz', + holdout_variation: 'holdout-variant-1', + }); + }); + + it('should handle missing optional fields', () => { + // Arrange + const assignmentEvent: IAssignmentEvent = { + subject: 'user-123', + experiment: 'experiment-abc', + entityId: 789, + allocation: 'allocation-1', + featureFlag: 'feature-flag-1', + variation: 'variation-1', + timestamp: new Date().toISOString(), + subjectAttributes: {}, + format: 'json', + evaluationDetails: null, + }; + + // Act + logger.logAssignment(assignmentEvent); + + // Assert + expect(mockEppoClient.track).toHaveBeenCalledTimes(1); + expect(mockEppoClient.track).toHaveBeenCalledWith('__eppo_assignment', { + subject: 'user-123', + experiment: 'experiment-abc', + variation: 'variation-1', + entity_id: 789, + holdout: undefined, + holdout_variation: undefined, + }); + }); + + it('should skip tracking when entityId is null', () => { + // Arrange + const assignmentEvent: IAssignmentEvent = { + subject: 'user-123', + experiment: 'experiment-abc', + variant: 'control', + entityId: null, + allocation: 'allocation-1', + featureFlag: 'feature-flag-1', + variation: 'variation-1', + timestamp: new Date().toISOString(), + subjectAttributes: {}, + format: 'json', + evaluationDetails: null, + }; + + // Act + logger.logAssignment(assignmentEvent); + + // Assert + expect(mockEppoClient.track).not.toHaveBeenCalled(); + }); + + it('should skip tracking when entityId is undefined', () => { + // Arrange + const assignmentEvent: IAssignmentEvent = { + subject: 'user-123', + experiment: 'experiment-abc', + variant: 'control', + allocation: 'allocation-1', + featureFlag: 'feature-flag-1', + variation: 'variation-1', + timestamp: new Date().toISOString(), + subjectAttributes: {}, + format: 'json', + evaluationDetails: null, + }; + + // Act + logger.logAssignment(assignmentEvent); + + // Assert + expect(mockEppoClient.track).not.toHaveBeenCalled(); + }); +}); diff --git a/src/eppo-assignment-logger.ts b/src/eppo-assignment-logger.ts new file mode 100644 index 0000000..4711b98 --- /dev/null +++ b/src/eppo-assignment-logger.ts @@ -0,0 +1,37 @@ +import { IAssignmentEvent, IAssignmentLogger } from './assignment-logger'; +import EppoClient from './client/eppo-client'; + +/** + * Tracks an assignment event by submitting it to the Eppo Ingestion API. + * Events are queued up for delivery according to the EppoClient's `EventDispatcher` implementation. + */ +export class EppoAssignmentLogger implements IAssignmentLogger { + constructor(private readonly eppoClient: EppoClient) {} + + logAssignment(event: IAssignmentEvent): void { + const { + entityId: entity_id, + experiment, + // holdout and holdout_variant come from `extraLogging` in FlagEvaluation + holdoutKey: holdout, + holdoutVariation: holdout_variation, + subject, + variation, + } = event; + + // Skip tracking if no entityId + if (!entity_id) { + return; + } + + const payload = { + entity_id, + experiment, + holdout_variation, + holdout, + subject, + variation, + }; + this.eppoClient.track('__eppo_assignment', payload); + } +} diff --git a/src/index.ts b/src/index.ts index df1770b..6b15bc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { } from './configuration-wire-types'; import * as constants from './constants'; import { decodePrecomputedFlag } from './decoding'; +import { EppoAssignmentLogger } from './eppo-assignment-logger'; import BatchEventProcessor from './events/batch-event-processor'; import { BoundedEventQueue } from './events/bounded-event-queue'; import DefaultEventDispatcher, { @@ -82,6 +83,7 @@ export { IAssignmentDetails, IAssignmentHooks, IAssignmentLogger, + EppoAssignmentLogger, IAssignmentEvent, IBanditLogger, IBanditEvent, diff --git a/src/interfaces.ts b/src/interfaces.ts index c4cbe94..a84a848 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -59,6 +59,7 @@ export interface Flag { variations: Record; allocations: Allocation[]; totalShards: number; + entityId?: number; } export interface ObfuscatedFlag {