react-native-nitro-amplitude 0.1.0 → 0.5.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.
Files changed (176) hide show
  1. package/README.md +547 -55
  2. package/cpp/bindings/HybridAmplitudeWorker.cpp +27 -7
  3. package/lib/commonjs/analytics/config.js +31 -10
  4. package/lib/commonjs/analytics/config.js.map +1 -1
  5. package/lib/commonjs/analytics/index.js +8 -2
  6. package/lib/commonjs/analytics/index.js.map +1 -1
  7. package/lib/commonjs/analytics/network-guarded-fetch-transport.js +16 -0
  8. package/lib/commonjs/analytics/network-guarded-fetch-transport.js.map +1 -0
  9. package/lib/commonjs/analytics/nitro-transport.js +2 -0
  10. package/lib/commonjs/analytics/nitro-transport.js.map +1 -1
  11. package/lib/commonjs/analytics/plugins/context.js +7 -1
  12. package/lib/commonjs/analytics/plugins/context.js.map +1 -1
  13. package/lib/commonjs/analytics/react-native-client.js +155 -9
  14. package/lib/commonjs/analytics/react-native-client.js.map +1 -1
  15. package/lib/commonjs/diagnostics.js +92 -0
  16. package/lib/commonjs/diagnostics.js.map +1 -0
  17. package/lib/commonjs/errors.js +48 -0
  18. package/lib/commonjs/errors.js.map +1 -0
  19. package/lib/commonjs/experiment/experimentClient.js +84 -2
  20. package/lib/commonjs/experiment/experimentClient.js.map +1 -1
  21. package/lib/commonjs/experiment/index.js +12 -0
  22. package/lib/commonjs/experiment/index.js.map +1 -1
  23. package/lib/commonjs/experiment/stubClient.js +25 -0
  24. package/lib/commonjs/experiment/stubClient.js.map +1 -1
  25. package/lib/commonjs/experiment/transport/http.js +8 -2
  26. package/lib/commonjs/experiment/transport/http.js.map +1 -1
  27. package/lib/commonjs/experiment/typed-variants.js +52 -0
  28. package/lib/commonjs/experiment/typed-variants.js.map +1 -0
  29. package/lib/commonjs/experiment/types/config.js +32 -4
  30. package/lib/commonjs/experiment/types/config.js.map +1 -1
  31. package/lib/commonjs/index.js +105 -3
  32. package/lib/commonjs/index.js.map +1 -1
  33. package/lib/commonjs/index.web.js +387 -13
  34. package/lib/commonjs/index.web.js.map +1 -1
  35. package/lib/commonjs/native/context.web.js +26 -0
  36. package/lib/commonjs/native/context.web.js.map +1 -0
  37. package/lib/commonjs/native/http.js +23 -8
  38. package/lib/commonjs/native/http.js.map +1 -1
  39. package/lib/commonjs/native/http.web.js +17 -0
  40. package/lib/commonjs/native/http.web.js.map +1 -0
  41. package/lib/commonjs/native/hybrid.web.js +23 -0
  42. package/lib/commonjs/native/hybrid.web.js.map +1 -0
  43. package/lib/commonjs/native/storage.js +27 -15
  44. package/lib/commonjs/native/storage.js.map +1 -1
  45. package/lib/commonjs/native/storage.web.js +135 -0
  46. package/lib/commonjs/native/storage.web.js.map +1 -0
  47. package/lib/commonjs/network.js +154 -0
  48. package/lib/commonjs/network.js.map +1 -0
  49. package/lib/commonjs/presets.js +118 -0
  50. package/lib/commonjs/presets.js.map +1 -0
  51. package/lib/commonjs/testing.js +166 -0
  52. package/lib/commonjs/testing.js.map +1 -0
  53. package/lib/module/analytics/config.js +32 -11
  54. package/lib/module/analytics/config.js.map +1 -1
  55. package/lib/module/analytics/index.js +4 -1
  56. package/lib/module/analytics/index.js.map +1 -1
  57. package/lib/module/analytics/network-guarded-fetch-transport.js +11 -0
  58. package/lib/module/analytics/network-guarded-fetch-transport.js.map +1 -0
  59. package/lib/module/analytics/nitro-transport.js +2 -0
  60. package/lib/module/analytics/nitro-transport.js.map +1 -1
  61. package/lib/module/analytics/plugins/context.js +7 -1
  62. package/lib/module/analytics/plugins/context.js.map +1 -1
  63. package/lib/module/analytics/react-native-client.js +154 -9
  64. package/lib/module/analytics/react-native-client.js.map +1 -1
  65. package/lib/module/diagnostics.js +85 -0
  66. package/lib/module/diagnostics.js.map +1 -0
  67. package/lib/module/errors.js +41 -0
  68. package/lib/module/errors.js.map +1 -0
  69. package/lib/module/experiment/experimentClient.js +84 -2
  70. package/lib/module/experiment/experimentClient.js.map +1 -1
  71. package/lib/module/experiment/index.js +1 -0
  72. package/lib/module/experiment/index.js.map +1 -1
  73. package/lib/module/experiment/stubClient.js +25 -0
  74. package/lib/module/experiment/stubClient.js.map +1 -1
  75. package/lib/module/experiment/transport/http.js +8 -2
  76. package/lib/module/experiment/transport/http.js.map +1 -1
  77. package/lib/module/experiment/typed-variants.js +43 -0
  78. package/lib/module/experiment/typed-variants.js.map +1 -0
  79. package/lib/module/experiment/types/config.js +31 -4
  80. package/lib/module/experiment/types/config.js.map +1 -1
  81. package/lib/module/index.js +15 -3
  82. package/lib/module/index.js.map +1 -1
  83. package/lib/module/index.web.js +60 -11
  84. package/lib/module/index.web.js.map +1 -1
  85. package/lib/module/native/context.web.js +18 -0
  86. package/lib/module/native/context.web.js.map +1 -0
  87. package/lib/module/native/http.js +23 -8
  88. package/lib/module/native/http.js.map +1 -1
  89. package/lib/module/native/http.web.js +12 -0
  90. package/lib/module/native/http.web.js.map +1 -0
  91. package/lib/module/native/hybrid.web.js +16 -0
  92. package/lib/module/native/hybrid.web.js.map +1 -0
  93. package/lib/module/native/storage.js +27 -15
  94. package/lib/module/native/storage.js.map +1 -1
  95. package/lib/module/native/storage.web.js +128 -0
  96. package/lib/module/native/storage.web.js.map +1 -0
  97. package/lib/module/network.js +138 -0
  98. package/lib/module/network.js.map +1 -0
  99. package/lib/module/presets.js +110 -0
  100. package/lib/module/presets.js.map +1 -0
  101. package/lib/module/testing.js +160 -0
  102. package/lib/module/testing.js.map +1 -0
  103. package/lib/typescript/analytics/config.d.ts +19 -6
  104. package/lib/typescript/analytics/config.d.ts.map +1 -1
  105. package/lib/typescript/analytics/index.d.ts +1 -1
  106. package/lib/typescript/analytics/index.d.ts.map +1 -1
  107. package/lib/typescript/analytics/network-guarded-fetch-transport.d.ts +6 -0
  108. package/lib/typescript/analytics/network-guarded-fetch-transport.d.ts.map +1 -0
  109. package/lib/typescript/analytics/nitro-transport.d.ts.map +1 -1
  110. package/lib/typescript/analytics/plugins/context.d.ts +2 -0
  111. package/lib/typescript/analytics/plugins/context.d.ts.map +1 -1
  112. package/lib/typescript/analytics/react-native-client.d.ts +46 -6
  113. package/lib/typescript/analytics/react-native-client.d.ts.map +1 -1
  114. package/lib/typescript/diagnostics.d.ts +21 -0
  115. package/lib/typescript/diagnostics.d.ts.map +1 -0
  116. package/lib/typescript/errors.d.ts +9 -0
  117. package/lib/typescript/errors.d.ts.map +1 -0
  118. package/lib/typescript/experiment/experimentClient.d.ts +9 -1
  119. package/lib/typescript/experiment/experimentClient.d.ts.map +1 -1
  120. package/lib/typescript/experiment/index.d.ts +1 -0
  121. package/lib/typescript/experiment/index.d.ts.map +1 -1
  122. package/lib/typescript/experiment/stubClient.d.ts +6 -1
  123. package/lib/typescript/experiment/stubClient.d.ts.map +1 -1
  124. package/lib/typescript/experiment/transport/http.d.ts.map +1 -1
  125. package/lib/typescript/experiment/typed-variants.d.ts +9 -0
  126. package/lib/typescript/experiment/typed-variants.d.ts.map +1 -0
  127. package/lib/typescript/experiment/types/client.d.ts +21 -0
  128. package/lib/typescript/experiment/types/client.d.ts.map +1 -1
  129. package/lib/typescript/experiment/types/config.d.ts.map +1 -1
  130. package/lib/typescript/index.d.ts +12 -3
  131. package/lib/typescript/index.d.ts.map +1 -1
  132. package/lib/typescript/index.web.d.ts +37 -8
  133. package/lib/typescript/index.web.d.ts.map +1 -1
  134. package/lib/typescript/native/context.web.d.ts +8 -0
  135. package/lib/typescript/native/context.web.d.ts.map +1 -0
  136. package/lib/typescript/native/http.d.ts.map +1 -1
  137. package/lib/typescript/native/http.web.d.ts +6 -0
  138. package/lib/typescript/native/http.web.d.ts.map +1 -0
  139. package/lib/typescript/native/hybrid.web.d.ts +8 -0
  140. package/lib/typescript/native/hybrid.web.d.ts.map +1 -0
  141. package/lib/typescript/native/storage.d.ts.map +1 -1
  142. package/lib/typescript/native/storage.web.d.ts +30 -0
  143. package/lib/typescript/native/storage.web.d.ts.map +1 -0
  144. package/lib/typescript/network.d.ts +50 -0
  145. package/lib/typescript/network.d.ts.map +1 -0
  146. package/lib/typescript/presets.d.ts +50 -0
  147. package/lib/typescript/presets.d.ts.map +1 -0
  148. package/lib/typescript/testing.d.ts +11 -0
  149. package/lib/typescript/testing.d.ts.map +1 -0
  150. package/package.json +4 -2
  151. package/src/analytics/config.ts +33 -8
  152. package/src/analytics/index.ts +3 -0
  153. package/src/analytics/network-guarded-fetch-transport.ts +10 -0
  154. package/src/analytics/nitro-transport.ts +2 -0
  155. package/src/analytics/plugins/context.ts +10 -1
  156. package/src/analytics/react-native-client.ts +238 -9
  157. package/src/diagnostics.ts +119 -0
  158. package/src/errors.ts +60 -0
  159. package/src/experiment/experimentClient.ts +116 -3
  160. package/src/experiment/index.ts +1 -0
  161. package/src/experiment/stubClient.ts +42 -1
  162. package/src/experiment/transport/http.ts +10 -2
  163. package/src/experiment/typed-variants.ts +68 -0
  164. package/src/experiment/types/client.ts +29 -0
  165. package/src/experiment/types/config.ts +29 -5
  166. package/src/index.ts +28 -2
  167. package/src/index.web.ts +89 -14
  168. package/src/native/context.web.ts +38 -0
  169. package/src/native/http.ts +38 -8
  170. package/src/native/http.web.ts +24 -0
  171. package/src/native/hybrid.web.ts +21 -0
  172. package/src/native/storage.ts +27 -25
  173. package/src/native/storage.web.ts +152 -0
  174. package/src/network.ts +258 -0
  175. package/src/presets.ts +208 -0
  176. package/src/testing.ts +177 -0
