@mostly-good-metrics/javascript 0.2.0 → 0.4.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/dist/cjs/client.js +74 -2
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/network.js +7 -0
- package/dist/cjs/network.js.map +1 -1
- package/dist/cjs/storage.js +61 -0
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +22 -0
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +75 -3
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/network.js +7 -0
- package/dist/esm/network.js.map +1 -1
- package/dist/esm/storage.js +61 -0
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +20 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/client.d.ts +40 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/network.d.ts.map +1 -1
- package/dist/types/storage.d.ts +23 -1
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/types.d.ts +24 -0
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts +8 -0
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +143 -0
- package/src/client.ts +86 -2
- package/src/network.test.ts +244 -0
- package/src/network.ts +7 -0
- package/src/storage.test.ts +150 -1
- package/src/storage.ts +66 -1
- package/src/types.ts +29 -0
- package/src/utils.test.ts +31 -0
- package/src/utils.ts +21 -0
package/src/client.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
generateUUID,
|
|
20
20
|
getDeviceModel,
|
|
21
21
|
getISOTimestamp,
|
|
22
|
+
getLocale,
|
|
22
23
|
getOSVersion,
|
|
24
|
+
getTimezone,
|
|
23
25
|
resolveConfiguration,
|
|
24
26
|
sanitizeProperties,
|
|
25
27
|
validateEventName,
|
|
@@ -164,6 +166,41 @@ export class MostlyGoodMetrics {
|
|
|
164
166
|
return MostlyGoodMetrics.instance?.getPendingEventCount() ?? Promise.resolve(0);
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Set a single super property that will be included with every event.
|
|
171
|
+
*/
|
|
172
|
+
static setSuperProperty(key: string, value: EventProperties[string]): void {
|
|
173
|
+
MostlyGoodMetrics.instance?.setSuperProperty(key, value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Set multiple super properties at once.
|
|
178
|
+
*/
|
|
179
|
+
static setSuperProperties(properties: EventProperties): void {
|
|
180
|
+
MostlyGoodMetrics.instance?.setSuperProperties(properties);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Remove a single super property.
|
|
185
|
+
*/
|
|
186
|
+
static removeSuperProperty(key: string): void {
|
|
187
|
+
MostlyGoodMetrics.instance?.removeSuperProperty(key);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear all super properties.
|
|
192
|
+
*/
|
|
193
|
+
static clearSuperProperties(): void {
|
|
194
|
+
MostlyGoodMetrics.instance?.clearSuperProperties();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get all current super properties.
|
|
199
|
+
*/
|
|
200
|
+
static getSuperProperties(): EventProperties {
|
|
201
|
+
return MostlyGoodMetrics.instance?.getSuperProperties() ?? {};
|
|
202
|
+
}
|
|
203
|
+
|
|
167
204
|
// ============================================================
|
|
168
205
|
// Instance properties
|
|
169
206
|
// ============================================================
|
|
@@ -212,17 +249,21 @@ export class MostlyGoodMetrics {
|
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
const sanitizedProperties = sanitizeProperties(properties);
|
|
252
|
+
const superProperties = persistence.getSuperProperties();
|
|
215
253
|
|
|
216
|
-
//
|
|
254
|
+
// Merge properties: super properties < event properties < system properties
|
|
255
|
+
// Event properties override super properties, system properties are always added
|
|
217
256
|
const mergedProperties: EventProperties = {
|
|
257
|
+
...superProperties,
|
|
258
|
+
...sanitizedProperties,
|
|
218
259
|
[SystemProperties.DEVICE_TYPE]: detectDeviceType(),
|
|
219
260
|
[SystemProperties.DEVICE_MODEL]: getDeviceModel(),
|
|
220
261
|
[SystemProperties.SDK]: this.config.sdk,
|
|
221
|
-
...sanitizedProperties,
|
|
222
262
|
};
|
|
223
263
|
|
|
224
264
|
const event: MGMEvent = {
|
|
225
265
|
name,
|
|
266
|
+
client_event_id: generateUUID(),
|
|
226
267
|
timestamp: getISOTimestamp(),
|
|
227
268
|
userId: this.userId ?? undefined,
|
|
228
269
|
sessionId: this.sessionIdValue,
|
|
@@ -230,6 +271,8 @@ export class MostlyGoodMetrics {
|
|
|
230
271
|
appVersion: this.config.appVersion || undefined,
|
|
231
272
|
osVersion: this.config.osVersion || getOSVersion() || undefined,
|
|
232
273
|
environment: this.config.environment,
|
|
274
|
+
locale: getLocale(),
|
|
275
|
+
timezone: getTimezone(),
|
|
233
276
|
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : undefined,
|
|
234
277
|
};
|
|
235
278
|
|
|
@@ -307,6 +350,45 @@ export class MostlyGoodMetrics {
|
|
|
307
350
|
return this.storage.eventCount();
|
|
308
351
|
}
|
|
309
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Set a single super property that will be included with every event.
|
|
355
|
+
*/
|
|
356
|
+
setSuperProperty(key: string, value: EventProperties[string]): void {
|
|
357
|
+
logger.debug(`Setting super property: ${key}`);
|
|
358
|
+
persistence.setSuperProperty(key, value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Set multiple super properties at once.
|
|
363
|
+
*/
|
|
364
|
+
setSuperProperties(properties: EventProperties): void {
|
|
365
|
+
logger.debug(`Setting super properties: ${Object.keys(properties).join(', ')}`);
|
|
366
|
+
persistence.setSuperProperties(properties);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Remove a single super property.
|
|
371
|
+
*/
|
|
372
|
+
removeSuperProperty(key: string): void {
|
|
373
|
+
logger.debug(`Removing super property: ${key}`);
|
|
374
|
+
persistence.removeSuperProperty(key);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Clear all super properties.
|
|
379
|
+
*/
|
|
380
|
+
clearSuperProperties(): void {
|
|
381
|
+
logger.debug('Clearing all super properties');
|
|
382
|
+
persistence.clearSuperProperties();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Get all current super properties.
|
|
387
|
+
*/
|
|
388
|
+
getSuperProperties(): EventProperties {
|
|
389
|
+
return persistence.getSuperProperties();
|
|
390
|
+
}
|
|
391
|
+
|
|
310
392
|
/**
|
|
311
393
|
* Clean up resources (stop timers, etc.).
|
|
312
394
|
*/
|
|
@@ -380,6 +462,8 @@ export class MostlyGoodMetrics {
|
|
|
380
462
|
userId: this.userId ?? undefined,
|
|
381
463
|
sessionId: this.sessionIdValue,
|
|
382
464
|
environment: this.config.environment,
|
|
465
|
+
locale: getLocale(),
|
|
466
|
+
timezone: getTimezone(),
|
|
383
467
|
};
|
|
384
468
|
|
|
385
469
|
return { events, context };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { TextEncoder } from 'util';
|
|
2
|
+
import { FetchNetworkClient } from './network';
|
|
3
|
+
import { MGMEventsPayload, ResolvedConfiguration } from './types';
|
|
4
|
+
|
|
5
|
+
// Polyfill TextEncoder for Jest environment
|
|
6
|
+
global.TextEncoder = TextEncoder;
|
|
7
|
+
|
|
8
|
+
describe('FetchNetworkClient', () => {
|
|
9
|
+
let networkClient: FetchNetworkClient;
|
|
10
|
+
let mockFetch: jest.Mock;
|
|
11
|
+
let capturedHeaders: Record<string, string>;
|
|
12
|
+
|
|
13
|
+
const createMockConfig = (
|
|
14
|
+
overrides: Partial<ResolvedConfiguration> = {}
|
|
15
|
+
): ResolvedConfiguration => ({
|
|
16
|
+
apiKey: 'test-api-key',
|
|
17
|
+
baseURL: 'https://api.example.com',
|
|
18
|
+
maxStoredEvents: 1000,
|
|
19
|
+
flushIntervalMs: 30000,
|
|
20
|
+
maxBatchSize: 100,
|
|
21
|
+
environment: 'production',
|
|
22
|
+
platform: 'web',
|
|
23
|
+
sdk: 'javascript',
|
|
24
|
+
trackAppLifecycleEvents: false,
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const createMockPayload = (): MGMEventsPayload => ({
|
|
29
|
+
events: [
|
|
30
|
+
{
|
|
31
|
+
name: 'test_event',
|
|
32
|
+
client_event_id: '123e4567-e89b-12d3-a456-426614174000',
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
platform: 'web',
|
|
35
|
+
environment: 'production',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
networkClient = new FetchNetworkClient();
|
|
42
|
+
capturedHeaders = {};
|
|
43
|
+
|
|
44
|
+
mockFetch = jest.fn().mockImplementation((_url: string, options: RequestInit) => {
|
|
45
|
+
capturedHeaders = options.headers as Record<string, string>;
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
status: 204,
|
|
48
|
+
headers: new Headers(),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
global.fetch = mockFetch;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
jest.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('SDK identification headers', () => {
|
|
60
|
+
it('should include X-MGM-SDK header from config', async () => {
|
|
61
|
+
const config = createMockConfig({ sdk: 'react-native' });
|
|
62
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
63
|
+
|
|
64
|
+
expect(capturedHeaders['X-MGM-SDK']).toBe('react-native');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should include X-MGM-SDK-Version header', async () => {
|
|
68
|
+
const config = createMockConfig();
|
|
69
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
70
|
+
|
|
71
|
+
expect(capturedHeaders['X-MGM-SDK-Version']).toBeDefined();
|
|
72
|
+
expect(capturedHeaders['X-MGM-SDK-Version']).toMatch(/^\d+\.\d+\.\d+$/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should include X-MGM-Platform header from config', async () => {
|
|
76
|
+
const config = createMockConfig({ platform: 'ios' });
|
|
77
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
78
|
+
|
|
79
|
+
expect(capturedHeaders['X-MGM-Platform']).toBe('ios');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should include X-MGM-Platform-Version when osVersion is configured', async () => {
|
|
83
|
+
const config = createMockConfig({ osVersion: '17.0' });
|
|
84
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
85
|
+
|
|
86
|
+
expect(capturedHeaders['X-MGM-Platform-Version']).toBe('17.0');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should include X-MGM-Key header with API key', async () => {
|
|
90
|
+
const config = createMockConfig({ apiKey: 'my-secret-key' });
|
|
91
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
92
|
+
|
|
93
|
+
expect(capturedHeaders['X-MGM-Key']).toBe('my-secret-key');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should include X-MGM-Bundle-Id when bundleId is configured', async () => {
|
|
97
|
+
const config = createMockConfig({ bundleId: 'com.example.app' });
|
|
98
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
99
|
+
|
|
100
|
+
expect(capturedHeaders['X-MGM-Bundle-Id']).toBe('com.example.app');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should not include X-MGM-Bundle-Id when bundleId is not configured', async () => {
|
|
104
|
+
const config = createMockConfig();
|
|
105
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
106
|
+
|
|
107
|
+
expect(capturedHeaders['X-MGM-Bundle-Id']).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should include all SDK headers together', async () => {
|
|
111
|
+
const config = createMockConfig({
|
|
112
|
+
apiKey: 'test-key',
|
|
113
|
+
sdk: 'javascript',
|
|
114
|
+
platform: 'web',
|
|
115
|
+
osVersion: '14.0',
|
|
116
|
+
bundleId: 'com.test.app',
|
|
117
|
+
});
|
|
118
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
119
|
+
|
|
120
|
+
expect(capturedHeaders['X-MGM-Key']).toBe('test-key');
|
|
121
|
+
expect(capturedHeaders['X-MGM-SDK']).toBe('javascript');
|
|
122
|
+
expect(capturedHeaders['X-MGM-SDK-Version']).toBeDefined();
|
|
123
|
+
expect(capturedHeaders['X-MGM-Platform']).toBe('web');
|
|
124
|
+
expect(capturedHeaders['X-MGM-Platform-Version']).toBe('14.0');
|
|
125
|
+
expect(capturedHeaders['X-MGM-Bundle-Id']).toBe('com.test.app');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('sendEvents', () => {
|
|
130
|
+
it('should return success on 204 response', async () => {
|
|
131
|
+
mockFetch.mockResolvedValueOnce({
|
|
132
|
+
status: 204,
|
|
133
|
+
headers: new Headers(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
137
|
+
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should return success on 200 response', async () => {
|
|
142
|
+
mockFetch.mockResolvedValueOnce({
|
|
143
|
+
status: 200,
|
|
144
|
+
headers: new Headers(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
148
|
+
|
|
149
|
+
expect(result.success).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should handle rate limiting (429) with Retry-After header', async () => {
|
|
153
|
+
mockFetch.mockResolvedValueOnce({
|
|
154
|
+
status: 429,
|
|
155
|
+
headers: new Headers({ 'Retry-After': '120' }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
159
|
+
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
expect(result.shouldRetry).toBe(true);
|
|
162
|
+
expect(result.error?.type).toBe('RATE_LIMITED');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle client errors (4xx) without retry', async () => {
|
|
166
|
+
mockFetch.mockResolvedValueOnce({
|
|
167
|
+
status: 400,
|
|
168
|
+
headers: new Headers(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
172
|
+
|
|
173
|
+
expect(result.success).toBe(false);
|
|
174
|
+
expect(result.shouldRetry).toBe(false);
|
|
175
|
+
expect(result.error?.type).toBe('BAD_REQUEST');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should handle server errors (5xx) with retry', async () => {
|
|
179
|
+
mockFetch.mockResolvedValueOnce({
|
|
180
|
+
status: 500,
|
|
181
|
+
headers: new Headers(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
185
|
+
|
|
186
|
+
expect(result.success).toBe(false);
|
|
187
|
+
expect(result.shouldRetry).toBe(true);
|
|
188
|
+
expect(result.error?.type).toBe('SERVER_ERROR');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle network errors with retry', async () => {
|
|
192
|
+
mockFetch.mockRejectedValueOnce(new Error('Network failure'));
|
|
193
|
+
|
|
194
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
195
|
+
|
|
196
|
+
expect(result.success).toBe(false);
|
|
197
|
+
expect(result.shouldRetry).toBe(true);
|
|
198
|
+
expect(result.error?.type).toBe('NETWORK_ERROR');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should send to correct endpoint', async () => {
|
|
202
|
+
const config = createMockConfig({ baseURL: 'https://api.test.com' });
|
|
203
|
+
await networkClient.sendEvents(createMockPayload(), config);
|
|
204
|
+
|
|
205
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/v1/events', expect.any(Object));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should use POST method', async () => {
|
|
209
|
+
await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
210
|
+
|
|
211
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
212
|
+
expect.any(String),
|
|
213
|
+
expect.objectContaining({ method: 'POST' })
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('rate limiting', () => {
|
|
219
|
+
it('should not be rate limited initially', () => {
|
|
220
|
+
expect(networkClient.isRateLimited()).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should return null for retry time initially', () => {
|
|
224
|
+
expect(networkClient.getRetryAfterTime()).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should skip requests when rate limited', async () => {
|
|
228
|
+
// First request gets rate limited
|
|
229
|
+
mockFetch.mockResolvedValueOnce({
|
|
230
|
+
status: 429,
|
|
231
|
+
headers: new Headers({ 'Retry-After': '60' }),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
235
|
+
|
|
236
|
+
// Second request should be skipped
|
|
237
|
+
const result = await networkClient.sendEvents(createMockPayload(), createMockConfig());
|
|
238
|
+
|
|
239
|
+
expect(result.success).toBe(false);
|
|
240
|
+
expect(result.error?.type).toBe('RATE_LIMITED');
|
|
241
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // Only first request was made
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/network.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
ResolvedConfiguration,
|
|
8
8
|
SendResult,
|
|
9
9
|
} from './types';
|
|
10
|
+
import { getOSVersion } from './utils';
|
|
10
11
|
|
|
11
12
|
const EVENTS_ENDPOINT = '/v1/events';
|
|
12
13
|
const REQUEST_TIMEOUT_MS = 60000; // 60 seconds
|
|
14
|
+
const SDK_VERSION = '0.4.0';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Compress data using gzip if available (browser CompressionStream API).
|
|
@@ -72,9 +74,14 @@ export class FetchNetworkClient implements INetworkClient {
|
|
|
72
74
|
const jsonBody = JSON.stringify(payload);
|
|
73
75
|
const { data, compressed } = await compressIfNeeded(jsonBody);
|
|
74
76
|
|
|
77
|
+
const osVersion = config.osVersion || getOSVersion();
|
|
75
78
|
const headers: Record<string, string> = {
|
|
76
79
|
'Content-Type': 'application/json',
|
|
77
80
|
'X-MGM-Key': config.apiKey,
|
|
81
|
+
'X-MGM-SDK': config.sdk,
|
|
82
|
+
'X-MGM-SDK-Version': SDK_VERSION,
|
|
83
|
+
'X-MGM-Platform': config.platform,
|
|
84
|
+
...(osVersion && { 'X-MGM-Platform-Version': osVersion }),
|
|
78
85
|
};
|
|
79
86
|
|
|
80
87
|
if (config.bundleId) {
|
package/src/storage.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InMemoryEventStorage, LocalStorageEventStorage } from './storage';
|
|
1
|
+
import { InMemoryEventStorage, LocalStorageEventStorage, persistence } from './storage';
|
|
2
2
|
import { MGMEvent } from './types';
|
|
3
3
|
|
|
4
4
|
const createMockEvent = (name: string): MGMEvent => ({
|
|
@@ -173,3 +173,152 @@ describe('LocalStorageEventStorage', () => {
|
|
|
173
173
|
expect(events).toHaveLength(0);
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
|
+
|
|
177
|
+
describe('PersistenceManager super properties', () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
// Mock localStorage
|
|
180
|
+
const localStorageMock = (() => {
|
|
181
|
+
let store: Record<string, string> = {};
|
|
182
|
+
return {
|
|
183
|
+
getItem: jest.fn((key: string) => store[key] ?? null),
|
|
184
|
+
setItem: jest.fn((key: string, value: string) => {
|
|
185
|
+
store[key] = value;
|
|
186
|
+
}),
|
|
187
|
+
removeItem: jest.fn((key: string) => {
|
|
188
|
+
delete store[key];
|
|
189
|
+
}),
|
|
190
|
+
clear: jest.fn(() => {
|
|
191
|
+
store = {};
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
})();
|
|
195
|
+
|
|
196
|
+
Object.defineProperty(window, 'localStorage', {
|
|
197
|
+
value: localStorageMock,
|
|
198
|
+
writable: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Clear any existing super properties
|
|
202
|
+
persistence.clearSuperProperties();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
jest.clearAllMocks();
|
|
207
|
+
persistence.clearSuperProperties();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should return empty object when no super properties are set', () => {
|
|
211
|
+
const props = persistence.getSuperProperties();
|
|
212
|
+
expect(props).toEqual({});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should set and get a single super property', () => {
|
|
216
|
+
persistence.setSuperProperty('tier', 'premium');
|
|
217
|
+
|
|
218
|
+
const props = persistence.getSuperProperties();
|
|
219
|
+
expect(props.tier).toBe('premium');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should set and get multiple super properties', () => {
|
|
223
|
+
persistence.setSuperProperties({
|
|
224
|
+
tier: 'enterprise',
|
|
225
|
+
region: 'us-west',
|
|
226
|
+
beta_user: true,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const props = persistence.getSuperProperties();
|
|
230
|
+
expect(props.tier).toBe('enterprise');
|
|
231
|
+
expect(props.region).toBe('us-west');
|
|
232
|
+
expect(props.beta_user).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should merge properties when setting multiple times', () => {
|
|
236
|
+
persistence.setSuperProperty('first', 'value1');
|
|
237
|
+
persistence.setSuperProperties({
|
|
238
|
+
second: 'value2',
|
|
239
|
+
third: 'value3',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const props = persistence.getSuperProperties();
|
|
243
|
+
expect(props.first).toBe('value1');
|
|
244
|
+
expect(props.second).toBe('value2');
|
|
245
|
+
expect(props.third).toBe('value3');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should override existing property with same key', () => {
|
|
249
|
+
persistence.setSuperProperty('key', 'original');
|
|
250
|
+
persistence.setSuperProperty('key', 'updated');
|
|
251
|
+
|
|
252
|
+
const props = persistence.getSuperProperties();
|
|
253
|
+
expect(props.key).toBe('updated');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should remove a single super property', () => {
|
|
257
|
+
persistence.setSuperProperties({
|
|
258
|
+
keep: 'this',
|
|
259
|
+
remove: 'this',
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
persistence.removeSuperProperty('remove');
|
|
263
|
+
|
|
264
|
+
const props = persistence.getSuperProperties();
|
|
265
|
+
expect(props.keep).toBe('this');
|
|
266
|
+
expect(props.remove).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should handle removing non-existent property gracefully', () => {
|
|
270
|
+
persistence.setSuperProperty('exists', 'value');
|
|
271
|
+
|
|
272
|
+
expect(() => persistence.removeSuperProperty('nonexistent')).not.toThrow();
|
|
273
|
+
|
|
274
|
+
const props = persistence.getSuperProperties();
|
|
275
|
+
expect(props.exists).toBe('value');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should clear all super properties', () => {
|
|
279
|
+
persistence.setSuperProperties({
|
|
280
|
+
prop1: 'value1',
|
|
281
|
+
prop2: 'value2',
|
|
282
|
+
prop3: 'value3',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
persistence.clearSuperProperties();
|
|
286
|
+
|
|
287
|
+
const props = persistence.getSuperProperties();
|
|
288
|
+
expect(Object.keys(props)).toHaveLength(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should persist super properties to localStorage', () => {
|
|
292
|
+
persistence.setSuperProperties({
|
|
293
|
+
persistent: 'value',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
297
|
+
'mostlygoodmetrics_super_properties',
|
|
298
|
+
JSON.stringify({ persistent: 'value' })
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should handle various value types', () => {
|
|
303
|
+
persistence.setSuperProperties({
|
|
304
|
+
string_val: 'text',
|
|
305
|
+
number_val: 42,
|
|
306
|
+
boolean_val: true,
|
|
307
|
+
null_val: null,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const props = persistence.getSuperProperties();
|
|
311
|
+
expect(props.string_val).toBe('text');
|
|
312
|
+
expect(props.number_val).toBe(42);
|
|
313
|
+
expect(props.boolean_val).toBe(true);
|
|
314
|
+
expect(props.null_val).toBe(null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should handle localStorage getItem returning corrupted JSON', () => {
|
|
318
|
+
(localStorage.getItem as jest.Mock).mockReturnValueOnce('not valid json {');
|
|
319
|
+
|
|
320
|
+
// Should not throw and should return empty object
|
|
321
|
+
const props = persistence.getSuperProperties();
|
|
322
|
+
expect(props).toEqual({});
|
|
323
|
+
});
|
|
324
|
+
});
|
package/src/storage.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { logger } from './logger';
|
|
2
|
-
import { Constraints, IEventStorage, MGMError, MGMEvent } from './types';
|
|
2
|
+
import { Constraints, EventProperties, IEventStorage, MGMError, MGMEvent } from './types';
|
|
3
3
|
|
|
4
4
|
const STORAGE_KEY = 'mostlygoodmetrics_events';
|
|
5
5
|
const USER_ID_KEY = 'mostlygoodmetrics_user_id';
|
|
6
6
|
const APP_VERSION_KEY = 'mostlygoodmetrics_app_version';
|
|
7
|
+
const SUPER_PROPERTIES_KEY = 'mostlygoodmetrics_super_properties';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Check if we're running in a browser environment with localStorage available.
|
|
@@ -177,6 +178,7 @@ export function createDefaultStorage(maxEvents: number): IEventStorage {
|
|
|
177
178
|
class PersistenceManager {
|
|
178
179
|
private inMemoryUserId: string | null = null;
|
|
179
180
|
private inMemoryAppVersion: string | null = null;
|
|
181
|
+
private inMemorySuperProperties: EventProperties = {};
|
|
180
182
|
|
|
181
183
|
/**
|
|
182
184
|
* Get the persisted user ID.
|
|
@@ -244,6 +246,69 @@ class PersistenceManager {
|
|
|
244
246
|
}
|
|
245
247
|
return false;
|
|
246
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get all super properties.
|
|
252
|
+
*/
|
|
253
|
+
getSuperProperties(): EventProperties {
|
|
254
|
+
if (isLocalStorageAvailable()) {
|
|
255
|
+
try {
|
|
256
|
+
const stored = localStorage.getItem(SUPER_PROPERTIES_KEY);
|
|
257
|
+
if (stored) {
|
|
258
|
+
return JSON.parse(stored) as EventProperties;
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
logger.warn('Failed to load super properties from localStorage', e);
|
|
262
|
+
}
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
return { ...this.inMemorySuperProperties };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Set a single super property.
|
|
270
|
+
*/
|
|
271
|
+
setSuperProperty(key: string, value: EventProperties[string]): void {
|
|
272
|
+
const properties = this.getSuperProperties();
|
|
273
|
+
properties[key] = value;
|
|
274
|
+
this.saveSuperProperties(properties);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Set multiple super properties at once.
|
|
279
|
+
*/
|
|
280
|
+
setSuperProperties(properties: EventProperties): void {
|
|
281
|
+
const current = this.getSuperProperties();
|
|
282
|
+
const merged = { ...current, ...properties };
|
|
283
|
+
this.saveSuperProperties(merged);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Remove a single super property.
|
|
288
|
+
*/
|
|
289
|
+
removeSuperProperty(key: string): void {
|
|
290
|
+
const properties = this.getSuperProperties();
|
|
291
|
+
delete properties[key];
|
|
292
|
+
this.saveSuperProperties(properties);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Clear all super properties.
|
|
297
|
+
*/
|
|
298
|
+
clearSuperProperties(): void {
|
|
299
|
+
this.saveSuperProperties({});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private saveSuperProperties(properties: EventProperties): void {
|
|
303
|
+
this.inMemorySuperProperties = properties;
|
|
304
|
+
if (isLocalStorageAvailable()) {
|
|
305
|
+
try {
|
|
306
|
+
localStorage.setItem(SUPER_PROPERTIES_KEY, JSON.stringify(properties));
|
|
307
|
+
} catch (e) {
|
|
308
|
+
logger.warn('Failed to save super properties to localStorage', e);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
247
312
|
}
|
|
248
313
|
|
|
249
314
|
export const persistence = new PersistenceManager();
|