Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 11 additions & 24 deletions src/assignment-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,15 +37,14 @@ export interface IAssignmentEvent {

metaData?: Record<string, unknown>;

/**
* 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;
}

/**
Expand Down
21 changes: 16 additions & 5 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions src/eppo-assignment-logger.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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<EppoClient>;
let logger: EppoAssignmentLogger;

beforeEach(() => {
jest.clearAllMocks();
mockEppoClient = new EppoClient({
flagConfigurationStore: {} as IConfigurationStore<Flag>,
}) as jest.Mocked<EppoClient>;
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',
Copy link
Contributor

Choose a reason for hiding this comment

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

I see that holdoutKey exists in other tests, but this field does not exist on IAssignmentEvent

Copy link
Contributor Author

@felipecsl felipecsl Mar 6, 2025

Choose a reason for hiding this comment

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

yeah I mentioned this in a code comment, it's confusing. this is the line:

https://github.com/Eppo-exp/js-sdk-common/blob/main/src/assignment-logger.ts#L48

IAssignmentEvent can hold arbitrary key/value pairs from extraLogging in FlagEvaluation

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_id: 'user-123',
experiment: 'experiment-abc',
variant: 'control',
entity_id: 456,
holdout: 'holdout-xyz',
holdout_variant: 'holdout-variant-1',
});
});

it('should handle missing optional fields', () => {
// Arrange
const assignmentEvent: IAssignmentEvent = {
subject: 'user-123',
experiment: 'experiment-abc',
variant: 'control',
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_id: 'user-123',
experiment: 'experiment-abc',
variant: 'control',
entity_id: 789,
holdout: undefined,
holdout_variant: 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();
});
});
37 changes: 37 additions & 0 deletions src/eppo-assignment-logger.ts
Original file line number Diff line number Diff line change
@@ -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_variant,
subject: subject_id,
variant,
} = event;

// Skip tracking if no entityId
if (!entity_id) {
return;
}

const payload = {
entity_id,
experiment,
holdout_variant,
holdout,
subject_id,
variant,
};
this.eppoClient.track('__eppo_assignment', payload);
}
}
1 change: 1 addition & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface Flag {
variations: Record<string, Variation>;
allocations: Allocation[];
totalShards: number;
entityId?: number;
}

export interface ObfuscatedFlag {
Expand Down
Loading