@@ -27,6 +27,7 @@ import {
27
27
  SpecialEventType,
28
28
  AnalyticsClient,
29
29
  } from "@amplitude/analytics-core";
30
+ import { healthCheck } from "../diagnostics";
30
31
  import { CampaignTracker } from "./campaign/campaign-tracker";
31
32
  import { Context } from "./plugins/context";
32
33
  import { useReactNativeConfig, createCookieStorage } from "./config";
@@ -41,14 +42,60 @@ type ScheduledDestination = {
41
42
  flushId?: ReturnType<typeof setTimeout> | null;
42
43
  queue?: unknown[];
43
44
  resetSchedule?: () => void;
45
+ fulfillRequest?: (list: unknown[], code: number, message: string) => unknown;
46
+ };
47
+
48
+ type FlushOutcome = {
49
+ code: number;
50
+ count: number;
51
+ message: string;
44
52
  };
45
53
 
46
54
  export type AmplitudeReactNativeClient = ReactNativeClient & {
47
55
  shutdown: () => void;
56
+ flushWithResult: () => Promise<AmplitudeFlushResult>;
57
+ getDiagnostics: () => AmplitudeAnalyticsDiagnostics;
58
+ healthCheck: () => Promise<AmplitudeHealthCheckResult>;
59
+ };
60
+
61
+ export type AmplitudeFlushResult = {
62
+ ok: boolean;
63
+ sent: number;
64
+ failed: number;
65
+ dropped: number;
66
+ retried: number;
67
+ reason?: string;
68
+ result?: Result;
69
+ finishedAt: number;
70
+ };
71
+
72
+ export type AmplitudeAnalyticsDiagnostics = {
73
+ initialized: boolean;
74
+ instanceName?: string;
75
+ userId?: string;
76
+ deviceId?: string;
77
+ sessionId?: number;
78
+ queueSize: number;
79
+ lastFlushTime?: number;
80
+ lastFlushDurationMillis?: number;
81
+ lastFlushError?: string;
82
+ activeInstanceNames: string[];
83
+ };
84
+
85
+ export type AmplitudeHealthCheckResult = {
86
+ ok: boolean;
87
+ analyticsInitialized: boolean;
88
+ nativeAvailable: boolean;
89
+ storageWritable: boolean;
90
+ errors: string[];
48
91
  };
