diff --git a/flagsmith-core.ts b/flagsmith-core.ts index b2446e0..b95ca5a 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -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, init?: Partial) => Promise> let _fetch: LikeFetch; @@ -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 | null = null - isPipelineFlushing = false - pipelineRecordedKeys: Map = new Map() + private evaluationAnalyticsUrl: string | null = null + private evaluationAnalyticsMaxBuffer: number = 1000 + private pipelineEvents: IPipelineEvent[] = [] + private pipelineAnalyticsInterval: ReturnType | null = null + private isPipelineFlushing = false + private pipelineRecordedKeys: Map = new Map() async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -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): Record { + 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; + 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]; @@ -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) => { + 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) { diff --git a/index.ts b/index.ts index 4d3c3cf..21dcac4 100644 --- a/index.ts +++ b/index.ts @@ -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'; diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 0d1b3cb..05d2e13 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -1,4 +1,5 @@ import { getFlagsmith, environmentID, testIdentity } from './test-constants'; +import { PipelineEventType } from '../lib/flagsmith'; const pipelineUrl = 'https://analytics.flagsmith.com/'; @@ -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(); + }); +}); diff --git a/types.d.ts b/types.d.ts index de3c682..09b48d2 100644 --- a/types.d.ts +++ b/types.d.ts @@ -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 @@ -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 @@ -147,7 +147,7 @@ export interface IInitConfig = 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; @@ -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; + /** + * 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 + */ + trackEvent: (eventName: string, metadata?: Record) => void; /** * The stored identity of the user */