@mostly-good-metrics/javascript 0.1.0 → 0.4.0
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 +76 -4
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/storage.js +61 -0
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/types.js +1 -0
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +25 -5
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +77 -5
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/storage.js +61 -0
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/types.js +1 -0
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +23 -5
- 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/index.d.ts +1 -1
- package/dist/types/index.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 +37 -2
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts +9 -0
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +181 -0
- package/src/client.ts +88 -5
- package/src/index.ts +1 -0
- package/src/storage.test.ts +150 -1
- package/src/storage.ts +66 -1
- package/src/types.ts +44 -2
- package/src/utils.test.ts +31 -0
- package/src/utils.ts +24 -6
package/src/client.test.ts
CHANGED
|
@@ -144,6 +144,70 @@ describe('MostlyGoodMetrics', () => {
|
|
|
144
144
|
expect(events[0].environment).toBe('production');
|
|
145
145
|
expect(events[0].platform).toBeDefined();
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
it('should include $sdk property defaulting to javascript', async () => {
|
|
149
|
+
MostlyGoodMetrics.track('test_event');
|
|
150
|
+
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
152
|
+
|
|
153
|
+
const events = await storage.fetchEvents(1);
|
|
154
|
+
expect(events[0].properties?.$sdk).toBe('javascript');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('platform and sdk configuration', () => {
|
|
159
|
+
it('should use configured platform', async () => {
|
|
160
|
+
MostlyGoodMetrics.reset();
|
|
161
|
+
MostlyGoodMetrics.configure({
|
|
162
|
+
apiKey: 'test-key',
|
|
163
|
+
storage,
|
|
164
|
+
networkClient,
|
|
165
|
+
trackAppLifecycleEvents: false,
|
|
166
|
+
platform: 'ios',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
MostlyGoodMetrics.track('test_event');
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
171
|
+
|
|
172
|
+
const events = await storage.fetchEvents(1);
|
|
173
|
+
expect(events[0].platform).toBe('ios');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should use configured sdk', async () => {
|
|
177
|
+
MostlyGoodMetrics.reset();
|
|
178
|
+
MostlyGoodMetrics.configure({
|
|
179
|
+
apiKey: 'test-key',
|
|
180
|
+
storage,
|
|
181
|
+
networkClient,
|
|
182
|
+
trackAppLifecycleEvents: false,
|
|
183
|
+
sdk: 'react-native',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
MostlyGoodMetrics.track('test_event');
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
188
|
+
|
|
189
|
+
const events = await storage.fetchEvents(1);
|
|
190
|
+
expect(events[0].properties?.$sdk).toBe('react-native');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should allow both platform and sdk to be configured together', async () => {
|
|
194
|
+
MostlyGoodMetrics.reset();
|
|
195
|
+
MostlyGoodMetrics.configure({
|
|
196
|
+
apiKey: 'test-key',
|
|
197
|
+
storage,
|
|
198
|
+
networkClient,
|
|
199
|
+
trackAppLifecycleEvents: false,
|
|
200
|
+
platform: 'android',
|
|
201
|
+
sdk: 'react-native',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
MostlyGoodMetrics.track('test_event');
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
206
|
+
|
|
207
|
+
const events = await storage.fetchEvents(1);
|
|
208
|
+
expect(events[0].platform).toBe('android');
|
|
209
|
+
expect(events[0].properties?.$sdk).toBe('react-native');
|
|
210
|
+
});
|
|
147
211
|
});
|
|
148
212
|
|
|
149
213
|
describe('identify', () => {
|
|
@@ -342,5 +406,122 @@ describe('MostlyGoodMetrics', () => {
|
|
|
342
406
|
await expect(MostlyGoodMetrics.clearPendingEvents()).resolves.not.toThrow();
|
|
343
407
|
await expect(MostlyGoodMetrics.getPendingEventCount()).resolves.toBe(0);
|
|
344
408
|
});
|
|
409
|
+
|
|
410
|
+
it('should not throw for super property methods when not configured', () => {
|
|
411
|
+
expect(() => MostlyGoodMetrics.setSuperProperty('key', 'value')).not.toThrow();
|
|
412
|
+
expect(() => MostlyGoodMetrics.setSuperProperties({ key: 'value' })).not.toThrow();
|
|
413
|
+
expect(() => MostlyGoodMetrics.removeSuperProperty('key')).not.toThrow();
|
|
414
|
+
expect(() => MostlyGoodMetrics.clearSuperProperties()).not.toThrow();
|
|
415
|
+
expect(MostlyGoodMetrics.getSuperProperties()).toEqual({});
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('super properties', () => {
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
MostlyGoodMetrics.configure({
|
|
422
|
+
apiKey: 'test-key',
|
|
423
|
+
storage,
|
|
424
|
+
networkClient,
|
|
425
|
+
trackAppLifecycleEvents: false,
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(() => {
|
|
430
|
+
MostlyGoodMetrics.clearSuperProperties();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should set a single super property', () => {
|
|
434
|
+
MostlyGoodMetrics.setSuperProperty('plan', 'premium');
|
|
435
|
+
|
|
436
|
+
const props = MostlyGoodMetrics.getSuperProperties();
|
|
437
|
+
expect(props.plan).toBe('premium');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should set multiple super properties at once', () => {
|
|
441
|
+
MostlyGoodMetrics.setSuperProperties({
|
|
442
|
+
plan: 'enterprise',
|
|
443
|
+
role: 'admin',
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const props = MostlyGoodMetrics.getSuperProperties();
|
|
447
|
+
expect(props.plan).toBe('enterprise');
|
|
448
|
+
expect(props.role).toBe('admin');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should merge with existing super properties', () => {
|
|
452
|
+
MostlyGoodMetrics.setSuperProperty('existing', 'value');
|
|
453
|
+
MostlyGoodMetrics.setSuperProperties({
|
|
454
|
+
new_prop: 'new_value',
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const props = MostlyGoodMetrics.getSuperProperties();
|
|
458
|
+
expect(props.existing).toBe('value');
|
|
459
|
+
expect(props.new_prop).toBe('new_value');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should remove a single super property', () => {
|
|
463
|
+
MostlyGoodMetrics.setSuperProperties({
|
|
464
|
+
keep: 'this',
|
|
465
|
+
remove: 'this',
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
MostlyGoodMetrics.removeSuperProperty('remove');
|
|
469
|
+
|
|
470
|
+
const props = MostlyGoodMetrics.getSuperProperties();
|
|
471
|
+
expect(props.keep).toBe('this');
|
|
472
|
+
expect(props.remove).toBeUndefined();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should clear all super properties', () => {
|
|
476
|
+
MostlyGoodMetrics.setSuperProperties({
|
|
477
|
+
prop1: 'value1',
|
|
478
|
+
prop2: 'value2',
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
MostlyGoodMetrics.clearSuperProperties();
|
|
482
|
+
|
|
483
|
+
const props = MostlyGoodMetrics.getSuperProperties();
|
|
484
|
+
expect(Object.keys(props)).toHaveLength(0);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should include super properties in tracked events', async () => {
|
|
488
|
+
MostlyGoodMetrics.setSuperProperties({
|
|
489
|
+
user_tier: 'gold',
|
|
490
|
+
source: 'mobile',
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
MostlyGoodMetrics.track('purchase');
|
|
494
|
+
|
|
495
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
496
|
+
|
|
497
|
+
const events = await storage.fetchEvents(1);
|
|
498
|
+
expect(events[0].properties?.user_tier).toBe('gold');
|
|
499
|
+
expect(events[0].properties?.source).toBe('mobile');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should allow event properties to override super properties', async () => {
|
|
503
|
+
MostlyGoodMetrics.setSuperProperty('source', 'default');
|
|
504
|
+
|
|
505
|
+
MostlyGoodMetrics.track('click', {
|
|
506
|
+
source: 'override',
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
510
|
+
|
|
511
|
+
const events = await storage.fetchEvents(1);
|
|
512
|
+
expect(events[0].properties?.source).toBe('override');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should not allow super properties to override system properties', async () => {
|
|
516
|
+
MostlyGoodMetrics.setSuperProperty('$device_type', 'hacked');
|
|
517
|
+
|
|
518
|
+
MostlyGoodMetrics.track('test_event');
|
|
519
|
+
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
521
|
+
|
|
522
|
+
const events = await storage.fetchEvents(1);
|
|
523
|
+
// System properties should take precedence
|
|
524
|
+
expect(events[0].properties?.$device_type).not.toBe('hacked');
|
|
525
|
+
});
|
|
345
526
|
});
|
|
346
527
|
});
|
package/src/client.ts
CHANGED
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
delay,
|
|
18
18
|
detectDeviceType,
|
|
19
|
-
detectPlatform,
|
|
20
19
|
generateUUID,
|
|
21
20
|
getDeviceModel,
|
|
22
21
|
getISOTimestamp,
|
|
22
|
+
getLocale,
|
|
23
23
|
getOSVersion,
|
|
24
|
+
getTimezone,
|
|
24
25
|
resolveConfiguration,
|
|
25
26
|
sanitizeProperties,
|
|
26
27
|
validateEventName,
|
|
@@ -165,6 +166,41 @@ export class MostlyGoodMetrics {
|
|
|
165
166
|
return MostlyGoodMetrics.instance?.getPendingEventCount() ?? Promise.resolve(0);
|
|
166
167
|
}
|
|
167
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
|
+
|
|
168
204
|
// ============================================================
|
|
169
205
|
// Instance properties
|
|
170
206
|
// ============================================================
|
|
@@ -213,12 +249,16 @@ export class MostlyGoodMetrics {
|
|
|
213
249
|
}
|
|
214
250
|
|
|
215
251
|
const sanitizedProperties = sanitizeProperties(properties);
|
|
252
|
+
const superProperties = persistence.getSuperProperties();
|
|
216
253
|
|
|
217
|
-
//
|
|
254
|
+
// Merge properties: super properties < event properties < system properties
|
|
255
|
+
// Event properties override super properties, system properties are always added
|
|
218
256
|
const mergedProperties: EventProperties = {
|
|
257
|
+
...superProperties,
|
|
258
|
+
...sanitizedProperties,
|
|
219
259
|
[SystemProperties.DEVICE_TYPE]: detectDeviceType(),
|
|
220
260
|
[SystemProperties.DEVICE_MODEL]: getDeviceModel(),
|
|
221
|
-
|
|
261
|
+
[SystemProperties.SDK]: this.config.sdk,
|
|
222
262
|
};
|
|
223
263
|
|
|
224
264
|
const event: MGMEvent = {
|
|
@@ -226,10 +266,12 @@ export class MostlyGoodMetrics {
|
|
|
226
266
|
timestamp: getISOTimestamp(),
|
|
227
267
|
userId: this.userId ?? undefined,
|
|
228
268
|
sessionId: this.sessionIdValue,
|
|
229
|
-
platform:
|
|
269
|
+
platform: this.config.platform,
|
|
230
270
|
appVersion: this.config.appVersion || undefined,
|
|
231
271
|
osVersion: this.config.osVersion || getOSVersion() || undefined,
|
|
232
272
|
environment: this.config.environment,
|
|
273
|
+
locale: getLocale(),
|
|
274
|
+
timezone: getTimezone(),
|
|
233
275
|
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : undefined,
|
|
234
276
|
};
|
|
235
277
|
|
|
@@ -307,6 +349,45 @@ export class MostlyGoodMetrics {
|
|
|
307
349
|
return this.storage.eventCount();
|
|
308
350
|
}
|
|
309
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Set a single super property that will be included with every event.
|
|
354
|
+
*/
|
|
355
|
+
setSuperProperty(key: string, value: EventProperties[string]): void {
|
|
356
|
+
logger.debug(`Setting super property: ${key}`);
|
|
357
|
+
persistence.setSuperProperty(key, value);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Set multiple super properties at once.
|
|
362
|
+
*/
|
|
363
|
+
setSuperProperties(properties: EventProperties): void {
|
|
364
|
+
logger.debug(`Setting super properties: ${Object.keys(properties).join(', ')}`);
|
|
365
|
+
persistence.setSuperProperties(properties);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Remove a single super property.
|
|
370
|
+
*/
|
|
371
|
+
removeSuperProperty(key: string): void {
|
|
372
|
+
logger.debug(`Removing super property: ${key}`);
|
|
373
|
+
persistence.removeSuperProperty(key);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Clear all super properties.
|
|
378
|
+
*/
|
|
379
|
+
clearSuperProperties(): void {
|
|
380
|
+
logger.debug('Clearing all super properties');
|
|
381
|
+
persistence.clearSuperProperties();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get all current super properties.
|
|
386
|
+
*/
|
|
387
|
+
getSuperProperties(): EventProperties {
|
|
388
|
+
return persistence.getSuperProperties();
|
|
389
|
+
}
|
|
390
|
+
|
|
310
391
|
/**
|
|
311
392
|
* Clean up resources (stop timers, etc.).
|
|
312
393
|
*/
|
|
@@ -374,12 +455,14 @@ export class MostlyGoodMetrics {
|
|
|
374
455
|
|
|
375
456
|
private buildPayload(events: MGMEvent[]): MGMEventsPayload {
|
|
376
457
|
const context: MGMEventContext = {
|
|
377
|
-
platform:
|
|
458
|
+
platform: this.config.platform,
|
|
378
459
|
appVersion: this.config.appVersion || undefined,
|
|
379
460
|
osVersion: this.config.osVersion || getOSVersion() || undefined,
|
|
380
461
|
userId: this.userId ?? undefined,
|
|
381
462
|
sessionId: this.sessionIdValue,
|
|
382
463
|
environment: this.config.environment,
|
|
464
|
+
locale: getLocale(),
|
|
465
|
+
timezone: getTimezone(),
|
|
383
466
|
};
|
|
384
467
|
|
|
385
468
|
return { events, context };
|
package/src/index.ts
CHANGED
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();
|
package/src/types.ts
CHANGED
|
@@ -73,6 +73,18 @@ export interface MGMConfiguration {
|
|
|
73
73
|
*/
|
|
74
74
|
osVersion?: string;
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Override the auto-detected platform.
|
|
78
|
+
* Use this when wrapping the SDK (e.g., React Native should pass 'ios' or 'android').
|
|
79
|
+
*/
|
|
80
|
+
platform?: Platform;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The SDK identifier. Auto-set to 'javascript' but can be overridden by wrapper SDKs.
|
|
84
|
+
* @default "javascript"
|
|
85
|
+
*/
|
|
86
|
+
sdk?: SDK;
|
|
87
|
+
|
|
76
88
|
/**
|
|
77
89
|
* Custom storage adapter. If not provided, uses localStorage in browsers
|
|
78
90
|
* or in-memory storage in non-browser environments.
|
|
@@ -147,6 +159,11 @@ export interface MGMEvent {
|
|
|
147
159
|
*/
|
|
148
160
|
appVersion?: string;
|
|
149
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The app build number (separate from version).
|
|
164
|
+
*/
|
|
165
|
+
appBuildNumber?: string;
|
|
166
|
+
|
|
150
167
|
/**
|
|
151
168
|
* The OS version string.
|
|
152
169
|
*/
|
|
@@ -157,6 +174,21 @@ export interface MGMEvent {
|
|
|
157
174
|
*/
|
|
158
175
|
environment: string;
|
|
159
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The device manufacturer (e.g., "Apple", "Samsung").
|
|
179
|
+
*/
|
|
180
|
+
deviceManufacturer?: string;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The user's locale (e.g., "en-US").
|
|
184
|
+
*/
|
|
185
|
+
locale?: string;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The user's timezone (e.g., "America/New_York").
|
|
189
|
+
*/
|
|
190
|
+
timezone?: string;
|
|
191
|
+
|
|
160
192
|
/**
|
|
161
193
|
* Custom properties attached to this event.
|
|
162
194
|
*/
|
|
@@ -170,10 +202,14 @@ export interface MGMEvent {
|
|
|
170
202
|
export interface MGMEventContext {
|
|
171
203
|
platform: Platform;
|
|
172
204
|
appVersion?: string;
|
|
205
|
+
appBuildNumber?: string;
|
|
173
206
|
osVersion?: string;
|
|
174
207
|
userId?: string;
|
|
175
208
|
sessionId?: string;
|
|
176
209
|
environment: string;
|
|
210
|
+
deviceManufacturer?: string;
|
|
211
|
+
locale?: string;
|
|
212
|
+
timezone?: string;
|
|
177
213
|
}
|
|
178
214
|
|
|
179
215
|
/**
|
|
@@ -185,9 +221,14 @@ export interface MGMEventsPayload {
|
|
|
185
221
|
}
|
|
186
222
|
|
|
187
223
|
/**
|
|
188
|
-
* Supported platforms.
|
|
224
|
+
* Supported platforms (the actual OS/runtime).
|
|
225
|
+
*/
|
|
226
|
+
export type Platform = 'web' | 'ios' | 'android' | 'node';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* SDK identifiers.
|
|
189
230
|
*/
|
|
190
|
-
export type
|
|
231
|
+
export type SDK = 'javascript' | 'react-native' | 'swift' | 'android';
|
|
191
232
|
|
|
192
233
|
/**
|
|
193
234
|
* Device types for automatic device detection.
|
|
@@ -310,6 +351,7 @@ export const SystemProperties = {
|
|
|
310
351
|
DEVICE_MODEL: '$device_model',
|
|
311
352
|
VERSION: '$version',
|
|
312
353
|
PREVIOUS_VERSION: '$previous_version',
|
|
354
|
+
SDK: '$sdk',
|
|
313
355
|
} as const;
|
|
314
356
|
|
|
315
357
|
/**
|