49
92
 
50
93
  let nextConnectorOwnerId = 0;
51
- let activeConnectorOwnerId: number | undefined;
94
+ const activeConnectorOwnerIds = new Map<string, number>();
95
+
96
+ export function getActiveAnalyticsInstanceNames(): string[] {
97
+ return Array.from(activeConnectorOwnerIds.keys()).sort();
98
+ }
52
99
 
53
100
  export class AmplitudeReactNative
54
101
  extends AmplitudeCore
@@ -58,11 +105,14 @@ export class AmplitudeReactNative
58
105
  private appStateChangeHandler: NativeEventSubscription | undefined;
59
106
  private initPromise: Promise<void> | undefined;
60
107
  private readonly connectorOwnerId = ++nextConnectorOwnerId;
108
+ private lastFlushTime: number | undefined;
109
+ private lastFlushDurationMillis: number | undefined;
110
+ private lastFlushError: string | undefined;
61
111
  explicitSessionId: number | undefined;
62
112
 
63
113
  // @ts-ignore
64
114
  config: ReactNativeConfig;
65
- userProperties: { [key: string]: any } | undefined;
115
+ userProperties: Record<string, unknown> | undefined;
66
116
 
67
117
  init(apiKey = "", userId?: string, options?: ReactNativeOptions) {
68
118
  this.initPromise =
@@ -99,7 +149,8 @@ export class AmplitudeReactNative
99
149
  // Set up the analytics connector to integrate with the experiment SDK.
100
150
  // Send events from the experiment SDK and forward identifies to the
101
151
  // identity store.
102
- const connector = getAnalyticsConnector();
152
+ const connectorInstanceName = this.getConnectorInstanceName();
153
+ const connector = getAnalyticsConnector(connectorInstanceName);
103
154
  connector.identityStore.setIdentity({
104
155
  userId: this.config.userId,
105
156
  deviceId: this.config.deviceId,
@@ -140,7 +191,7 @@ export class AmplitudeReactNative
140
191
  this.track(event.eventType, event.eventProperties).promise,
141
192
  );
142
193
  });
