@mostly-good-metrics/react-native 0.1.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/src/index.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { AppState, Platform } from 'react-native';
2
+ import type { AppStateStatus } from 'react-native';
3
+ import {
4
+ MostlyGoodMetrics as MGMClient,
5
+ type MGMConfiguration,
6
+ type EventProperties,
7
+ SystemEvents,
8
+ SystemProperties,
9
+ } from '@mostly-good-metrics/javascript';
10
+ import { AsyncStorageEventStorage, persistence, getStorageType } from './storage';
11
+
12
+ export type { MGMConfiguration, EventProperties };
13
+
14
+ export interface ReactNativeConfig extends Omit<MGMConfiguration, 'storage'> {
15
+ /**
16
+ * The app version string. Required for install/update tracking.
17
+ */
18
+ appVersion?: string;
19
+ }
20
+
21
+ // Use global to persist state across hot reloads
22
+ const g = globalThis as typeof globalThis & {
23
+ __MGM_RN_STATE__?: {
24
+ appStateSubscription: { remove: () => void } | null;
25
+ isConfigured: boolean;
26
+ currentAppState: AppStateStatus;
27
+ debugLogging: boolean;
28
+ lastLifecycleEvent: { name: string; time: number } | null;
29
+ };
30
+ };
31
+
32
+ // Initialize or restore state
33
+ if (!g.__MGM_RN_STATE__) {
34
+ g.__MGM_RN_STATE__ = {
35
+ appStateSubscription: null,
36
+ isConfigured: false,
37
+ currentAppState: AppState.currentState,
38
+ debugLogging: false,
39
+ lastLifecycleEvent: null,
40
+ };
41
+ }
42
+
43
+ const state = g.__MGM_RN_STATE__;
44
+
45
+ const DEDUPE_INTERVAL_MS = 1000; // Ignore duplicate events within 1 second
46
+
47
+ function log(...args: unknown[]) {
48
+ if (state.debugLogging) {
49
+ console.log('[MostlyGoodMetrics]', ...args);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Track a lifecycle event with deduplication.
55
+ */
56
+ function trackLifecycleEvent(eventName: string, properties?: EventProperties) {
57
+ const now = Date.now();
58
+
59
+ // Deduplicate events that fire multiple times in quick succession
60
+ if (state.lastLifecycleEvent &&
61
+ state.lastLifecycleEvent.name === eventName &&
62
+ now - state.lastLifecycleEvent.time < DEDUPE_INTERVAL_MS) {
63
+ log(`Skipping duplicate ${eventName} (${now - state.lastLifecycleEvent.time}ms ago)`);
64
+ return;
65
+ }
66
+
67
+ state.lastLifecycleEvent = { name: eventName, time: now };
68
+ log(`Tracking lifecycle event: ${eventName}`);
69
+ MGMClient.track(eventName, properties);
70
+ }
71
+
72
+ /**
73
+ * Handle app state changes for lifecycle tracking.
74
+ */
75
+ function handleAppStateChange(nextAppState: AppStateStatus) {
76
+ if (!MGMClient.shared) return;
77
+
78
+ log(`AppState change: ${state.currentAppState} -> ${nextAppState}`);
79
+
80
+ // App came to foreground
81
+ if (state.currentAppState.match(/inactive|background/) && nextAppState === 'active') {
82
+ trackLifecycleEvent(SystemEvents.APP_OPENED);
83
+ }
84
+
85
+ // App went to background
86
+ if (state.currentAppState === 'active' && nextAppState.match(/inactive|background/)) {
87
+ trackLifecycleEvent(SystemEvents.APP_BACKGROUNDED);
88
+ // Flush events when going to background
89
+ MGMClient.flush().catch((e) => log('Flush error:', e));
90
+ }
91
+
92
+ state.currentAppState = nextAppState;
93
+ }
94
+
95
+ /**
96
+ * Track app install or update events.
97
+ */
98
+ async function trackInstallOrUpdate(appVersion?: string) {
99
+ if (!appVersion) return;
100
+
101
+ const previousVersion = await persistence.getAppVersion();
102
+ const isFirst = await persistence.isFirstLaunch();
103
+
104
+ if (isFirst) {
105
+ trackLifecycleEvent(SystemEvents.APP_INSTALLED, {
106
+ [SystemProperties.VERSION]: appVersion,
107
+ });
108
+ await persistence.setAppVersion(appVersion);
109
+ } else if (previousVersion && previousVersion !== appVersion) {
110
+ trackLifecycleEvent(SystemEvents.APP_UPDATED, {
111
+ [SystemProperties.VERSION]: appVersion,
112
+ [SystemProperties.PREVIOUS_VERSION]: previousVersion,
113
+ });
114
+ await persistence.setAppVersion(appVersion);
115
+ } else if (!previousVersion) {
116
+ await persistence.setAppVersion(appVersion);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * MostlyGoodMetrics React Native SDK
122
+ */
123
+ const MostlyGoodMetrics = {
124
+ /**
125
+ * Configure the SDK with an API key and optional settings.
126
+ */
127
+ configure(apiKey: string, config: Omit<ReactNativeConfig, 'apiKey'> = {}): void {
128
+ // Check both our state and the underlying JS SDK
129
+ if (state.isConfigured || MGMClient.isConfigured) {
130
+ log('Already configured, skipping');
131
+ return;
132
+ }
133
+
134
+ state.debugLogging = config.enableDebugLogging ?? false;
135
+ log('Configuring with options:', config);
136
+
137
+ // Create AsyncStorage-based storage
138
+ const storage = new AsyncStorageEventStorage(config.maxStoredEvents);
139
+
140
+ // Restore user ID from storage
141
+ persistence.getUserId().then((userId) => {
142
+ if (userId) {
143
+ log('Restored user ID:', userId);
144
+ }
145
+ });
146
+
147
+ // Configure the JS SDK
148
+ // Disable its built-in lifecycle tracking since we handle it ourselves
149
+ MGMClient.configure({
150
+ apiKey,
151
+ ...config,
152
+ storage,
153
+ osVersion: getOSVersion(),
154
+ trackAppLifecycleEvents: false, // We handle this with AppState
155
+ });
156
+
157
+ state.isConfigured = true;
158
+
159
+ // Set up React Native lifecycle tracking
160
+ if (config.trackAppLifecycleEvents !== false) {
161
+ log('Setting up lifecycle tracking, currentAppState:', state.currentAppState);
162
+
163
+ // Remove any existing listener (in case of hot reload)
164
+ if (state.appStateSubscription) {
165
+ state.appStateSubscription.remove();
166
+ state.appStateSubscription = null;
167
+ }
168
+
169
+ // Track initial app open
170
+ trackLifecycleEvent(SystemEvents.APP_OPENED);
171
+
172
+ // Track install/update
173
+ trackInstallOrUpdate(config.appVersion).catch((e) => log('Install/update tracking error:', e));
174
+
175
+ // Subscribe to app state changes
176
+ state.appStateSubscription = AppState.addEventListener('change', handleAppStateChange);
177
+ }
178
+ },
179
+
180
+ /**
181
+ * Track an event with optional properties.
182
+ */
183
+ track(name: string, properties?: EventProperties): void {
184
+ if (!state.isConfigured) {
185
+ console.warn('[MostlyGoodMetrics] SDK not configured. Call configure() first.');
186
+ return;
187
+ }
188
+
189
+ // Add React Native specific properties
190
+ const enrichedProperties: EventProperties = {
191
+ [SystemProperties.DEVICE_TYPE]: getDeviceType(),
192
+ $storage_type: getStorageType(),
193
+ ...properties,
194
+ };
195
+
196
+ MGMClient.track(name, enrichedProperties);
197
+ },
198
+
199
+ /**
200
+ * Identify a user.
201
+ */
202
+ identify(userId: string): void {
203
+ if (!state.isConfigured) {
204
+ console.warn('[MostlyGoodMetrics] SDK not configured. Call configure() first.');
205
+ return;
206
+ }
207
+
208
+ log('Identifying user:', userId);
209
+ MGMClient.identify(userId);
210
+ // Also persist to AsyncStorage for restoration
211
+ persistence.setUserId(userId).catch((e) => log('Failed to persist user ID:', e));
212
+ },
213
+
214
+ /**
215
+ * Clear the current user identity.
216
+ */
217
+ resetIdentity(): void {
218
+ if (!state.isConfigured) return;
219
+
220
+ log('Resetting identity');
221
+ MGMClient.resetIdentity();
222
+ persistence.setUserId(null).catch((e) => log('Failed to clear user ID:', e));
223
+ },
224
+
225
+ /**
226
+ * Manually flush pending events to the server.
227
+ */
228
+ flush(): void {
229
+ if (!state.isConfigured) return;
230
+
231
+ log('Flushing events');
232
+ MGMClient.flush().catch((e) => log('Flush error:', e));
233
+ },
234
+
235
+ /**
236
+ * Start a new session with a fresh session ID.
237
+ */
238
+ startNewSession(): void {
239
+ if (!state.isConfigured) return;
240
+
241
+ log('Starting new session');
242
+ MGMClient.startNewSession();
243
+ },
244
+
245
+ /**
246
+ * Clear all pending events without sending them.
247
+ */
248
+ clearPendingEvents(): void {
249
+ if (!state.isConfigured) return;
250
+
251
+ log('Clearing pending events');
252
+ MGMClient.clearPendingEvents().catch((e) => log('Clear error:', e));
253
+ },
254
+
255
+ /**
256
+ * Get the number of pending events.
257
+ */
258
+ async getPendingEventCount(): Promise<number> {
259
+ if (!state.isConfigured) return 0;
260
+ return MGMClient.getPendingEventCount();
261
+ },
262
+
263
+ /**
264
+ * Clean up resources. Call when unmounting the app.
265
+ */
266
+ destroy(): void {
267
+ if (state.appStateSubscription) {
268
+ state.appStateSubscription.remove();
269
+ state.appStateSubscription = null;
270
+ }
271
+ MGMClient.reset();
272
+ state.isConfigured = false;
273
+ state.lastLifecycleEvent = null;
274
+ log('Destroyed');
275
+ },
276
+ };
277
+
278
+ /**
279
+ * Get device type based on platform.
280
+ */
281
+ function getDeviceType(): string {
282
+ if (Platform.OS === 'ios') {
283
+ // Could use react-native-device-info for more accuracy
284
+ return Platform.isPad ? 'tablet' : 'phone';
285
+ }
286
+ if (Platform.OS === 'android') {
287
+ return 'phone'; // Could detect tablet with dimensions
288
+ }
289
+ return 'unknown';
290
+ }
291
+
292
+ /**
293
+ * Get OS version based on platform.
294
+ */
295
+ function getOSVersion(): string {
296
+ const version = Platform.Version;
297
+ if (Platform.OS === 'ios') {
298
+ // iOS returns a string like "15.0"
299
+ return String(version);
300
+ }
301
+ if (Platform.OS === 'android') {
302
+ // Android returns SDK version number (e.g., 31 for Android 12)
303
+ return String(version);
304
+ }
305
+ return 'unknown';
306
+ }
307
+
308
+ export default MostlyGoodMetrics;
package/src/storage.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type { IEventStorage, MGMEvent } from '@mostly-good-metrics/javascript';
2
+
3
+ const STORAGE_KEY = 'mostlygoodmetrics_events';
4
+ const USER_ID_KEY = 'mostlygoodmetrics_user_id';
5
+ const APP_VERSION_KEY = 'mostlygoodmetrics_app_version';
6
+ const FIRST_LAUNCH_KEY = 'mostlygoodmetrics_installed';
7
+
8
+ // Try to import AsyncStorage, fall back to null if not available
9
+ let AsyncStorage: typeof import('@react-native-async-storage/async-storage').default | null = null;
10
+ try {
11
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
12
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
13
+ } catch {
14
+ // AsyncStorage not installed - will use in-memory storage
15
+ }
16
+
17
+ /**
18
+ * Returns the storage type being used.
19
+ */
20
+ export function getStorageType(): 'persistent' | 'memory' {
21
+ return AsyncStorage ? 'persistent' : 'memory';
22
+ }
23
+
24
+ /**
25
+ * In-memory fallback storage when AsyncStorage is not available.
26
+ */
27
+ const memoryStorage: Record<string, string> = {};
28
+
29
+ /**
30
+ * Storage helpers that work with or without AsyncStorage.
31
+ */
32
+ async function getItem(key: string): Promise<string | null> {
33
+ if (AsyncStorage) {
34
+ try {
35
+ return await AsyncStorage.getItem(key);
36
+ } catch {
37
+ return memoryStorage[key] ?? null;
38
+ }
39
+ }
40
+ return memoryStorage[key] ?? null;
41
+ }
42
+
43
+ async function setItem(key: string, value: string): Promise<void> {
44
+ memoryStorage[key] = value;
45
+ if (AsyncStorage) {
46
+ try {
47
+ await AsyncStorage.setItem(key, value);
48
+ } catch {
49
+ // Fall back to memory storage (already set above)
50
+ }
51
+ }
52
+ }
53
+
54
+ async function removeItem(key: string): Promise<void> {
55
+ delete memoryStorage[key];
56
+ if (AsyncStorage) {
57
+ try {
58
+ await AsyncStorage.removeItem(key);
59
+ } catch {
60
+ // Already removed from memory
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Event storage for React Native.
67
+ * Uses AsyncStorage if available, otherwise falls back to in-memory storage.
68
+ */
69
+ export class AsyncStorageEventStorage implements IEventStorage {
70
+ private maxEvents: number;
71
+ private events: MGMEvent[] | null = null;
72
+
73
+ constructor(maxEvents: number = 10000) {
74
+ this.maxEvents = Math.max(maxEvents, 100);
75
+ }
76
+
77
+ private async loadEvents(): Promise<MGMEvent[]> {
78
+ if (this.events !== null) {
79
+ return this.events;
80
+ }
81
+
82
+ try {
83
+ const stored = await getItem(STORAGE_KEY);
84
+ if (stored) {
85
+ this.events = JSON.parse(stored) as MGMEvent[];
86
+ } else {
87
+ this.events = [];
88
+ }
89
+ } catch {
90
+ this.events = [];
91
+ }
92
+
93
+ return this.events;
94
+ }
95
+
96
+ private async saveEvents(): Promise<void> {
97
+ await setItem(STORAGE_KEY, JSON.stringify(this.events ?? []));
98
+ }
99
+
100
+ async store(event: MGMEvent): Promise<void> {
101
+ const events = await this.loadEvents();
102
+ events.push(event);
103
+
104
+ // Trim oldest events if we exceed the limit
105
+ if (events.length > this.maxEvents) {
106
+ const excess = events.length - this.maxEvents;
107
+ events.splice(0, excess);
108
+ }
109
+
110
+ await this.saveEvents();
111
+ }
112
+
113
+ async fetchEvents(limit: number): Promise<MGMEvent[]> {
114
+ const events = await this.loadEvents();
115
+ return events.slice(0, limit);
116
+ }
117
+
118
+ async removeEvents(count: number): Promise<void> {
119
+ const events = await this.loadEvents();
120
+ events.splice(0, count);
121
+ await this.saveEvents();
122
+ }
123
+
124
+ async eventCount(): Promise<number> {
125
+ const events = await this.loadEvents();
126
+ return events.length;
127
+ }
128
+
129
+ async clear(): Promise<void> {
130
+ this.events = [];
131
+ await removeItem(STORAGE_KEY);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Persistence helpers for user ID and app version.
137
+ */
138
+ export const persistence = {
139
+ async getUserId(): Promise<string | null> {
140
+ return getItem(USER_ID_KEY);
141
+ },
142
+
143
+ async setUserId(userId: string | null): Promise<void> {
144
+ if (userId) {
145
+ await setItem(USER_ID_KEY, userId);
146
+ } else {
147
+ await removeItem(USER_ID_KEY);
148
+ }
149
+ },
150
+
151
+ async getAppVersion(): Promise<string | null> {
152
+ return getItem(APP_VERSION_KEY);
153
+ },
154
+
155
+ async setAppVersion(version: string | null): Promise<void> {
156
+ if (version) {
157
+ await setItem(APP_VERSION_KEY, version);
158
+ } else {
159
+ await removeItem(APP_VERSION_KEY);
160
+ }
161
+ },
162
+
163
+ async isFirstLaunch(): Promise<boolean> {
164
+ const hasLaunched = await getItem(FIRST_LAUNCH_KEY);
165
+ if (!hasLaunched) {
166
+ await setItem(FIRST_LAUNCH_KEY, 'true');
167
+ return true;
168
+ }
169
+ return false;
170
+ },
171
+ };