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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions flagsmith-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export enum FlagSource {
"SERVER" = "SERVER",
}

export enum PipelineEventType {
FLAG_EVALUATION = 'flag_evaluation',
CUSTOM_EVENT = 'custom_event',
}

export type LikeFetch = (input: Partial<RequestInfo>, init?: Partial<RequestInit>) => Promise<Partial<Response>>
let _fetch: LikeFetch;

Expand Down Expand Up @@ -333,12 +338,12 @@ const Flagsmith = class {
sentryClient: ISentryClient | null = null
withTraits?: ITraits|null= null
cacheOptions = {ttl:0, skipAPI: false, loadStale: false, storageKey: undefined as string|undefined}
evaluationAnalyticsUrl: string | null = null
evaluationAnalyticsMaxBuffer: number = 1000
pipelineEvents: IPipelineEvent[] = []
pipelineAnalyticsInterval: ReturnType<typeof setInterval> | null = null
isPipelineFlushing = false
pipelineRecordedKeys: Map<string, string> = new Map()
private evaluationAnalyticsUrl: string | null = null
private evaluationAnalyticsMaxBuffer: number = 1000
private pipelineEvents: IPipelineEvent[] = []
private pipelineAnalyticsInterval: ReturnType<typeof setInterval> | null = null
private isPipelineFlushing = false
private pipelineRecordedKeys: Map<string, string> = new Map()
async init(config: IInitConfig) {
const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext);
try {
Expand Down Expand Up @@ -1007,8 +1012,49 @@ const Flagsmith = class {
this.pipelineRecordedKeys.clear();
}

private currentTraitsSnapshot() {
return this.evaluationContext.identity?.traits
? { ...this.evaluationContext.identity.traits }
: null;
}

private getPageUrl(): string | null {
return typeof window !== 'undefined' && window.location ? window.location.href : null;
}

private getEventMetadata(extra?: Record<string, unknown>): Record<string, unknown> {
const pageUrl = this.getPageUrl();
return {
...(extra || {}),
...(pageUrl ? { page_url: pageUrl } : {}),
...(SDK_VERSION ? { sdk_version: SDK_VERSION } : {}),
};
}

// Pipeline event schema — must match the pipeline server's Event struct.
// To update: 1) IPipelineEvent in types.d.ts 2) event object below 3) tests in test/analytics-pipeline.test.ts
private buildAnalyticEvent(
eventType: PipelineEventType,
eventId: string,
options?: {
enabled?: boolean | null;
value?: any;
extraMetadata?: Record<string, unknown>;
timestamp?: number;
},
): IPipelineEvent {
return {
event_id: eventId,
event_type: eventType,
evaluated_at: options?.timestamp ?? Date.now(),
identity_identifier: this.evaluationContext.identity?.identifier ?? null,
enabled: options?.enabled ?? null,
value: options?.value ?? null,
traits: this.currentTraitsSnapshot(),
metadata: this.getEventMetadata(options?.extraMetadata),
};
}

private recordPipelineEvent(key: string) {
const flagKey = key.toLowerCase().replace(/ /g, '_');
const flag = this.flags && this.flags[flagKey];
Expand All @@ -1017,22 +1063,25 @@ const Flagsmith = class {
return;
}
this.pipelineRecordedKeys.set(flagKey, fingerprint);
const event: IPipelineEvent = {
event_id: flagKey,
event_type: 'flag_evaluation',
evaluated_at: Date.now(),
identity_identifier: this.evaluationContext.identity?.identifier ?? null,
const event = this.buildAnalyticEvent(PipelineEventType.FLAG_EVALUATION, flagKey, {
enabled: flag ? flag.enabled : null,
value: flag ? flag.value : null,
traits: this.evaluationContext.identity?.traits
? { ...this.evaluationContext.identity.traits }
: null,
metadata: {
...(flag ? { id: flag.id } : {}),
...(typeof window !== 'undefined' && window.location ? { page_url: window.location.href } : {}),
...(SDK_VERSION ? { sdk_version: SDK_VERSION } : {}),
},
};
extraMetadata: flag ? { id: flag.id } : undefined,
});
this.pipelineEvents.push(event);

if (this.pipelineFlushInterval === 0 || this.pipelineEvents.length >= this.evaluationAnalyticsMaxBuffer) {
this.flushPipelineAnalytics();
}
}

trackEvent = (eventName: string, metadata?: Record<string, unknown>) => {
if (!this.evaluationAnalyticsUrl || !eventName) {
return;
}
const event = this.buildAnalyticEvent(PipelineEventType.CUSTOM_EVENT, eventName, {
extraMetadata: metadata,
});
this.pipelineEvents.push(event);

if (this.pipelineFlushInterval === 0 || this.pipelineEvents.length >= this.evaluationAnalyticsMaxBuffer) {
Expand Down
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ export default flagsmith;
export const createFlagsmithInstance = ():IFlagsmith=>{
return core({ AsyncStorage, fetch:_fetch, eventSource:_EventSource})
}
export { FlagSource } from './flagsmith-core';
export { FlagSource, PipelineEventType } from './flagsmith-core';
151 changes: 151 additions & 0 deletions test/analytics-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getFlagsmith, environmentID, testIdentity } from './test-constants';
import { PipelineEventType } from '../lib/flagsmith';

const pipelineUrl = 'https://analytics.flagsmith.com/';

Expand Down Expand Up @@ -218,3 +219,153 @@ describe('Pipeline Analytics', () => {
expect(flagsmith.pipelineEvents[0].event_id).toBe('hero');
});
});

const defaultPipelineConfig = {
evaluationAnalyticsConfig: {
analyticsServerUrl: pipelineUrl,
flushInterval: 60000,
},
};

function getCustomEvents(flagsmith: any) {
return flagsmith.pipelineEvents.filter((e: any) => e.event_type === PipelineEventType.CUSTOM_EVENT);
}

describe('trackEvent (custom events)', () => {
test('sends custom_event with correct shape', async () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith({
...defaultPipelineConfig,
identity: testIdentity,
});
await flagsmith.init(initConfig);

flagsmith.trackEvent('checkout', { item: 'shoes', price: 99 });
// @ts-ignore
await flagsmith.flushPipelineAnalytics();

const calls = getPipelineCalls(mockFetch);
expect(calls).toHaveLength(1);

const event = JSON.parse(calls[0][1].body).events[0];
expect(event).toEqual(expect.objectContaining({
event_id: 'checkout',
event_type: PipelineEventType.CUSTOM_EVENT,
enabled: null,
value: null,
identity_identifier: testIdentity,
}));
expect(event.evaluated_at).toEqual(expect.any(Number));
expect(event.metadata).toEqual(expect.objectContaining({ item: 'shoes', price: 99 }));
expect(event.metadata.sdk_version).toBeDefined();
});

test('no-ops when evaluationAnalyticsConfig is not set', async () => {
const { flagsmith, initConfig } = getFlagsmith();
await flagsmith.init(initConfig);

flagsmith.trackEvent('checkout');

// @ts-ignore
expect(flagsmith.pipelineEvents).toHaveLength(0);
});

test('no-ops when eventName is empty', async () => {
const { flagsmith, initConfig } = getFlagsmith({
...defaultPipelineConfig,
identity: testIdentity,
});
await flagsmith.init(initConfig);

flagsmith.trackEvent('');

// @ts-ignore
expect(flagsmith.pipelineEvents).toHaveLength(0);
});

test('tracks events with null identity when not identified', async () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith(defaultPipelineConfig);
await flagsmith.init(initConfig);

flagsmith.trackEvent('page_view', { page: '/home' });
flagsmith.trackEvent('signup');

const custom = getCustomEvents(flagsmith);
expect(custom).toHaveLength(2);
expect(custom[0].event_id).toBe('page_view');
expect(custom[0].identity_identifier).toBeNull();
expect(custom[0].metadata).toEqual(expect.objectContaining({ page: '/home' }));
expect(custom[1].event_id).toBe('signup');
expect(custom[1].identity_identifier).toBeNull();

// @ts-ignore
await flagsmith.flushPipelineAnalytics();
const calls = getPipelineCalls(mockFetch);
expect(calls).toHaveLength(1);
});

test('tracks events with identity after identify', async () => {
const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig);
await flagsmith.init(initConfig);

flagsmith.trackEvent('anonymous_action');
await flagsmith.identify(testIdentity);
flagsmith.trackEvent('identified_action');

const custom = getCustomEvents(flagsmith);
expect(custom).toHaveLength(2);
expect(custom[0].identity_identifier).toBeNull();
expect(custom[1].identity_identifier).toBe(testIdentity);
});

