Skip to content
This repository was archived by the owner on Feb 15, 2026. It is now read-only.

Commit 9430930

Browse files
committed
feat: Experiments
1 parent 47d89f6 commit 9430930

File tree

3 files changed

+554
-41
lines changed

3 files changed

+554
-41
lines changed

src/Lib.ts

Lines changed: 147 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,26 @@ export interface FeatureFlagsOptions {
120120
}
121121

122122
/**
123-
* Cached feature flags with timestamp.
123+
* Options for evaluating experiments.
124124
*/
125-
interface CachedFlags {
125+
export interface ExperimentOptions {
126+
/**
127+
* Optional profile ID for long-term user tracking.
128+
* If not provided, an anonymous profile ID will be generated server-side based on IP and user agent.
129+
* Overrides the global profileId if set.
130+
*/
131+
profileId?: string
132+
}
133+
134+
/**
135+
* Cached feature flags and experiments with timestamp.
136+
*/
137+
interface CachedData {
126138
flags: Record<string, boolean>
139+
experiments: Record<string, string>
127140
timestamp: number
141+
/** The profileId used when fetching this cached data */
142+
profileId?: string
128143
}
129144

130145
/**
@@ -218,7 +233,7 @@ export class Lib {
218233
private perfStatsCollected: boolean = false
219234
private activePage: string | null = null
220235
private errorListenerExists = false
221-
private cachedFlags: CachedFlags | null = null
236+
private cachedData: CachedData | null = null
222237

223238
constructor(private projectID: string, private options?: LibOptions) {
224239
this.trackPathChange = this.trackPathChange.bind(this)
@@ -407,63 +422,80 @@ export class Lib {
407422
}
408423

409424
/**
410-
* Fetches all feature flags for the project.
425+
* Fetches all feature flags and experiments for the project.
411426
* Results are cached for 5 minutes by default.
412427
*
413428
* @param options - Options for evaluating feature flags.
414-
* @param forceRefresh - If true, bypasses the cache and fetches fresh flags.
429+
* @param forceRefresh - If true, bypasses the cache and fetches fresh data.
415430
* @returns A promise that resolves to a record of flag keys to boolean values.
416431
*/
417432
async getFeatureFlags(options?: FeatureFlagsOptions, forceRefresh?: boolean): Promise<Record<string, boolean>> {
418433
if (!isInBrowser()) {
419434
return {}
420435
}
421436

422-
// Check cache first
423-
if (!forceRefresh && this.cachedFlags) {
437+
const requestedProfileId = options?.profileId ?? this.options?.profileId
438+
439+
// Check cache first - must match profileId and not be expired
440+
if (!forceRefresh && this.cachedData) {
424441
const now = Date.now()
425-
if (now - this.cachedFlags.timestamp < DEFAULT_CACHE_DURATION) {
426-
return this.cachedFlags.flags
442+
const isSameProfile = this.cachedData.profileId === requestedProfileId
443+
if (isSameProfile && now - this.cachedData.timestamp < DEFAULT_CACHE_DURATION) {
444+
return this.cachedData.flags
427445
}
428446
}
429447

430448
try {
431-
const apiBase = this.getApiBase()
432-
const body: { pid: string; profileId?: string } = {
433-
pid: this.projectID,
434-
}
449+
await this.fetchFlagsAndExperiments(options)
450+
return this.cachedData?.flags || {}
451+
} catch (error) {
452+
console.warn('[Swetrix] Error fetching feature flags:', error)
453+
return this.cachedData?.flags || {}
454+
}
455+
}
435456

436-
// Use profileId from options, or fall back to global profileId
437-
const profileId = options?.profileId ?? this.options?.profileId
438-
if (profileId) {
439-
body.profileId = profileId
440-
}
457+
/**
458+
* Internal method to fetch both feature flags and experiments from the API.
459+
*/
460+
private async fetchFlagsAndExperiments(options?: FeatureFlagsOptions | ExperimentOptions): Promise<void> {
461+
const apiBase = this.getApiBase()
462+
const body: { pid: string; profileId?: string } = {
463+
pid: this.projectID,
464+
}
441465

442-
const response = await fetch(`${apiBase}/feature-flag/evaluate`, {
443-
method: 'POST',
444-
headers: {
445-
'Content-Type': 'application/json',
446-
},
447-
body: JSON.stringify(body),
448-
})
466+
// Use profileId from options, or fall back to global profileId
467+
const profileId = options?.profileId ?? this.options?.profileId
468+
if (profileId) {
469+
body.profileId = profileId
470+
}
449471

450-
if (!response.ok) {
451-
console.warn('[Swetrix] Failed to fetch feature flags:', response.status)
452-
return this.cachedFlags?.flags || {}
453-
}
472+
const response = await fetch(`${apiBase}/feature-flag/evaluate`, {
473+
method: 'POST',
474+
headers: {
475+
'Content-Type': 'application/json',
476+
},
477+
body: JSON.stringify(body),
478+
})
454479

455-
const data = (await response.json()) as { flags: Record<string, boolean> }
480+
if (!response.ok) {
481+
console.warn('[Swetrix] Failed to fetch feature flags and experiments:', response.status)
482+
return
483+
}
456484

457-
// Update cache
458-
this.cachedFlags = {
459-
flags: data.flags,
460-
timestamp: Date.now(),
461-
}
485+
const data = (await response.json()) as {
486+
flags: Record<string, boolean>
487+
experiments?: Record<string, string>
488+
}
462489

463-
return data.flags
464-
} catch (error) {
465-
console.warn('[Swetrix] Error fetching feature flags:', error)
466-
return this.cachedFlags?.flags || {}
490+
// Use profileId from options, or fall back to global profileId
491+
const cachedProfileId = options?.profileId ?? this.options?.profileId
492+
493+
// Update cache with both flags and experiments
494+
this.cachedData = {
495+
flags: data.flags || {},
496+
experiments: data.experiments || {},
497+
timestamp: Date.now(),
498+
profileId: cachedProfileId,
467499
}
468500
}
469501

@@ -481,10 +513,84 @@ export class Lib {
481513
}
482514

483515
/**
484-
* Clears the cached feature flags, forcing a fresh fetch on the next call.
516+
* Clears the cached feature flags and experiments, forcing a fresh fetch on the next call.
485517
*/
486518
clearFeatureFlagsCache(): void {
487-
this.cachedFlags = null
519+
this.cachedData = null
520+
}
521+
522+
/**
523+
* Fetches all A/B test experiments for the project.
524+
* Results are cached for 5 minutes by default (shared cache with feature flags).
525+
*
526+
* @param options - Options for evaluating experiments.
527+
* @param forceRefresh - If true, bypasses the cache and fetches fresh data.
528+
* @returns A promise that resolves to a record of experiment IDs to variant keys.
529+
*
530+
* @example
531+
* ```typescript
532+
* const experiments = await getExperiments()
533+
* // experiments = { 'exp-123': 'variant-a', 'exp-456': 'control' }
534+
* ```
535+
*/
536+
async getExperiments(options?: ExperimentOptions, forceRefresh?: boolean): Promise<Record<string, string>> {
537+
if (!isInBrowser()) {
538+
return {}
539+
}
540+
541+
const requestedProfileId = options?.profileId ?? this.options?.profileId
542+
543+
// Check cache first - must match profileId and not be expired
544+
if (!forceRefresh && this.cachedData) {
545+
const now = Date.now()
546+
const isSameProfile = this.cachedData.profileId === requestedProfileId
547+
if (isSameProfile && now - this.cachedData.timestamp < DEFAULT_CACHE_DURATION) {
548+
return this.cachedData.experiments
549+
}
550+
}
551+
552+
try {
553+
await this.fetchFlagsAndExperiments(options)
554+
return this.cachedData?.experiments || {}
555+
} catch (error) {
556+
console.warn('[Swetrix] Error fetching experiments:', error)
557+
return this.cachedData?.experiments || {}
558+
}
559+
}
560+
561+
/**
562+
* Gets the variant key for a specific A/B test experiment.
563+
*
564+
* @param experimentId - The experiment ID.
565+
* @param options - Options for evaluating the experiment.
566+
* @param defaultVariant - Default variant key to return if the experiment is not found. Defaults to null.
567+
* @returns A promise that resolves to the variant key assigned to this user, or defaultVariant if not found.
568+
*
569+
* @example
570+
* ```typescript
571+
* const variant = await getExperiment('checkout-redesign')
572+
*
573+
* if (variant === 'new-checkout') {
574+
* // Show new checkout flow
575+
* } else {
576+
* // Show control (original) checkout
577+
* }
578+
* ```
579+
*/
580+
async getExperiment(
581+
experimentId: string,
582+
options?: ExperimentOptions,
583+
defaultVariant: string | null = null,
584+
): Promise<string | null> {
585+
const experiments = await this.getExperiments(options)
586+
return experiments[experimentId] ?? defaultVariant
587+
}
588+
589+
/**
590+
* Clears the cached experiments (alias for clearFeatureFlagsCache since they share the same cache).
591+
*/
592+
clearExperimentsCache(): void {
593+
this.cachedData = null
488594
}
489595

490596
/**

src/index.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IErrorEventPayload,
1111
IPageViewPayload,
1212
FeatureFlagsOptions,
13+
ExperimentOptions,
1314
} from './Lib.js'
1415

1516
export let LIB_INSTANCE: Lib | null = null
@@ -187,6 +188,81 @@ export function clearFeatureFlagsCache(): void {
187188
LIB_INSTANCE.clearFeatureFlagsCache()
188189
}
189190

191+
/**
192+
* Fetches all A/B test experiments for the project.
193+
* Results are cached for 5 minutes by default (shared cache with feature flags).
194+
*
195+
* @param options - Options for evaluating experiments.
196+
* @param forceRefresh - If true, bypasses the cache and fetches fresh data.
197+
* @returns A promise that resolves to a record of experiment IDs to variant keys.
198+
*
199+
* @example
200+
* ```typescript
201+
* const experiments = await getExperiments()
202+
* // experiments = { 'exp-123': 'variant-a', 'exp-456': 'control' }
203+
*
204+
* // Use the assigned variant
205+
* const checkoutVariant = experiments['checkout-experiment-id']
206+
* if (checkoutVariant === 'new-checkout') {
207+
* showNewCheckout()
208+
* } else {
209+
* showOriginalCheckout()
210+
* }
211+
* ```
212+
*/
213+
export async function getExperiments(
214+
options?: ExperimentOptions,
215+
forceRefresh?: boolean,
216+
): Promise<Record<string, string>> {
217+
if (!LIB_INSTANCE) return {}
218+
219+
return LIB_INSTANCE.getExperiments(options, forceRefresh)
220+
}
221+
222+
/**
223+
* Gets the variant key for a specific A/B test experiment.
224+
*
225+
* @param experimentId - The experiment ID.
226+
* @param options - Options for evaluating the experiment.
227+
* @param defaultVariant - Default variant key to return if the experiment is not found. Defaults to null.
228+
* @returns A promise that resolves to the variant key assigned to this user, or defaultVariant if not found.
229+
*
230+
* @example
231+
* ```typescript
232+
* const variant = await getExperiment('checkout-redesign-experiment-id')
233+
*
234+
* if (variant === 'new-checkout') {
235+
* // Show new checkout flow
236+
* showNewCheckout()
237+
* } else if (variant === 'control') {
238+
* // Show original checkout (control group)
239+
* showOriginalCheckout()
240+
* } else {
241+
* // Experiment not running or user not included
242+
* showOriginalCheckout()
243+
* }
244+
* ```
245+
*/
246+
export async function getExperiment(
247+
experimentId: string,
248+
options?: ExperimentOptions,
249+
defaultVariant: string | null = null,
250+
): Promise<string | null> {
251+
if (!LIB_INSTANCE) return defaultVariant
252+
253+
return LIB_INSTANCE.getExperiment(experimentId, options, defaultVariant)
254+
}
255+
256+
/**
257+
* Clears the cached experiments, forcing a fresh fetch on the next call.
258+
* This is an alias for clearFeatureFlagsCache since experiments and flags share the same cache.
259+
*/
260+
export function clearExperimentsCache(): void {
261+
if (!LIB_INSTANCE) return
262+
263+
LIB_INSTANCE.clearExperimentsCache()
264+
}
265+
190266
export {
191267
LibOptions,
192268
TrackEventOptions,
@@ -197,4 +273,5 @@ export {
197273
IErrorEventPayload,
198274
IPageViewPayload,
199275
FeatureFlagsOptions,
276+
ExperimentOptions,
200277
}

0 commit comments

Comments
 (0)