@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/jest.setup.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Jest setup file for Amplitude plugin tests
3
+ */
4
+
5
+ import '@testing-library/jest-dom';
6
+
7
+ // Mock window and document objects
8
+ Object.defineProperty(window, 'location', {
9
+ value: {
10
+ href: 'https://example.com',
11
+ pathname: '/',
12
+ search: '',
13
+ hash: '',
14
+ origin: 'https://example.com',
15
+ protocol: 'https:',
16
+ host: 'example.com',
17
+ hostname: 'example.com',
18
+ port: '',
19
+ assign: jest.fn(),
20
+ replace: jest.fn(),
21
+ reload: jest.fn(),
22
+ },
23
+ writable: true,
24
+ });
25
+
26
+ Object.defineProperty(window, 'navigator', {
27
+ value: {
28
+ userAgent: 'Mozilla/5.0 (compatible; Test)',
29
+ language: 'en-US',
30
+ onLine: true,
31
+ },
32
+ writable: true,
33
+ });
34
+
35
+ Object.defineProperty(window, 'screen', {
36
+ value: {
37
+ width: 1920,
38
+ height: 1080,
39
+ },
40
+ writable: true,
41
+ });
42
+
43
+ Object.defineProperty(window, 'innerWidth', {
44
+ value: 1200,
45
+ writable: true,
46
+ });
47
+
48
+ Object.defineProperty(window, 'innerHeight', {
49
+ value: 800,
50
+ writable: true,
51
+ });
52
+
53
+ Object.defineProperty(window, 'scrollX', {
54
+ value: 0,
55
+ writable: true,
56
+ });
57
+
58
+ Object.defineProperty(window, 'scrollY', {
59
+ value: 0,
60
+ writable: true,
61
+ });
62
+
63
+ Object.defineProperty(window, 'devicePixelRatio', {
64
+ value: 2,
65
+ writable: true,
66
+ });
67
+
68
+ // Mock performance API
69
+ Object.defineProperty(window, 'performance', {
70
+ value: {
71
+ now: jest.fn(() => Date.now()),
72
+ getEntriesByType: jest.fn(() => []),
73
+ getEntriesByName: jest.fn(() => []),
74
+ mark: jest.fn(),
75
+ measure: jest.fn(),
76
+ memory: {
77
+ usedJSHeapSize: 1024 * 1024,
78
+ totalJSHeapSize: 2 * 1024 * 1024,
79
+ jsHeapSizeLimit: 4 * 1024 * 1024,
80
+ },
81
+ },
82
+ writable: true,
83
+ });
84
+
85
+ // Mock document methods
86
+ Object.defineProperty(document, 'title', {
87
+ value: 'Test Page',
88
+ writable: true,
89
+ });
90
+
91
+ Object.defineProperty(document, 'referrer', {
92
+ value: 'https://example.com',
93
+ writable: true,
94
+ });
95
+
96
+ Object.defineProperty(document, 'hidden', {
97
+ value: false,
98
+ writable: true,
99
+ });
100
+
101
+ Object.defineProperty(document, 'visibilityState', {
102
+ value: 'visible',
103
+ writable: true,
104
+ });
105
+
106
+ // Mock localStorage
107
+ const localStorageMock = {
108
+ getItem: jest.fn(),
109
+ setItem: jest.fn(),
110
+ removeItem: jest.fn(),
111
+ clear: jest.fn(),
112
+ length: 0,
113
+ key: jest.fn(),
114
+ };
115
+
116
+ Object.defineProperty(window, 'localStorage', {
117
+ value: localStorageMock,
118
+ writable: true,
119
+ });
120
+
121
+ // Mock sessionStorage
122
+ const sessionStorageMock = {
123
+ getItem: jest.fn(),
124
+ setItem: jest.fn(),
125
+ removeItem: jest.fn(),
126
+ clear: jest.fn(),
127
+ length: 0,
128
+ key: jest.fn(),
129
+ };
130
+
131
+ Object.defineProperty(window, 'sessionStorage', {
132
+ value: sessionStorageMock,
133
+ writable: true,
134
+ });
135
+
136
+ // Mock MutationObserver
137
+ class MockMutationObserver {
138
+ constructor(callback: MutationCallback) {
139
+ this.callback = callback;
140
+ }
141
+
142
+ private callback: MutationCallback;
143
+
144
+ observe = jest.fn();
145
+ disconnect = jest.fn();
146
+ takeRecords = jest.fn(() => []);
147
+ }
148
+
149
+ Object.defineProperty(window, 'MutationObserver', {
150
+ value: MockMutationObserver,
151
+ writable: true,
152
+ });
153
+
154
+ // Mock IntersectionObserver
155
+ class MockIntersectionObserver {
156
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
157
+ this.callback = callback;
158
+ this.options = options;
159
+ }
160
+
161
+ private callback: IntersectionObserverCallback;
162
+ private options?: IntersectionObserverInit;
163
+
164
+ observe = jest.fn();
165
+ unobserve = jest.fn();
166
+ disconnect = jest.fn();
167
+ takeRecords = jest.fn(() => []);
168
+ }
169
+
170
+ Object.defineProperty(window, 'IntersectionObserver', {
171
+ value: MockIntersectionObserver,
172
+ writable: true,
173
+ });
174
+
175
+ // Mock ResizeObserver
176
+ class MockResizeObserver {
177
+ constructor(callback: ResizeObserverCallback) {
178
+ this.callback = callback;
179
+ }
180
+
181
+ private callback: ResizeObserverCallback;
182
+
183
+ observe = jest.fn();
184
+ unobserve = jest.fn();
185
+ disconnect = jest.fn();
186
+ }
187
+
188
+ Object.defineProperty(window, 'ResizeObserver', {
189
+ value: MockResizeObserver,
190
+ writable: true,
191
+ });
192
+
193
+ // Mock setTimeout and setInterval for consistent testing
194
+ global.setTimeout = jest.fn((fn, delay) => {
195
+ if (typeof fn === 'function') {
196
+ return setTimeout(fn, delay);
197
+ }
198
+ return setTimeout(fn, delay);
199
+ }) as any;
200
+
201
+ global.setInterval = jest.fn((fn, delay) => {
202
+ if (typeof fn === 'function') {
203
+ return setInterval(fn, delay);
204
+ }
205
+ return setInterval(fn, delay);
206
+ }) as any;
207
+
208
+ global.clearTimeout = jest.fn(clearTimeout);
209
+ global.clearInterval = jest.fn(clearInterval);
210
+
211
+ // Mock console methods to reduce noise in tests
212
+ const originalConsole = global.console;
213
+ global.console = {
214
+ ...originalConsole,
215
+ log: jest.fn(),
216
+ warn: jest.fn(),
217
+ error: jest.fn(),
218
+ info: jest.fn(),
219
+ debug: jest.fn(),
220
+ };
221
+
222
+ // Mock fetch for API calls
223
+ global.fetch = jest.fn(() =>
224
+ Promise.resolve({
225
+ ok: true,
226
+ status: 200,
227
+ json: () => Promise.resolve({}),
228
+ text: () => Promise.resolve(''),
229
+ blob: () => Promise.resolve(new Blob()),
230
+ })
231
+ ) as jest.Mock;
232
+
233
+ // Mock crypto for UUID generation
234
+ Object.defineProperty(global, 'crypto', {
235
+ value: {
236
+ randomUUID: jest.fn(() => 'test-uuid-1234-5678'),
237
+ getRandomValues: jest.fn((arr) => {
238
+ for (let i = 0; i < arr.length; i++) {
239
+ arr[i] = Math.floor(Math.random() * 256);
240
+ }
241
+ return arr;
242
+ }),
243
+ },
244
+ writable: true,
245
+ });
246
+
247
+ // Error boundary for React components
248
+ const originalError = console.error;
249
+ beforeAll(() => {
250
+ console.error = (...args) => {
251
+ if (
252
+ typeof args[0] === 'string' &&
253
+ args[0].includes('Warning: ReactDOM.render is no longer supported')
254
+ ) {
255
+ return;
256
+ }
257
+ originalError.call(console, ...args);
258
+ };
259
+ });
260
+
261
+ afterAll(() => {
262
+ console.error = originalError;
263
+ });
264
+
265
+ // Clean up after each test
266
+ afterEach(() => {
267
+ jest.clearAllMocks();
268
+ localStorageMock.getItem.mockClear();
269
+ localStorageMock.setItem.mockClear();
270
+ localStorageMock.removeItem.mockClear();
271
+ localStorageMock.clear.mockClear();
272
+ sessionStorageMock.getItem.mockClear();
273
+ sessionStorageMock.setItem.mockClear();
274
+ sessionStorageMock.removeItem.mockClear();
275
+ sessionStorageMock.clear.mockClear();
276
+ });
@@ -0,0 +1,178 @@
1
+ import { AmplitudeAPIKey, AmplitudePluginConfig, EventProperties, EventType, UserProperties, UserId } from '../types/amplitude.types';
2
+ import { trackPerformanceMetric, getPerformanceMetrics } from './performance';
3
+ import { EventQueue } from './queue';
4
+ import { DataSanitizer } from './security';
5
+
6
+ class AmplitudeCoreWrapper {
7
+ private initialized = false;
8
+ private config: AmplitudePluginConfig | null = null;
9
+ private eventQueue: EventQueue;
10
+ private healthCheckInterval: NodeJS.Timeout | null = null;
11
+
12
+ constructor() {
13
+ this.eventQueue = new EventQueue(this.sendEventsBatch.bind(this));
14
+ }
15
+
16
+ public async init(apiKey: AmplitudeAPIKey, config: AmplitudePluginConfig): Promise<void> {
17
+ try {
18
+ // Check for double initialization
19
+ if (this.initialized) {
20
+ throw new Error('Amplitude is already initialized');
21
+ }
22
+
23
+ // Validate API key (must be at least 32 characters)
24
+ if (!apiKey || apiKey.length < 32) {
25
+ throw new Error('Invalid API key: Must be at least 32 characters');
26
+ }
27
+
28
+ this.config = config;
29
+
30
+ // Initialize Amplitude SDK (mock for now)
31
+ console.log(`[Amplitude Core] Initializing with API key: ${apiKey.substring(0, 8)}...`);
32
+
33
+ // Simulate async initialization
34
+ await new Promise(resolve => setTimeout(resolve, 100));
35
+
36
+ this.initialized = true;
37
+ this.startHealthChecks();
38
+
39
+ trackPerformanceMetric('amplitude_init_success', 1, 'counter');
40
+ console.log('[Amplitude Core] Successfully initialized');
41
+ } catch (error) {
42
+ trackPerformanceMetric('amplitude_init_error', 1, 'counter');
43
+ throw new Error(`Failed to initialize Amplitude: ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ }
46
+
47
+ public async track(eventType: EventType, properties?: EventProperties): Promise<void> {
48
+ if (!this.initialized) {
49
+ throw new Error('Amplitude not initialized');
50
+ }
51
+
52
+ const startTime = Date.now();
53
+
54
+ try {
55
+ // Sanitize properties
56
+ const sanitizedProperties = this.config?.piiMaskingEnabled
57
+ ? DataSanitizer.sanitizeEventProperties(properties, [])
58
+ : properties;
59
+
60
+ // Add to queue for batch processing
61
+ await this.eventQueue.enqueue(eventType, sanitizedProperties);
62
+
63
+ const latency = Date.now() - startTime;
64
+ trackPerformanceMetric('amplitude_track_latency', latency, 'timing');
65
+ } catch (error) {
66
+ trackPerformanceMetric('amplitude_track_error', 1, 'counter');
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ private async sendEventsBatch(events: Array<{ eventType: EventType; properties?: EventProperties; timestamp?: number }>): Promise<void> {
72
+ if (!this.initialized || events.length === 0) return;
73
+
74
+ try {
75
+ // Mock sending to Amplitude API
76
+ console.log(`[Amplitude Core] Sending batch of ${events.length} events`);
77
+
78
+ // Simulate API call
79
+ await new Promise(resolve => setTimeout(resolve, Math.random() * 100 + 50));
80
+
81
+ trackPerformanceMetric('amplitude_batch_sent', events.length, 'counter');
82
+ } catch (error) {
83
+ trackPerformanceMetric('amplitude_batch_error', 1, 'counter');
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ public async identify(userId: UserId, properties?: UserProperties): Promise<void> {
89
+ if (!this.initialized) {
90
+ throw new Error('Amplitude not initialized');
91
+ }
92
+
93
+ try {
94
+ // Sanitize user properties
95
+ const sanitizedProperties = this.config?.piiMaskingEnabled
96
+ ? DataSanitizer.sanitizeUserProperties(properties, [])
97
+ : properties;
98
+
99
+ // Mock identify call
100
+ console.log(`[Amplitude Core] Identifying user: ${userId}`);
101
+
102
+ trackPerformanceMetric('amplitude_identify_success', 1, 'counter');
103
+ } catch (error) {
104
+ trackPerformanceMetric('amplitude_identify_error', 1, 'counter');
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ public async setUserProperties(properties: UserProperties): Promise<void> {
110
+ if (!this.initialized) {
111
+ throw new Error('Amplitude not initialized');
112
+ }
113
+
114
+ try {
115
+ // Sanitize user properties
116
+ const sanitizedProperties = this.config?.piiMaskingEnabled
117
+ ? DataSanitizer.sanitizeUserProperties(properties, [])
118
+ : properties;
119
+
120
+ // Mock setUserProperties call
121
+ console.log('[Amplitude Core] Setting user properties');
122
+
123
+ trackPerformanceMetric('amplitude_user_properties_success', 1, 'counter');
124
+ } catch (error) {
125
+ trackPerformanceMetric('amplitude_user_properties_error', 1, 'counter');
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ public reset(): void {
131
+ if (!this.initialized) {
132
+ throw new Error('Amplitude not initialized');
133
+ }
134
+
135
+ try {
136
+ // Mock reset call
137
+ console.log('[Amplitude Core] Resetting user session');
138
+
139
+ trackPerformanceMetric('amplitude_reset', 1, 'counter');
140
+ } catch (error) {
141
+ trackPerformanceMetric('amplitude_reset_error', 1, 'counter');
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ public isInitialized(): boolean {
147
+ return this.initialized;
148
+ }
149
+
150
+ public shutdown(): void {
151
+ if (this.healthCheckInterval) {
152
+ clearInterval(this.healthCheckInterval);
153
+ this.healthCheckInterval = null;
154
+ }
155
+
156
+ this.eventQueue.shutdown();
157
+ this.initialized = false;
158
+
159
+ console.log('[Amplitude Core] Shutdown complete');
160
+ }
161
+
162
+ private startHealthChecks(): void {
163
+ this.healthCheckInterval = setInterval(() => {
164
+ const metrics = getPerformanceMetrics();
165
+ const memoryUsage = (performance as any).memory?.usedJSHeapSize || 0;
166
+
167
+ trackPerformanceMetric('amplitude_memory_usage', memoryUsage, 'gauge');
168
+ trackPerformanceMetric('amplitude_health_check', 1, 'counter');
169
+
170
+ // Check for performance issues
171
+ if (memoryUsage > 50 * 1024 * 1024) { // 50MB
172
+ console.warn('[Amplitude Core] High memory usage detected:', memoryUsage);
173
+ }
174
+ }, 30000); // Every 30 seconds
175
+ }
176
+ }
177
+
178
+ export const AmplitudeCore = new AmplitudeCoreWrapper();
package/lib/cache.ts ADDED
@@ -0,0 +1,181 @@
1
+ interface CacheEntry<T> {
2
+ value: T;
3
+ timestamp: number;
4
+ ttl: number;
5
+ hits: number;
6
+ lastAccessed: number;
7
+ }
8
+
9
+ export class LRUCache<K, V> {
10
+ private cache: Map<K, CacheEntry<V>> = new Map();
11
+ private maxSize: number;
12
+ private defaultTtl: number;
13
+ private hitCount = 0;
14
+ private missCount = 0;
15
+
16
+ constructor(maxSize: number = 1000, defaultTtl: number = 300000) { // 5 minutes default TTL
17
+ this.maxSize = maxSize;
18
+ this.defaultTtl = defaultTtl;
19
+ }
20
+
21
+ public get(key: K): V | undefined {
22
+ const entry = this.cache.get(key);
23
+
24
+ if (!entry) {
25
+ this.missCount++;
26
+ return undefined;
27
+ }
28
+
29
+ // Check if expired
30
+ if (Date.now() > entry.timestamp + entry.ttl) {
31
+ this.cache.delete(key);
32
+ this.missCount++;
33
+ return undefined;
34
+ }
35
+
36
+ // Update access stats
37
+ entry.hits++;
38
+ entry.lastAccessed = Date.now();
39
+ this.hitCount++;
40
+
41
+ // Move to end (most recently used)
42
+ this.cache.delete(key);
43
+ this.cache.set(key, entry);
44
+
45
+ return entry.value;
46
+ }
47
+
48
+ public set(key: K, value: V, ttl?: number): void {
49
+ const now = Date.now();
50
+ const entryTtl = ttl || this.defaultTtl;
51
+
52
+ // Remove existing entry if it exists
53
+ if (this.cache.has(key)) {
54
+ this.cache.delete(key);
55
+ } else if (this.cache.size >= this.maxSize) {
56
+ // Remove least recently used item
57
+ this.evictLRU();
58
+ }
59
+
60
+ // Add new entry
61
+ this.cache.set(key, {
62
+ value,
63
+ timestamp: now,
64
+ ttl: entryTtl,
65
+ hits: 0,
66
+ lastAccessed: now,
67
+ });
68
+ }
69
+
70
+ public delete(key: K): boolean {
71
+ return this.cache.delete(key);
72
+ }
73
+
74
+ public has(key: K): boolean {
75
+ const entry = this.cache.get(key);
76
+ if (!entry) return false;
77
+
78
+ // Check if expired
79
+ if (Date.now() > entry.timestamp + entry.ttl) {
80
+ this.cache.delete(key);
81
+ return false;
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ public clear(): void {
88
+ this.cache.clear();
89
+ this.hitCount = 0;
90
+ this.missCount = 0;
91
+ }
92
+
93
+ public size(): number {
94
+ this.cleanupExpired();
95
+ return this.cache.size;
96
+ }
97
+
98
+ public getStats(): {
99
+ size: number;
100
+ maxSize: number;
101
+ hitRate: number;
102
+ missRate: number;
103
+ totalHits: number;
104
+ totalMisses: number;
105
+ } {
106
+ this.cleanupExpired();
107
+ const total = this.hitCount + this.missCount;
108
+
109
+ return {
110
+ size: this.cache.size,
111
+ maxSize: this.maxSize,
112
+ hitRate: total > 0 ? this.hitCount / total : 0,
113
+ missRate: total > 0 ? this.missCount / total : 0,
114
+ totalHits: this.hitCount,
115
+ totalMisses: this.missCount,
116
+ };
117
+ }
118
+
119
+ public getEntryStats(key: K): {
120
+ hits: number;
121
+ age: number;
122
+ ttl: number;
123
+ lastAccessed: number;
124
+ } | null {
125
+ const entry = this.cache.get(key);
126
+ if (!entry) return null;
127
+
128
+ return {
129
+ hits: entry.hits,
130
+ age: Date.now() - entry.timestamp,
131
+ ttl: entry.ttl,
132
+ lastAccessed: entry.lastAccessed,
133
+ };
134
+ }
135
+
136
+ private evictLRU(): void {
137
+ // Find the least recently used entry
138
+ let lruKey: K | null = null;
139
+ let oldestAccess = Date.now();
140
+
141
+ for (const [key, entry] of this.cache.entries()) {
142
+ if (entry.lastAccessed < oldestAccess) {
143
+ oldestAccess = entry.lastAccessed;
144
+ lruKey = key;
145
+ }
146
+ }
147
+
148
+ if (lruKey !== null) {
149
+ this.cache.delete(lruKey);
150
+ }
151
+ }
152
+
153
+ private cleanupExpired(): void {
154
+ const now = Date.now();
155
+ const expiredKeys: K[] = [];
156
+
157
+ for (const [key, entry] of this.cache.entries()) {
158
+ if (now > entry.timestamp + entry.ttl) {
159
+ expiredKeys.push(key);
160
+ }
161
+ }
162
+
163
+ expiredKeys.forEach(key => this.cache.delete(key));
164
+ }
165
+
166
+ public keys(): K[] {
167
+ this.cleanupExpired();
168
+ return Array.from(this.cache.keys());
169
+ }
170
+
171
+ public values(): V[] {
172
+ this.cleanupExpired();
173
+ return Array.from(this.cache.values()).map(entry => entry.value);
174
+ }
175
+
176
+ public entries(): [K, V][] {
177
+ this.cleanupExpired();
178
+ return Array.from(this.cache.entries()).map(([key, entry]) => [key, entry.value]);
179
+ }
180
+ }
181
+