Skip to content

Commit 0f84c8f

Browse files
Merge pull request #191 from yusuftomilola/main
implement advanced feature flag management system
2 parents ffa0c52 + 42eee97 commit 0f84c8f

File tree

7 files changed

+1092
-0
lines changed

7 files changed

+1092
-0
lines changed
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { Injectable } from '@nestjs/common';
2+
import {
3+
EvaluationReason,
4+
ExperimentStats,
5+
ExperimentVariantStats,
6+
FlagAnalyticsEvent,
7+
FlagEvaluationStats,
8+
FlagSummary,
9+
FlagValueType,
10+
} from '../interfaces';
11+
12+
type TrackEvaluationInput = Omit<FlagAnalyticsEvent, 'eventId' | 'timestamp'>;
13+
14+
@Injectable()
15+
export class FlagAnalyticsService {
16+
/** flagKey → events */
17+
private readonly flagEvents = new Map<string, FlagAnalyticsEvent[]>();
18+
/** flagKey → Set of unique userIds */
19+
private readonly flagUsers = new Map<string, Set<string>>();
20+
/** experimentId → variantKey → impression count */
21+
private readonly experimentImpressions = new Map<string, Map<string, number>>();
22+
/** experimentId → variantKey → conversion count */
23+
private readonly experimentConversions = new Map<string, Map<string, number>>();
24+
25+
/**
26+
* Records a flag evaluation event.
27+
*/
28+
trackEvaluation(input: TrackEvaluationInput): void {
29+
const event: FlagAnalyticsEvent = {
30+
...input,
31+
eventId: this.generateEventId(),
32+
timestamp: new Date(),
33+
};
34+
35+
if (event.flagKey) {
36+
if (!this.flagEvents.has(event.flagKey)) {
37+
this.flagEvents.set(event.flagKey, []);
38+
}
39+
this.flagEvents.get(event.flagKey)!.push(event);
40+
41+
if (event.userId) {
42+
if (!this.flagUsers.has(event.flagKey)) {
43+
this.flagUsers.set(event.flagKey, new Set());
44+
}
45+
this.flagUsers.get(event.flagKey)!.add(event.userId);
46+
}
47+
}
48+
}
49+
50+
/**
51+
* Records an experiment impression (user saw a variant).
52+
*/
53+
trackImpression(
54+
experimentId: string,
55+
variantKey: string,
56+
userId?: string,
57+
flagKey?: string,
58+
): void {
59+
this.incrementExperimentCounter(this.experimentImpressions, experimentId, variantKey);
60+
61+
this.trackEvaluation({
62+
eventType: 'impression',
63+
flagKey,
64+
userId,
65+
experimentId,
66+
experimentVariantKey: variantKey,
67+
});
68+
}
69+
70+
/**
71+
* Records an experiment conversion event.
72+
*/
73+
trackConversion(
74+
experimentId: string,
75+
variantKey: string,
76+
userId?: string,
77+
metadata?: Record<string, unknown>,
78+
): void {
79+
this.incrementExperimentCounter(this.experimentConversions, experimentId, variantKey);
80+
81+
this.trackEvaluation({
82+
eventType: 'conversion',
83+
userId,
84+
experimentId,
85+
experimentVariantKey: variantKey,
86+
metadata,
87+
});
88+
}
89+
90+
/**
91+
* Returns evaluation statistics for a flag.
92+
* Optionally filters to events within the last `sinceHours` hours.
93+
*/
94+
getEvaluationStats(flagKey: string, sinceHours?: number): FlagEvaluationStats {
95+
const allEvents = this.flagEvents.get(flagKey) ?? [];
96+
97+
const events = sinceHours
98+
? allEvents.filter((e) => {
99+
const cutoff = new Date(Date.now() - sinceHours * 3_600_000);
100+
return e.timestamp >= cutoff;
101+
})
102+
: allEvents;
103+
104+
const evaluationsByVariation: Record<string, number> = {};
105+
const evaluationsByReason: Record<string, number> = {};
106+
let errorCount = 0;
107+
let evaluationCount = 0;
108+
109+
for (const event of events) {
110+
if (event.eventType !== 'evaluation') continue;
111+
evaluationCount++;
112+
113+
if (event.variationKey) {
114+
evaluationsByVariation[event.variationKey] =
115+
(evaluationsByVariation[event.variationKey] ?? 0) + 1;
116+
}
117+
118+
if (event.reason) {
119+
evaluationsByReason[event.reason] = (evaluationsByReason[event.reason] ?? 0) + 1;
120+
if (event.reason === 'ERROR') errorCount++;
121+
}
122+
}
123+
124+
return {
125+
flagKey,
126+
totalEvaluations: evaluationCount,
127+
evaluationsByVariation,
128+
evaluationsByReason,
129+
uniqueUsers: this.flagUsers.get(flagKey)?.size ?? 0,
130+
errorRate: evaluationCount > 0 ? errorCount / evaluationCount : 0,
131+
};
132+
}
133+
134+
/**
135+
* Returns impression and conversion stats for all variants in an experiment.
136+
*/
137+
getExperimentStats(
138+
experimentId: string,
139+
controlVariantKey?: string,
140+
): ExperimentStats {
141+
const impressions = this.experimentImpressions.get(experimentId) ?? new Map<string, number>();
142+
const conversions = this.experimentConversions.get(experimentId) ?? new Map<string, number>();
143+
144+
const allVariantKeys = new Set([...impressions.keys(), ...conversions.keys()]);
145+
146+
let totalImpressions = 0;
147+
const variants: Record<string, ExperimentVariantStats> = {};
148+
149+
for (const variantKey of allVariantKeys) {
150+
const imp = impressions.get(variantKey) ?? 0;
151+
const conv = conversions.get(variantKey) ?? 0;
152+
totalImpressions += imp;
153+
154+
variants[variantKey] = {
155+
variantKey,
156+
impressions: imp,
157+
conversions: conv,
158+
conversionRate: imp > 0 ? conv / imp : 0,
159+
isControl: variantKey === controlVariantKey,
160+
};
161+
}
162+
163+
return { experimentId, totalImpressions, variants };
164+
}
165+
166+
/**
167+
* Returns the most evaluated flags, sorted by evaluation count descending.
168+
*/
169+
getTopFlags(limit: number = 10): FlagSummary[] {
170+
const summaries: FlagSummary[] = [];
171+
172+
for (const [flagKey, events] of this.flagEvents.entries()) {
173+
const evaluations = events.filter((e) => e.eventType === 'evaluation');
174+
summaries.push({
175+
flagKey,
176+
totalEvaluations: evaluations.length,
177+
lastEvaluatedAt: events[events.length - 1]?.timestamp,
178+
});
179+
}
180+
181+
return summaries
182+
.sort((a, b) => b.totalEvaluations - a.totalEvaluations)
183+
.slice(0, limit);
184+
}
185+
186+
/**
187+
* Returns the most recent evaluation events for a flag in reverse-chronological order.
188+
*/
189+
getFlagEvaluationHistory(flagKey: string, limit: number = 100): FlagAnalyticsEvent[] {
190+
const events = this.flagEvents.get(flagKey) ?? [];
191+
return events
192+
.filter((e) => e.eventType === 'evaluation')
193+
.slice(-limit)
194+
.reverse();
195+
}
196+
197+
/**
198+
* Clears stored analytics. Pass a flagKey to clear only that flag's data,
199+
* or call without arguments to wipe all analytics.
200+
*/
201+
clearAnalytics(flagKey?: string): void {
202+
if (flagKey) {
203+
this.flagEvents.delete(flagKey);
204+
this.flagUsers.delete(flagKey);
205+
return;
206+
}
207+
208+
this.flagEvents.clear();
209+
this.flagUsers.clear();
210+
this.experimentImpressions.clear();
211+
this.experimentConversions.clear();
212+
}
213+
214+
private incrementExperimentCounter(
215+
store: Map<string, Map<string, number>>,
216+
experimentId: string,
217+
variantKey: string,
218+
): void {
219+
if (!store.has(experimentId)) {
220+
store.set(experimentId, new Map());
221+
}
222+
const inner = store.get(experimentId)!;
223+
inner.set(variantKey, (inner.get(variantKey) ?? 0) + 1);
224+
}
225+
226+
private generateEventId(): string {
227+
return `evt_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
228+
}
229+
}

0 commit comments

Comments
 (0)