@mostly-good-metrics/javascript 0.2.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.
@@ -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
@@ -159,6 +159,11 @@ export interface MGMEvent {
159
159
  */
160
160
  appVersion?: string;
161
161
 
162
+ /**
163
+ * The app build number (separate from version).
164
+ */
165
+ appBuildNumber?: string;
166
+
162
167
  /**
163
168
  * The OS version string.
164
169
  */
@@ -169,6 +174,21 @@ export interface MGMEvent {
169
174
  */
170
175
  environment: string;
171
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
+
172
192
  /**
173
193
  * Custom properties attached to this event.
174
194
  */
@@ -182,10 +202,14 @@ export interface MGMEvent {
182
202
  export interface MGMEventContext {
183
203
  platform: Platform;
184
204
  appVersion?: string;
205
+ appBuildNumber?: string;
185
206
  osVersion?: string;
186
207
  userId?: string;
187
208
  sessionId?: string;
188
209
  environment: string;
210
+ deviceManufacturer?: string;
211
+ locale?: string;
212
+ timezone?: string;
189
213
  }
190
214
 
191
215
  /**
package/src/utils.test.ts CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  validateEventName,
6
6
  sanitizeProperties,
7
7
  resolveConfiguration,
8
+ getLocale,
9
+ getTimezone,
8
10
  } from './utils';
9
11
  import { Constraints, DefaultConfiguration, MGMError } from './types';
10
12
 
@@ -237,3 +239,32 @@ describe('resolveConfiguration', () => {
237
239
  expect(config.maxStoredEvents).toBe(Constraints.MIN_STORED_EVENTS);
238
240
  });
239
241
  });
242
+
243
+ describe('getLocale', () => {
244
+ it('should return a non-empty string', () => {
245
+ const locale = getLocale();
246
+ expect(typeof locale).toBe('string');
247
+ expect(locale.length).toBeGreaterThan(0);
248
+ });
249
+
250
+ it('should return a locale-like string (e.g., en, en-US)', () => {
251
+ const locale = getLocale();
252
+ // Locale should be something like "en", "en-US", "fr-FR", etc.
253
+ expect(locale).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/i);
254
+ });
255
+ });
256
+
257
+ describe('getTimezone', () => {
258
+ it('should return a string', () => {
259
+ const timezone = getTimezone();
260
+ expect(typeof timezone).toBe('string');
261
+ });
262
+
263
+ it('should return a valid IANA timezone or empty string', () => {
264
+ const timezone = getTimezone();
265
+ // Should be empty or a valid IANA timezone like "America/New_York" or "UTC"
266
+ if (timezone) {
267
+ expect(timezone).toMatch(/^([A-Za-z_]+\/[A-Za-z_]+|UTC)$/);
268
+ }
269
+ });
270
+ });
package/src/utils.ts CHANGED
@@ -310,3 +310,24 @@ export function getDeviceModel(): string {
310
310
  export function delay(ms: number): Promise<void> {
311
311
  return new Promise((resolve) => setTimeout(resolve, ms));
312
312
  }
313
+
314
+ /**
315
+ * Get the user's locale.
316
+ */
317
+ export function getLocale(): string {
318
+ if (typeof navigator !== 'undefined') {
319
+ return navigator.language || 'en';
320
+ }
321
+ return 'en';
322
+ }
323
+
324
+ /**
325
+ * Get the user's timezone.
326
+ */
327
+ export function getTimezone(): string {
328
+ try {
329
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
330
+ } catch {
331
+ return '';
332
+ }
333
+ }