@nextsparkjs/plugin-amplitude 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_REVIEW_REPORT.md +462 -0
- package/README.md +619 -0
- package/__tests__/amplitude-core.test.ts +279 -0
- package/__tests__/hooks.test.ts +478 -0
- package/__tests__/validation.test.ts +393 -0
- package/components/AnalyticsDashboard.tsx +339 -0
- package/components/ConsentManager.tsx +265 -0
- package/components/ExperimentWrapper.tsx +440 -0
- package/components/PerformanceMonitor.tsx +578 -0
- package/hooks/useAmplitude.ts +132 -0
- package/hooks/useAmplitudeEvents.ts +100 -0
- package/hooks/useExperiment.ts +195 -0
- package/hooks/useSessionReplay.ts +238 -0
- package/jest.setup.ts +276 -0
- package/lib/amplitude-core.ts +178 -0
- package/lib/cache.ts +181 -0
- package/lib/performance.ts +319 -0
- package/lib/queue.ts +389 -0
- package/lib/security.ts +188 -0
- package/package.json +15 -0
- package/plugin.config.ts +58 -0
- package/providers/AmplitudeProvider.tsx +113 -0
- package/styles/amplitude.css +593 -0
- package/translations/en.json +45 -0
- package/translations/es.json +45 -0
- package/tsconfig.json +47 -0
- package/types/amplitude.types.ts +105 -0
- package/utils/debounce.ts +133 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExperimentWrapper component for A/B testing integration
|
|
3
|
+
* Provides declarative experiment participation with automatic exposure tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useEffect, useRef, useState, ReactNode } from 'react';
|
|
7
|
+
import { useExperiment } from '../hooks/useExperiment';
|
|
8
|
+
|
|
9
|
+
// Component props
|
|
10
|
+
export interface ExperimentWrapperProps {
|
|
11
|
+
experimentId: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
children: ReactNode | ((variant: string | null, config: Record<string, any> | null) => ReactNode);
|
|
14
|
+
fallback?: ReactNode;
|
|
15
|
+
trackExposureOnMount?: boolean;
|
|
16
|
+
trackExposureOnVisible?: boolean;
|
|
17
|
+
exposureDelay?: number;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: React.CSSProperties;
|
|
20
|
+
onVariantAssigned?: (variant: string | null, config: Record<string, any> | null) => void;
|
|
21
|
+
onExposed?: (experimentId: string, variant: string) => void;
|
|
22
|
+
onError?: (error: Error) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Intersection Observer hook for visibility tracking
|
|
26
|
+
const useIntersectionObserver = (
|
|
27
|
+
elementRef: React.RefObject<Element | null>,
|
|
28
|
+
callback: () => void,
|
|
29
|
+
options: IntersectionObserverInit = {}
|
|
30
|
+
) => {
|
|
31
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
32
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!elementRef.current || !('IntersectionObserver' in window)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
observerRef.current = new IntersectionObserver(
|
|
40
|
+
([entry]) => {
|
|
41
|
+
const visible = entry.isIntersecting;
|
|
42
|
+
setIsVisible(visible);
|
|
43
|
+
if (visible) {
|
|
44
|
+
callback();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
threshold: 0.1,
|
|
49
|
+
...options,
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
observerRef.current.observe(elementRef.current);
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
if (observerRef.current) {
|
|
57
|
+
observerRef.current.disconnect();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}, [elementRef, callback, options]);
|
|
61
|
+
|
|
62
|
+
return isVisible;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ExperimentWrapper Component
|
|
67
|
+
*/
|
|
68
|
+
export const ExperimentWrapper: React.FC<ExperimentWrapperProps> = ({
|
|
69
|
+
experimentId,
|
|
70
|
+
userId,
|
|
71
|
+
children,
|
|
72
|
+
fallback = null,
|
|
73
|
+
trackExposureOnMount = true,
|
|
74
|
+
trackExposureOnVisible = false,
|
|
75
|
+
exposureDelay = 0,
|
|
76
|
+
className,
|
|
77
|
+
style,
|
|
78
|
+
onVariantAssigned,
|
|
79
|
+
onExposed,
|
|
80
|
+
onError,
|
|
81
|
+
}) => {
|
|
82
|
+
const {
|
|
83
|
+
getVariant,
|
|
84
|
+
isInExperiment,
|
|
85
|
+
trackExposure,
|
|
86
|
+
canRunExperiments,
|
|
87
|
+
experiments,
|
|
88
|
+
getExperimentConfig,
|
|
89
|
+
} = useExperiment();
|
|
90
|
+
|
|
91
|
+
const [variant, setVariant] = useState<string | null>(null);
|
|
92
|
+
const [config, setConfig] = useState<Record<string, any> | null>(null);
|
|
93
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
94
|
+
const [error, setError] = useState<Error | null>(null);
|
|
95
|
+
const [hasTrackedExposure, setHasTrackedExposure] = useState(false);
|
|
96
|
+
|
|
97
|
+
const elementRef = useRef<HTMLDivElement>(null);
|
|
98
|
+
const exposureTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
99
|
+
|
|
100
|
+
// Determine user ID
|
|
101
|
+
const effectiveUserId = userId || 'anonymous';
|
|
102
|
+
|
|
103
|
+
// Get experiment configuration
|
|
104
|
+
const experiment = experiments.get(experimentId);
|
|
105
|
+
|
|
106
|
+
// Track exposure when visible
|
|
107
|
+
const handleVisibilityExposure = () => {
|
|
108
|
+
if (trackExposureOnVisible && variant && !hasTrackedExposure) {
|
|
109
|
+
handleExposure();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Use intersection observer for visibility tracking
|
|
114
|
+
useIntersectionObserver(elementRef, handleVisibilityExposure, {
|
|
115
|
+
threshold: 0.5, // Track when 50% visible
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Handle exposure tracking
|
|
119
|
+
const handleExposure = () => {
|
|
120
|
+
if (!variant || hasTrackedExposure || !canRunExperiments) return;
|
|
121
|
+
|
|
122
|
+
const trackExposureWithDelay = () => {
|
|
123
|
+
trackExposure(experimentId, variant)
|
|
124
|
+
.then(() => {
|
|
125
|
+
setHasTrackedExposure(true);
|
|
126
|
+
onExposed?.(experimentId, variant);
|
|
127
|
+
console.log(`[Experiment] Tracked exposure for ${experimentId}:${variant}`);
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
console.error('[Experiment] Failed to track exposure:', err);
|
|
131
|
+
setError(err);
|
|
132
|
+
onError?.(err);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (exposureDelay > 0) {
|
|
137
|
+
exposureTimeoutRef.current = setTimeout(trackExposureWithDelay, exposureDelay);
|
|
138
|
+
} else {
|
|
139
|
+
trackExposureWithDelay();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Initialize experiment
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!canRunExperiments) {
|
|
146
|
+
setIsLoading(false);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
setIsLoading(true);
|
|
152
|
+
setError(null);
|
|
153
|
+
|
|
154
|
+
// Check if experiment exists
|
|
155
|
+
if (!experiment) {
|
|
156
|
+
console.warn(`[Experiment] Experiment ${experimentId} not found`);
|
|
157
|
+
setVariant(null);
|
|
158
|
+
setConfig(null);
|
|
159
|
+
setIsLoading(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Get variant assignment
|
|
164
|
+
const assignedVariant = getVariant(experimentId, effectiveUserId);
|
|
165
|
+
|
|
166
|
+
// Get variant config from experiment config
|
|
167
|
+
let variantConfig: Record<string, any> | null = null;
|
|
168
|
+
if (assignedVariant && experiment.variants) {
|
|
169
|
+
const variantData = experiment.variants.find((v: any) => v.id === assignedVariant);
|
|
170
|
+
variantConfig = variantData?.config || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setVariant(assignedVariant);
|
|
174
|
+
setConfig(variantConfig);
|
|
175
|
+
setIsLoading(false);
|
|
176
|
+
|
|
177
|
+
// Notify parent of assignment
|
|
178
|
+
onVariantAssigned?.(assignedVariant, variantConfig);
|
|
179
|
+
|
|
180
|
+
console.log(`[Experiment] ${experimentId} assigned variant:`, assignedVariant);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error('[Experiment] Failed to initialize experiment:', err);
|
|
183
|
+
const error = err instanceof Error ? err : new Error('Failed to initialize experiment');
|
|
184
|
+
setError(error);
|
|
185
|
+
setIsLoading(false);
|
|
186
|
+
onError?.(error);
|
|
187
|
+
}
|
|
188
|
+
}, [
|
|
189
|
+
experimentId,
|
|
190
|
+
effectiveUserId,
|
|
191
|
+
canRunExperiments,
|
|
192
|
+
experiment,
|
|
193
|
+
getVariant,
|
|
194
|
+
getExperimentConfig,
|
|
195
|
+
onVariantAssigned,
|
|
196
|
+
onError,
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// Track exposure on mount
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (trackExposureOnMount && variant && !trackExposureOnVisible) {
|
|
202
|
+
handleExposure();
|
|
203
|
+
}
|
|
204
|
+
}, [variant, trackExposureOnMount, trackExposureOnVisible]);
|
|
205
|
+
|
|
206
|
+
// Cleanup timeout on unmount
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
return () => {
|
|
209
|
+
if (exposureTimeoutRef.current) {
|
|
210
|
+
clearTimeout(exposureTimeoutRef.current);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
// Handle loading state
|
|
216
|
+
if (isLoading) {
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
ref={elementRef}
|
|
220
|
+
className={className}
|
|
221
|
+
style={style}
|
|
222
|
+
data-experiment-loading={experimentId}
|
|
223
|
+
>
|
|
224
|
+
{fallback}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Handle error state
|
|
230
|
+
if (error) {
|
|
231
|
+
console.error(`[Experiment] Error in experiment ${experimentId}:`, error);
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
ref={elementRef}
|
|
235
|
+
className={className}
|
|
236
|
+
style={style}
|
|
237
|
+
data-experiment-error={experimentId}
|
|
238
|
+
>
|
|
239
|
+
{fallback}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Handle no experiment or not in experiment
|
|
245
|
+
if (!canRunExperiments || !experiment || !variant) {
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
ref={elementRef}
|
|
249
|
+
className={className}
|
|
250
|
+
style={style}
|
|
251
|
+
data-experiment-excluded={experimentId}
|
|
252
|
+
>
|
|
253
|
+
{fallback}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Render experiment content
|
|
259
|
+
const content = typeof children === 'function'
|
|
260
|
+
? children(variant, config)
|
|
261
|
+
: children;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
ref={elementRef}
|
|
266
|
+
className={className}
|
|
267
|
+
style={style}
|
|
268
|
+
data-experiment={experimentId}
|
|
269
|
+
data-variant={variant}
|
|
270
|
+
data-exposed={hasTrackedExposure}
|
|
271
|
+
>
|
|
272
|
+
{content}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Feature Flag Component (simplified experiment wrapper)
|
|
279
|
+
*/
|
|
280
|
+
export interface FeatureFlagProps {
|
|
281
|
+
flag: string;
|
|
282
|
+
userId?: string;
|
|
283
|
+
children: ReactNode;
|
|
284
|
+
fallback?: ReactNode;
|
|
285
|
+
onEnabled?: (flag: string) => void;
|
|
286
|
+
onDisabled?: (flag: string) => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const FeatureFlag: React.FC<FeatureFlagProps> = ({
|
|
290
|
+
flag,
|
|
291
|
+
userId,
|
|
292
|
+
children,
|
|
293
|
+
fallback = null,
|
|
294
|
+
onEnabled,
|
|
295
|
+
onDisabled,
|
|
296
|
+
}) => {
|
|
297
|
+
return (
|
|
298
|
+
<ExperimentWrapper
|
|
299
|
+
experimentId={flag}
|
|
300
|
+
userId={userId}
|
|
301
|
+
fallback={fallback}
|
|
302
|
+
trackExposureOnMount={true}
|
|
303
|
+
onVariantAssigned={(variant) => {
|
|
304
|
+
if (variant === 'enabled' || variant === 'treatment') {
|
|
305
|
+
onEnabled?.(flag);
|
|
306
|
+
} else {
|
|
307
|
+
onDisabled?.(flag);
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
{(variant) => {
|
|
312
|
+
// For feature flags, show content if variant is 'enabled' or 'treatment'
|
|
313
|
+
if (variant === 'enabled' || variant === 'treatment') {
|
|
314
|
+
return children;
|
|
315
|
+
}
|
|
316
|
+
return fallback;
|
|
317
|
+
}}
|
|
318
|
+
</ExperimentWrapper>
|
|
319
|
+
);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A/B Test Component (variant-based rendering)
|
|
324
|
+
*/
|
|
325
|
+
export interface ABTestProps {
|
|
326
|
+
experimentId: string;
|
|
327
|
+
userId?: string;
|
|
328
|
+
variants: Record<string, ReactNode>;
|
|
329
|
+
fallback?: ReactNode;
|
|
330
|
+
onVariantShown?: (experimentId: string, variant: string) => void;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export const ABTest: React.FC<ABTestProps> = ({
|
|
334
|
+
experimentId,
|
|
335
|
+
userId,
|
|
336
|
+
variants,
|
|
337
|
+
fallback = null,
|
|
338
|
+
onVariantShown,
|
|
339
|
+
}) => {
|
|
340
|
+
return (
|
|
341
|
+
<ExperimentWrapper
|
|
342
|
+
experimentId={experimentId}
|
|
343
|
+
userId={userId}
|
|
344
|
+
fallback={fallback}
|
|
345
|
+
trackExposureOnMount={true}
|
|
346
|
+
onExposed={onVariantShown}
|
|
347
|
+
>
|
|
348
|
+
{(variant) => {
|
|
349
|
+
if (variant && variants[variant]) {
|
|
350
|
+
return variants[variant];
|
|
351
|
+
}
|
|
352
|
+
return fallback;
|
|
353
|
+
}}
|
|
354
|
+
</ExperimentWrapper>
|
|
355
|
+
);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Multivariate Test Component (config-based rendering)
|
|
360
|
+
*/
|
|
361
|
+
export interface MultivariateTestProps {
|
|
362
|
+
experimentId: string;
|
|
363
|
+
userId?: string;
|
|
364
|
+
children: (config: Record<string, any> | null) => ReactNode;
|
|
365
|
+
fallback?: ReactNode;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const MultivariateTest: React.FC<MultivariateTestProps> = ({
|
|
369
|
+
experimentId,
|
|
370
|
+
userId,
|
|
371
|
+
children,
|
|
372
|
+
fallback = null,
|
|
373
|
+
}) => {
|
|
374
|
+
return (
|
|
375
|
+
<ExperimentWrapper
|
|
376
|
+
experimentId={experimentId}
|
|
377
|
+
userId={userId}
|
|
378
|
+
fallback={fallback}
|
|
379
|
+
trackExposureOnMount={true}
|
|
380
|
+
>
|
|
381
|
+
{(variant, config) => {
|
|
382
|
+
if (variant && config) {
|
|
383
|
+
return children(config);
|
|
384
|
+
}
|
|
385
|
+
return fallback;
|
|
386
|
+
}}
|
|
387
|
+
</ExperimentWrapper>
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Hook for programmatic experiment access
|
|
393
|
+
*/
|
|
394
|
+
export const useExperimentComponent = (experimentId: string, userId?: string) => {
|
|
395
|
+
const {
|
|
396
|
+
getVariant,
|
|
397
|
+
isInExperiment,
|
|
398
|
+
trackExposure,
|
|
399
|
+
trackConversion,
|
|
400
|
+
canRunExperiments,
|
|
401
|
+
getExperimentConfig,
|
|
402
|
+
} = useExperiment();
|
|
403
|
+
|
|
404
|
+
const effectiveUserId = userId || 'anonymous';
|
|
405
|
+
|
|
406
|
+
const variant = canRunExperiments ? getVariant(experimentId, effectiveUserId) : null;
|
|
407
|
+
|
|
408
|
+
// Get variant config from experiment config
|
|
409
|
+
let config: Record<string, any> | null = null;
|
|
410
|
+
if (canRunExperiments && variant) {
|
|
411
|
+
const experiment = getExperimentConfig(experimentId);
|
|
412
|
+
if (experiment?.variants) {
|
|
413
|
+
const variantData = experiment.variants.find((v: any) => v.id === variant);
|
|
414
|
+
config = variantData?.config || null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const inExperiment = canRunExperiments ? isInExperiment(experimentId, effectiveUserId) : false;
|
|
419
|
+
|
|
420
|
+
const expose = () => {
|
|
421
|
+
if (variant) {
|
|
422
|
+
return trackExposure(experimentId, variant);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const convert = (metricId?: string, value?: number) => {
|
|
427
|
+
return trackConversion(experimentId, metricId, value);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
variant,
|
|
432
|
+
config,
|
|
433
|
+
inExperiment,
|
|
434
|
+
canRunExperiments,
|
|
435
|
+
expose,
|
|
436
|
+
convert,
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
export default ExperimentWrapper;
|