143
- activeConnectorOwnerId = this.connectorOwnerId;
194
+ activeConnectorOwnerIds.set(connectorInstanceName, this.connectorOwnerId);
144
195
  } catch (error) {
145
196
  if (appStateHandlerInstalled) {
146
197
  this.appStateChangeHandler?.remove();
@@ -162,11 +213,18 @@ export class AmplitudeReactNative
162
213
  this.dispatchQ = [];
163
214
  this.isReady = false;
164
215
 
165
- if (activeConnectorOwnerId === this.connectorOwnerId) {
166
- const connector = getAnalyticsConnector();
216
+ const connectorInstanceName = this.config
217
+ ? this.getConnectorInstanceName()
218
+ : undefined;
219
+ if (
220
+ connectorInstanceName &&
221
+ activeConnectorOwnerIds.get(connectorInstanceName) ===
222
+ this.connectorOwnerId
223
+ ) {
224
+ const connector = getAnalyticsConnector(connectorInstanceName);
167
225
  connector.eventBridge.setEventReceiver(() => undefined);
168
226
  connector.identityStore.setIdentity({});
169
- activeConnectorOwnerId = undefined;
227
+ activeConnectorOwnerIds.delete(connectorInstanceName);
170
228
  }
171
229
  }
172
230
 
@@ -178,6 +236,10 @@ export class AmplitudeReactNative
178
236
  });
179
237
  }
180
238
 