test('flushes immediately when flushInterval is 0', async () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith({
evaluationAnalyticsConfig: {
analyticsServerUrl: pipelineUrl,
flushInterval: 0,
},
identity: testIdentity,
});
await flagsmith.init(initConfig);

flagsmith.trackEvent('instant_event');

const calls = getPipelineCalls(mockFetch);
expect(calls.length).toBeGreaterThanOrEqual(1);

const allEvents = calls.flatMap(
([, opts]: [string, any]) => JSON.parse(opts.body).events
);
const custom = allEvents.filter((e: any) => e.event_type === PipelineEventType.CUSTOM_EVENT);
expect(custom).toHaveLength(1);
expect(custom[0].event_id).toBe('instant_event');
});

test('does not deduplicate - each call produces a distinct event', async () => {
const { flagsmith, initConfig } = getFlagsmith({
...defaultPipelineConfig,
identity: testIdentity,
});
await flagsmith.init(initConfig);

flagsmith.trackEvent('click');
flagsmith.trackEvent('click');
flagsmith.trackEvent('click');

expect(getCustomEvents(flagsmith)).toHaveLength(3);
});

test('tracks with null identity after logout', async () => {
const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig);
await flagsmith.init(initConfig);

await flagsmith.identify(testIdentity);
flagsmith.trackEvent('while_identified');
expect(getCustomEvents(flagsmith)[0].identity_identifier).toBe(testIdentity);

await flagsmith.logout();

flagsmith.trackEvent('after_logout');
const custom = getCustomEvents(flagsmith);
expect(custom[custom.length - 1].identity_identifier).toBeNull();
});
});
15 changes: 12 additions & 3 deletions types.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EvaluationContext, IdentityEvaluationContext, TraitEvaluationContext } from "./evaluation-context";
import { FlagSource } from "./flagsmith-core";
import { FlagSource, PipelineEventType } from "./flagsmith-core";

