global-errors.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. import { persistedGet, persistedSet } from 'app/lib/util/storage';
  2. import { buildErrorHit, captureError, isErrorCaptureOff, submitHealthHits } from './health';
  3. import type { AnalyticsHit } from './analytics.types';
  4. /**
  5. * JS errors of the apps for UNA's analytics: React Native's global handler (chained to the
  6. * previous one) and, in release builds, Hermes' unhandled promise rejections. Render errors
  7. * don't reach the global handler in React Native: `AnalyticsErrorBoundary` reports those.
  8. *
  9. * A fatal error usually ends the app before a request could finish, so it is kept on the
  10. * device and sent on the next start, with `deferred` and its real age.
  11. */
  12. type GlobalHandler = (error: unknown, isFatal?: boolean) => void;
  13. type RejectionTrackerOptions = {
  14. allRejections: boolean;
  15. onUnhandled: (id: number, rejection: unknown) => void;
  16. onHandled: (id: number) => void;
  17. };
  18. type NativeGlobal = typeof globalThis & {
  19. ErrorUtils?: { getGlobalHandler?: () => GlobalHandler | undefined; setGlobalHandler?: (handler: GlobalHandler) => void };
  20. HermesInternal?: { enablePromiseRejectionTracker?: (options: RejectionTrackerOptions) => void };
  21. __neoAnalyticsErrors?: boolean;
  22. };
  23. type StoredError = { hit: AnalyticsHit; at: number };
  24. const STORAGE_KEY = 'analytics_pending_errors';
  25. const MAX_STORED = 5;
  26. /** UNA takes errors up to 7 days old. */
  27. const MAX_STORED_AGE_MS = 7 * 24 * 3600 * 1000;
  28. /** How long a fatal error waits for storage before the previous handler ends the app. */
  29. const FATAL_WRITE_WAIT_MS = 300;
  30. async function storeFatalError(error: unknown): Promise<void> {
  31. if (isErrorCaptureOff()) return;
  32. const hit = buildErrorHit('native', { error, fatal: true });
  33. if (!hit) return;
  34. const stored = await persistedGet(STORAGE_KEY);
  35. const list: StoredError[] = Array.isArray(stored) ? stored : [];
  36. await persistedSet(STORAGE_KEY, list.concat({ hit, at: Date.now() }).slice(-MAX_STORED));
  37. }
  38. export function installGlobalErrorCapture(): void {
  39. const g = globalThis as NativeGlobal;
  40. if (g.__neoAnalyticsErrors) return;
  41. g.__neoAnalyticsErrors = true;
  42. try {
  43. const errorUtils = g.ErrorUtils;
  44. if (errorUtils?.getGlobalHandler && errorUtils.setGlobalHandler) {
  45. const previous = errorUtils.getGlobalHandler();
  46. errorUtils.setGlobalHandler((error, isFatal) => {
  47. let stored: Promise<void> | null = null;
  48. try {
  49. if (isFatal) stored = storeFatalError(error);
  50. else captureError('native', { error });
  51. } catch {
  52. stored = null;
  53. }
  54. if (!previous) return;
  55. if (!stored) {
  56. previous(error, isFatal);
  57. return;
  58. }
  59. let done = false;
  60. const finish = () => {
  61. if (done) return;
  62. done = true;
  63. previous(error, isFatal);
  64. };
  65. stored.then(finish, finish);
  66. setTimeout(finish, FATAL_WRITE_WAIT_MS);
  67. });
  68. }
  69. } catch {
  70. // no global handler: nothing to chain to
  71. }
  72. // React Native tracks unhandled rejections only in development (and shows them there).
  73. const hermes = g.HermesInternal;
  74. if (!__DEV__ && typeof hermes?.enablePromiseRejectionTracker === 'function') {
  75. try {
  76. hermes.enablePromiseRejectionTracker({
  77. allRejections: true,
  78. onUnhandled: (_id, rejection) => captureError('promise', { error: rejection, nonErrorPrefix: 'Non-Error promise rejection: ' }),
  79. onHandled: () => {},
  80. });
  81. } catch {
  82. // an internal Hermes API: when it changes, rejections just aren't reported
  83. }
  84. }
  85. }
  86. /** Fatal errors kept by the last run of the app, sent once. */
  87. export async function sendStoredErrors(): Promise<void> {
  88. try {
  89. const stored = await persistedGet(STORAGE_KEY);
  90. if (!Array.isArray(stored) || !stored.length) return;
  91. await persistedSet(STORAGE_KEY, []);
  92. const now = Date.now();
  93. const hits = (stored as StoredError[])
  94. .filter((item) => item?.hit?.type === 'error' && typeof item.at === 'number' && now - item.at >= 0 && now - item.at < MAX_STORED_AGE_MS)
  95. .map((item): AnalyticsHit => ({ ...item.hit, repeats: 1, deferred: 1, age: now - item.at }));
  96. submitHealthHits(hits);
  97. } catch {
  98. // storage unavailable: the errors stay for the next start
  99. }
  100. }