239
+ private getConnectorInstanceName() {
240
+ return this.config.instanceName ?? "$default_instance";
241
+ }
242
+
181
243
  private cancelDestinationFlushes() {
182
244
  this.timeline.plugins.forEach((plugin) => {
183
245
  if (plugin.type !== "destination") {
@@ -232,7 +294,7 @@ export class AmplitudeReactNative
232
294
  return;
233
295
  }
234
296
  this.config.userId = userId;
235
- setConnectorUserId(userId);
297
+ setConnectorUserId(userId, this.getConnectorInstanceName());
236
298
  }
237
299
 
238
300
  getDeviceId() {
@@ -245,7 +307,7 @@ export class AmplitudeReactNative
245
307
  return;
246
308
  }
247
309
  this.config.deviceId = deviceId;
248
- setConnectorDeviceId(deviceId);
310
+ setConnectorDeviceId(deviceId, this.getConnectorInstanceName());
249
311
  }
250
312
 
251
313
  identify(identify: IIdentify, eventOptions?: EventOptions) {
@@ -267,6 +329,95 @@ export class AmplitudeReactNative
267
329
  return this.config?.sessionId;
268
330
  }
269
331
 
332
+ async flushWithResult(): Promise<AmplitudeFlushResult> {
333
+ const queueSize = this.getQueueSize();
334
+ const collector = this.collectFlushOutcomes();
335
+ const startedAt = Date.now();
336
+ try {
337
+ await this.flush().promise;
338
+ const outcomes = collector.finish();
339
+ this.lastFlushTime = Date.now();
340
+ this.lastFlushDurationMillis = this.lastFlushTime - startedAt;
341
+ const remainingQueueSize = this.getQueueSize();
342
+ const failedOutcomes = outcomes.filter(
343
+ (outcome) => !this.isSuccessStatusCode(outcome.code),
344
+ );
345
+ const dropped = failedOutcomes.reduce(
346
+ (total, outcome) => total + outcome.count,
347
+ 0,
348
+ );
349
+ if (remainingQueueSize > 0) {
350
+ this.lastFlushError = `Flush completed with ${remainingQueueSize} queued event(s) remaining`;
351
+ return {
352
+ ok: false,
353
+ sent: this.countSuccessfulOutcomes(outcomes),
354
+ failed: remainingQueueSize,
355
+ dropped,
356
+ retried: remainingQueueSize,
357
+ reason: this.lastFlushError,
358
+ finishedAt: this.lastFlushTime,
359
+ };
360
+ }
361
+ if (dropped > 0) {
362
+ this.lastFlushError =
363
+ failedOutcomes[0]?.message ??
364
+ `Flush dropped ${dropped} event(s) without retry`;
365
+ return {
366
+ ok: false,
367
+ sent: this.countSuccessfulOutcomes(outcomes),
368
+ failed: dropped,
369
+ dropped,
370
+ retried: 0,
371
+ reason: this.lastFlushError,
372
+ finishedAt: this.lastFlushTime,
373
+ };
374
+ }
375
+ this.lastFlushError = undefined;
376
+ return {
377
+ ok: true,
378
+ sent: this.countSuccessfulOutcomes(outcomes) || queueSize,
379
+ failed: 0,
380
+ dropped: 0,
381
+ retried: 0,
382
+ finishedAt: this.lastFlushTime,
383
+ };
384
+ } catch (error) {
385
+ collector.finish();
386
+ this.lastFlushTime = Date.now();
387
+ this.lastFlushDurationMillis = this.lastFlushTime - startedAt;
388
+ this.lastFlushError =
389
+ error instanceof Error ? error.message : String(error);
390
+ return {
391
+ ok: false,
392
+ sent: 0,
393
+ failed: queueSize,
394
+ dropped: 0,
395
+ retried: 0,
396
+ reason: this.lastFlushError,
397
+ finishedAt: this.lastFlushTime,
398
+ };
399
+ }
400
+ }
401
+
402
+ getDiagnostics(): AmplitudeAnalyticsDiagnostics {
403
+ return {
404
+ initialized: Boolean(this.config && this.isReady),
405
+ instanceName: this.config?.instanceName,
406
+ userId: this.getUserId(),
407
+ deviceId: this.getDeviceId(),
408
+ sessionId: this.getSessionId(),
409
+ queueSize: this.getQueueSize(),
410
+ lastFlushTime: this.lastFlushTime,
411
+ lastFlushDurationMillis: this.lastFlushDurationMillis,
412
+ lastFlushError: this.lastFlushError,
413
+ activeInstanceNames: getActiveAnalyticsInstanceNames(),
414
+ };
415
+ }
416
+
417
+ async healthCheck(): Promise<AmplitudeHealthCheckResult> {
418
+ return healthCheck(this);
419
+ }
420
+
270
421
  getIdentity() {
271
422
  return {
272
423
  userId: this.getUserId(),
@@ -297,6 +448,66 @@ export class AmplitudeReactNative
297
448
  this.config.lastEventTime = this.currentTimeMillis();
298
449
  }
299
450
 
451
+ private getQueueSize(): number {
452
+ let queueSize =
453
+ this.q.length + this.dispatchQ.length + this.timeline.queue.length;
454
+ this.timeline.plugins.forEach((plugin) => {
455
+ if (plugin.type !== "destination") {
456
+ return;
457
+ }
458
+ const destination = plugin as ScheduledDestination;
459
+ queueSize += destination.queue?.length ?? 0;
460
+ });
461
+ return queueSize;
462
+ }
463
+
464
+ private collectFlushOutcomes(): { finish: () => FlushOutcome[] } {
465
+ const outcomes: FlushOutcome[] = [];
466
+ const restorers: (() => void)[] = [];
467
+
468
+ this.timeline.plugins.forEach((plugin) => {
469
+ if (plugin.type !== "destination") {
470
+ return;
471
+ }
472
+
473
+ const destination = plugin as ScheduledDestination;
474
+ if (!destination.fulfillRequest) {
475
+ return;
476
+ }
477
+
478
+ const original = destination.fulfillRequest;
479
+ destination.fulfillRequest = (list, code, message) => {
480
+ outcomes.push({ code, count: list.length, message });
481
+ return original.call(destination, list, code, message);
482
+ };
483
+ restorers.push(() => {
484
+ destination.fulfillRequest = original;
485
+ });
486
+ });
487
+
488
+ return {
489
+ finish: () => {
490
+ while (restorers.length > 0) {
491
+ restorers.pop()?.();
492
+ }
493
+ return outcomes;
494
+ },
495
+ };
496
+ }
497
+
498
+ private countSuccessfulOutcomes(outcomes: FlushOutcome[]): number {
499
+ return outcomes.reduce((total, outcome) => {
500
+ if (!this.isSuccessStatusCode(outcome.code)) {
501
+ return total;
502
+ }
503
+ return total + outcome.count;
504
+ }, 0);
505
+ }
506
+
507
+ private isSuccessStatusCode(code: number): boolean {
508
+ return code >= 200 && code < 300;
509
+ }
510
+
300
511
  private setSessionIdInternal(sessionId: number, eventTime: number) {
301
512
  const previousSessionId = this.config.sessionId;
302
513
  if (previousSessionId === sessionId) {
@@ -507,6 +718,12 @@ export const createInstance = (): AmplitudeReactNativeClient => {
507
718
  getClientLogConfig(client),
508
719
  getClientStates(client, ["config.apiKey", "timeline.queue.length"]),
509
720
  ),
721
+ flushWithResult: debugWrapper(
722
+ client.flushWithResult.bind(client),
723
+ "flushWithResult",
724
+ getClientLogConfig(client),
725
+ getClientStates(client, ["config.apiKey", "timeline.queue.length"]),
726
+ ),
510
727
  getUserId: debugWrapper(
511
728
  client.getUserId.bind(client),
512
729
  "getUserId",
@@ -567,6 +784,18 @@ export const createInstance = (): AmplitudeReactNativeClient => {
567
784
  getClientLogConfig(client),
568
785
  getClientStates(client, ["config", "timeline.plugins"]),
569
786
  ),
787
+ getDiagnostics: debugWrapper(
788
+ client.getDiagnostics.bind(client),
789
+ "getDiagnostics",
790
+ getClientLogConfig(client),
791
+ getClientStates(client, ["config", "timeline.plugins"]),
792
+ ),
793
+ healthCheck: debugWrapper(
794
+ client.healthCheck.bind(client),
795
+ "healthCheck",
796
+ getClientLogConfig(client),
797
+ getClientStates(client, ["config", "timeline.plugins"]),
798
+ ),
570
799
  };
571
800
  };
572
801
 
@@ -0,0 +1,119 @@
1
+ import type {
2
+ AmplitudeAnalyticsDiagnostics,
3
+ AmplitudeHealthCheckResult,
4
+ AmplitudeReactNativeClient,
5
+ } from "./analytics/react-native-client";
6
+ import { isNative } from "./analytics/utils/platform";
7
+ import { getAmplitudeErrorCode } from "./errors";
8
+ import { getNetworkEnabled } from "./network";
9
+
10
+ type HybridModule = typeof import("./native/hybrid");
11
+
12
+ export type NativeStartupDiagnostics = {
13
+ nitroModulesAvailable: boolean;
14
+ contextAvailable: boolean;
15
+ storageAvailable: boolean;
16
+ workerAvailable: boolean;
17
+ nativeAvailable: boolean;
18
+ lastError?: {
19
+ code: string;
20
+ message: string;
21
+ };
22
+ };
23
+
24
+ export type AmplitudeDiagnostics = AmplitudeAnalyticsDiagnostics & {
25
+ native: NativeStartupDiagnostics;
26
+ networkEnabled: boolean;
27
+ };
28
+
29
+ let lastNativeError: NativeStartupDiagnostics["lastError"];
30
+
31
+ function getHybridModule(): HybridModule {
32
+ if (isNative()) {
33
+ return require("./native/hybrid") as HybridModule;
34
+ }
35
+ return require("./native/hybrid.web") as HybridModule;
36
+ }
37
+
38
+ export function getLastNativeError(): NativeStartupDiagnostics["lastError"] {
39
+ return lastNativeError;
40
+ }
41
+
42
+ export function getNativeStartupDiagnostics(): NativeStartupDiagnostics {
43
+ const result: NativeStartupDiagnostics = {
44
+ nitroModulesAvailable: true,
45
+ contextAvailable: false,
46
+ storageAvailable: false,
47
+ workerAvailable: false,
48
+ nativeAvailable: false,
49
+ lastError: lastNativeError,
50
+ };
51
+
52
+ try {
53
+ const { getAmplitudeContext, getAmplitudeStorage, getAmplitudeWorker } =
54
+ getHybridModule();
55
+ getAmplitudeContext();
56
+ result.contextAvailable = true;
57
+ getAmplitudeStorage();
58
+ result.storageAvailable = true;
59
+ getAmplitudeWorker();
60
+ result.workerAvailable = true;
61
+ result.nativeAvailable = true;
62
+ lastNativeError = undefined;
63
+ result.lastError = undefined;
64
+ } catch (error) {
65
+ lastNativeError = {
66
+ code: getAmplitudeErrorCode(error),
67
+ message: error instanceof Error ? error.message : String(error),
68
+ };
69
+ result.nitroModulesAvailable = false;
70
+ result.lastError = lastNativeError;
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ export function getAmplitudeDiagnostics(
77
+ analytics: AmplitudeReactNativeClient,
78
+ ): AmplitudeDiagnostics {
79
+ return {
80
+ ...analytics.getDiagnostics(),
81
+ native: getNativeStartupDiagnostics(),
82
+ networkEnabled: getNetworkEnabled(),
83
+ };
84
+ }
85
+
86
+ export async function healthCheck(
87
+ analytics?: AmplitudeReactNativeClient,
88
+ ): Promise<AmplitudeHealthCheckResult> {
89
+ const errors: string[] = [];
90
+ const native = getNativeStartupDiagnostics();
91
+ let storageWritable = false;
92
+
93
+ try {
94
+ const { getAmplitudeStorage } = getHybridModule();
95
+ const storage = getAmplitudeStorage();
96
+ const key = `health::${Date.now()}`;
97
+ storage.set(key, "ok", false);
98
+ storageWritable = storage.get(key, false) === "ok";
99
+ storage.remove(key, false);
100
+ } catch (error) {
101
+ errors.push(error instanceof Error ? error.message : String(error));
102
+ }
103
+
104
+ if (!native.nativeAvailable && native.lastError) {
105
+ errors.push(native.lastError.message);
106
+ }
107
+
108
+ const analyticsInitialized = analytics
109
+ ? analytics.getDiagnostics().initialized
110
+ : false;
111
+
112
+ return {
113
+ ok: native.nativeAvailable && storageWritable,
114
+ analyticsInitialized,
115
+ nativeAvailable: native.nativeAvailable,
116
+ storageWritable,
117
+ errors,
118
+ };
119
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type AmplitudeErrorCode =
2
+ | "not_initialized"
3
+ | "network_error"
4
+ | "storage_error"
5
+ | "invalid_api_key"
6
+ | "invalid_deployment_key"
7
+ | "experiment_fetch_failed"
8
+ | "native_unavailable"
9
+ | "serialization_error"
10
+ | "event_too_large"
11
+ | "timeout"
12
+ | "unknown";
13
+
14
+ export class AmplitudeError extends Error {
15
+ readonly code: AmplitudeErrorCode;
16
+ readonly cause?: unknown;
17
+
18
+ constructor(code: AmplitudeErrorCode, message: string, cause?: unknown) {
19
+ super(message);
20
+ this.name = "AmplitudeError";
21
+ this.code = code;
22
+ this.cause = cause;
23
+ }
24
+ }
25
+
26
+ export function createAmplitudeError(
27
+ code: AmplitudeErrorCode,
28
+ message: string,
29
+ cause?: unknown,
30
+ ): AmplitudeError {
31
+ return new AmplitudeError(code, message, cause);
32
+ }
33
+
34
+ export function getAmplitudeErrorCode(error: unknown): AmplitudeErrorCode {
35
+ if (error instanceof AmplitudeError) {
36
+ return error.code;
37
+ }
38
+ if (error instanceof Error) {
39
+ const message = error.message.toLowerCase();
40
+ if (message.includes("deployment key")) {
41
+ return "invalid_deployment_key";
42
+ }
43
+ if (message.includes("api key")) {
44
+ return "invalid_api_key";
45
+ }
46
+ if (message.includes("timeout")) {
47
+ return "timeout";
48
+ }
49
+ if (message.includes("network") || message.includes("fetch")) {
50
+ return "network_error";
51
+ }
52
+ if (message.includes("storage")) {
53
+ return "storage_error";
54
+ }
55
+ if (message.includes("nitro") || message.includes("native")) {
56
+ return "native_unavailable";
57
+ }
58
+ }
59
+ return "unknown";
60
+ }
@@ -30,7 +30,12 @@ import {
30
30
  } from "./storage/cache";
31
31
  import { MemoryStorage } from "./storage/local-storage";
32
32
  import { FetchHttpClient, WrapperClient } from "./transport/http";
33
- import { Client, FetchOptions } from "./types/client";
33
+ import {
34
+ Client,
35
+ ExperimentFetchResult,
36
+ ExperimentVariantResult,
37
+ FetchOptions,
38
+ } from "./types/client";
34
39
  import { ExperimentConfig, Defaults } from "./types/config";
35
40
  import { Exposure } from "./types/exposure";
36
41
  import { LogLevel } from "./types/logger";
@@ -119,6 +124,9 @@ export class ExperimentClient implements Client {
119
124
  private storedFetchSequenceNumber = 0;
120
125
  private readonly fetchVariantsOptions: SingleValueStoreCache<GetVariantsOptions>;
121
126
  private readonly stopCallbacks = new Set<() => void>();
127
+ private readonly inFlightFetches = new Map<string, Promise<Variants>>();
128
+ private lastFetchTime: number | undefined;
129
+ private lastFetchFailure: string | undefined;
122
130
 
123
131
  /**
124
132
  * Creates a new ExperimentClient instance.
@@ -341,6 +349,37 @@ export class ExperimentClient implements Client {
341
349
  return this;
342
350
  }
343
351
 
352
+ public async fetchWithMetadata(
353
+ user: ExperimentUser = this.user,
354
+ options?: FetchOptions,
355
+ ): Promise<ExperimentFetchResult> {
356
+ const startedAt = Date.now();
357
+ const fetchUser = user ?? this.user;
358
+ this.setUser(fetchUser);
359
+ try {
360
+ const variants = await this.fetchWithRetries(fetchUser, options);
361
+ const flagKeys = Object.keys(variants);
362
+ return {
363
+ fetched: true,
364
+ flagKeys,
365
+ cacheHit: false,
366
+ durationMillis: Date.now() - startedAt,
367
+ source: "network",
368
+ };
369
+ } catch (error) {
370
+ const reason = error instanceof Error ? error.message : String(error);
371
+ this.lastFetchFailure = reason;
372
+ return {
373
+ fetched: false,
374
+ flagKeys: options?.flagKeys ?? Object.keys(this.variants.getAll()),
375
+ cacheHit: Object.keys(this.variants.getAll()).length > 0,
376
+ durationMillis: Date.now() - startedAt,
377
+ source: "cache",
378
+ failureReason: reason,
379
+ };
380
+ }
381
+ }
382
+
344
383
  public async fetchOrThrow(
345
384
  user: ExperimentUser = this.user,
346
385
  options?: FetchOptions,
@@ -380,6 +419,45 @@ export class ExperimentClient implements Client {
380
419
  return sourceVariant.variant || {};
381
420
  }
382
421
 
422
+ public variantWithMetadata(
423
+ key: string,
424
+ fallback?: string | Variant,
425
+ ): ExperimentVariantResult {
426
+ if (!this.apiKey) {
427
+ return {
428
+ variant: { value: undefined },
429
+ fallback: true,
430
+ stale: false,
431
+ reason: "missing_flag",
432
+ };
433
+ }
434
+ const sourceVariant = this.variantAndSource(key, fallback);
435
+ if (this.config.automaticExposureTracking) {
436
+ this.exposureInternal(key, sourceVariant);
437
+ }
438
+ this.logger.debug(
439
+ `[Experiment] variant for ${key} is ${sourceVariant.variant?.value}`,
440
+ );
441
+ const variant = sourceVariant.variant || {};
442
+ const fallbackVariant = isFallback(sourceVariant.source);
443
+ const missingVariant = variant.value === undefined;
444
+ return {
445
+ variant,
446
+ source: sourceVariant.source,
447
+ fallback: fallbackVariant,
448
+ stale: false,
449
+ reason: missingVariant
450
+ ? this.lastFetchFailure
451
+ ? "fetch_failure"
452
+ : fallbackVariant
453
+ ? "fallback"
454
+ : "no_assignment"
455
+ : fallbackVariant
456
+ ? "fallback"
457
+ : undefined,
458
+ };
459
+ }
460
+
383
461
  /**
384
462
  * Track an exposure event for the variant associated with the flag/experiment
385
463
  * {@link key}.
@@ -431,6 +509,18 @@ export class ExperimentClient implements Client {
431
509
  void this.variants.store().catch((e) => this.logger.warn(e));
432
510
  }
433
511
 
512
+ public clearVariants(): void {
513
+ this.clear();
514
+ }
515
+
516
+ public hasCachedVariant(key: string): boolean {
517
+ return this.variants.get(key) !== undefined;
518
+ }
519
+
520
+ public getLastFetchTime(): number | undefined {
521
+ return this.lastFetchTime;
522
+ }
523
+
434
524
  /**
435
525
  * Get a copy of the internal {@link ExperimentUser} object if it is set.
436
526
  *
@@ -788,12 +878,35 @@ export class ExperimentClient implements Client {
788
878
  user: ExperimentUser,
789
879
  options?: FetchOptions,
790
880
  ): Promise<Variants> {
791
- return await this.fetchInternal(
881
+ const key = JSON.stringify({
882
+ user,
883
+ flagKeys: options?.flagKeys ?? null,
884
+ });
885
+ const inFlightFetch = this.inFlightFetches.get(key);
886
+ if (inFlightFetch) {
887
+ return await inFlightFetch;
888
+ }
889
+ const fetch = this.fetchInternal(
792
890
  user,
793
891
  this.config.fetchTimeoutMillis,
794
892
  this.config.retryFetchOnFailure,
795
893
  options,
796
- );
894
+ )
895
+ .then((variants) => {
896
+ this.lastFetchTime = Date.now();
897
+ this.lastFetchFailure = undefined;
898
+ return variants;
899
+ })
900
+ .catch((error: unknown) => {
901
+ this.lastFetchFailure =
902
+ error instanceof Error ? error.message : String(error);
903
+ throw error;
904
+ })
905
+ .finally(() => {
906
+ this.inFlightFetches.delete(key);
907
+ });
908
+ this.inFlightFetches.set(key, fetch);
909
+ return await fetch;
797
910
  }
798
911
 
799
912
  private async doFetch(
@@ -12,3 +12,4 @@ export { LogLevel } from "./types/logger";
12
12
  export type { Logger } from "./types/logger";
13
13
  export { ConsoleLogger } from "./logger/consoleLogger";
14
14
  export { LocalStorage, MemoryStorage } from "./storage/local-storage";
15
+ export * from "./typed-variants";