type IFlagsmithValue<T = string | number | boolean | null> = T

Expand Down Expand Up @@ -85,7 +85,7 @@ export type ISentryClient = {
} | undefined;


export { FlagSource };
export { FlagSource, PipelineEventType };

export declare type LoadingState = {
error: Error | null, // Current error, resets on next attempt to fetch flags
Expand Down Expand Up @@ -147,7 +147,7 @@ export interface IInitConfig<F extends string | Record<string, any> = string, T

export interface IPipelineEvent {
event_id: string; // flag_name or event_name
event_type: 'flag_evaluation' | 'custom_event';
event_type: PipelineEventType;
evaluated_at: number;
identity_identifier: string | null;
enabled?: boolean | null;
Expand Down Expand Up @@ -299,6 +299,15 @@ T extends string = string
* Set a key value set of traits for a given user, triggers a call to get flags
*/
setTraits: (traits: ITraits) => Promise<void>;
/**
* Track a custom event through the evaluation analytics pipeline.
* Requires `evaluationAnalyticsConfig` to be set; no-op otherwise.
* Events are sent with the current identity (or null if anonymous).
* @experimental Internal use only — API may change without notice.
* @internal
* @hidden
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just missing the internal tag here too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks @Zaimwa9
Fixed in: 2849346

*/
trackEvent: (eventName: string, metadata?: Record<string, unknown>) => void;
/**
* The stored identity of the user
*/
Expand Down
Loading