@grafana/faro-react-native 1.3.0 → 1.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.
Files changed (266) hide show
  1. package/README.md +176 -30
  2. package/android/build.gradle +20 -15
  3. package/android/src/main/java/com/grafana/faro/reactnative/FaroCrashIds.kt +44 -0
  4. package/android/src/main/java/com/grafana/faro/reactnative/FaroCrashReporter.kt +134 -38
  5. package/android/src/main/java/com/grafana/faro/reactnative/FaroCrashSessionStore.kt +433 -0
  6. package/android/src/main/java/com/grafana/faro/reactnative/FaroCrashTraceCache.kt +11 -2
  7. package/android/src/main/java/com/grafana/faro/reactnative/FaroReactNativeModule.kt +128 -17
  8. package/android/src/main/java/com/grafana/faro/reactnative/FaroSessionProcess.kt +88 -0
  9. package/android/src/main/java/com/grafana/faro/reactnative/FrameMonitor.kt +34 -42
  10. package/android/src/test/java/com/grafana/faro/reactnative/FaroCrashReporterBelowApi30Test.kt +21 -0
  11. package/android/src/test/java/com/grafana/faro/reactnative/FaroCrashReporterTest.kt +59 -0
  12. package/android/src/test/java/com/grafana/faro/reactnative/FaroCrashSessionStoreTest.kt +268 -0
  13. package/android/src/test/java/com/grafana/faro/reactnative/FaroCrashTraceCacheTest.kt +52 -0
  14. package/android/src/test/java/com/grafana/faro/reactnative/FaroSessionProcessTest.kt +127 -0
  15. package/dist/cjs/config/getRNInstrumentations.js +6 -8
  16. package/dist/cjs/config/getRNInstrumentations.js.map +1 -1
  17. package/dist/cjs/config/sampling.js.map +1 -1
  18. package/dist/cjs/config/types.js.map +1 -1
  19. package/dist/cjs/dataCollection/DataCollectionPolicy.js +1 -2
  20. package/dist/cjs/dataCollection/DataCollectionPolicy.js.map +1 -1
  21. package/dist/cjs/generated/faroRNPackageMeta.js +1 -1
  22. package/dist/cjs/generated/faroRNPackageMeta.js.map +1 -1
  23. package/dist/cjs/index.js +8 -3
  24. package/dist/cjs/index.js.map +1 -1
  25. package/dist/cjs/instrumentations/anr/instrumentation.js +10 -1
  26. package/dist/cjs/instrumentations/anr/instrumentation.js.map +1 -1
  27. package/dist/cjs/instrumentations/appState/index.js +8 -2
  28. package/dist/cjs/instrumentations/appState/index.js.map +1 -1
  29. package/dist/cjs/instrumentations/crashReporting/BaseCrashReportingInstrumentation.js +19 -6
  30. package/dist/cjs/instrumentations/crashReporting/BaseCrashReportingInstrumentation.js.map +1 -1
  31. package/dist/cjs/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.js +331 -0
  32. package/dist/cjs/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.js.map +1 -0
  33. package/dist/cjs/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.js +2 -2
  34. package/dist/cjs/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.js.map +1 -1
  35. package/dist/cjs/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.js +2 -2
  36. package/dist/cjs/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.js.map +1 -1
  37. package/dist/cjs/instrumentations/crashReporting/types.js.map +1 -1
  38. package/dist/cjs/instrumentations/errors/index.js +8 -3
  39. package/dist/cjs/instrumentations/errors/index.js.map +1 -1
  40. package/dist/cjs/instrumentations/frameMonitoring/instrumentation.js +20 -31
  41. package/dist/cjs/instrumentations/frameMonitoring/instrumentation.js.map +1 -1
  42. package/dist/cjs/instrumentations/frameMonitoring/types.js.map +1 -1
  43. package/dist/cjs/instrumentations/performance/index.js +2 -6
  44. package/dist/cjs/instrumentations/performance/index.js.map +1 -1
  45. package/dist/cjs/instrumentations/session/FaroSessionActivityBoundary.js +48 -0
  46. package/dist/cjs/instrumentations/session/FaroSessionActivityBoundary.js.map +1 -0
  47. package/dist/cjs/instrumentations/session/directSessionActivity.js +37 -0
  48. package/dist/cjs/instrumentations/session/directSessionActivity.js.map +1 -0
  49. package/dist/cjs/instrumentations/session/index.js +214 -45
  50. package/dist/cjs/instrumentations/session/index.js.map +1 -1
  51. package/dist/cjs/instrumentations/session/sessionActivity.js +43 -0
  52. package/dist/cjs/instrumentations/session/sessionActivity.js.map +1 -0
  53. package/dist/cjs/instrumentations/session/sessionAttributes.js +8 -8
  54. package/dist/cjs/instrumentations/session/sessionAttributes.js.map +1 -1
  55. package/dist/cjs/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.js +199 -24
  56. package/dist/cjs/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.js.map +1 -1
  57. package/dist/cjs/instrumentations/session/sessionManager/VolatileSessionManager.js +31 -13
  58. package/dist/cjs/instrumentations/session/sessionManager/VolatileSessionManager.js.map +1 -1
  59. package/dist/cjs/instrumentations/session/sessionManager/persistentSessionRecord.js +72 -0
  60. package/dist/cjs/instrumentations/session/sessionManager/persistentSessionRecord.js.map +1 -0
  61. package/dist/cjs/instrumentations/session/sessionManager/sessionConstants.js +1 -1
  62. package/dist/cjs/instrumentations/session/sessionManager/sessionConstants.js.map +1 -1
  63. package/dist/cjs/instrumentations/session/sessionManager/sessionManagerUtils.js +124 -96
  64. package/dist/cjs/instrumentations/session/sessionManager/sessionManagerUtils.js.map +1 -1
  65. package/dist/cjs/instrumentations/session/sessionManager/types.js.map +1 -1
  66. package/dist/cjs/instrumentations/session/sessionProcess.js +96 -0
  67. package/dist/cjs/instrumentations/session/sessionProcess.js.map +1 -0
  68. package/dist/cjs/instrumentations/startup/index.js +2 -6
  69. package/dist/cjs/instrumentations/startup/index.js.map +1 -1
  70. package/dist/cjs/navigation/utils.js +3 -1
  71. package/dist/cjs/navigation/utils.js.map +1 -1
  72. package/dist/cjs/transports/fetch/index.js.map +1 -1
  73. package/dist/cjs/transports/fetch/transport.js +56 -25
  74. package/dist/cjs/transports/fetch/transport.js.map +1 -1
  75. package/dist/cjs/transports/fetch/types.js.map +1 -1
  76. package/dist/cjs/transports/offline/ConnectivityService.js +1 -1
  77. package/dist/cjs/transports/offline/ConnectivityService.js.map +1 -1
  78. package/dist/cjs/transports/offline/OfflineCache.js +2 -2
  79. package/dist/cjs/transports/offline/OfflineCache.js.map +1 -1
  80. package/dist/cjs/transports/offline/transport.js +3 -4
  81. package/dist/cjs/transports/offline/transport.js.map +1 -1
  82. package/dist/cjs/userPersistence/UserPersistence.js +1 -2
  83. package/dist/cjs/userPersistence/UserPersistence.js.map +1 -1
  84. package/dist/esm/config/getRNInstrumentations.js +6 -8
  85. package/dist/esm/config/getRNInstrumentations.js.map +1 -1
  86. package/dist/esm/config/sampling.js.map +1 -1
  87. package/dist/esm/config/types.js.map +1 -1
  88. package/dist/esm/dataCollection/DataCollectionPolicy.js +1 -2
  89. package/dist/esm/dataCollection/DataCollectionPolicy.js.map +1 -1
  90. package/dist/esm/generated/faroRNPackageMeta.js +1 -1
  91. package/dist/esm/generated/faroRNPackageMeta.js.map +1 -1
  92. package/dist/esm/index.js +4 -2
  93. package/dist/esm/index.js.map +1 -1
  94. package/dist/esm/instrumentations/anr/instrumentation.js +6 -1
  95. package/dist/esm/instrumentations/anr/instrumentation.js.map +1 -1
  96. package/dist/esm/instrumentations/appState/index.js +8 -2
  97. package/dist/esm/instrumentations/appState/index.js.map +1 -1
  98. package/dist/esm/instrumentations/crashReporting/BaseCrashReportingInstrumentation.js +15 -4
  99. package/dist/esm/instrumentations/crashReporting/BaseCrashReportingInstrumentation.js.map +1 -1
  100. package/dist/esm/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.js +230 -0
  101. package/dist/esm/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.js.map +1 -0
  102. package/dist/esm/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.js +2 -2
  103. package/dist/esm/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.js.map +1 -1
  104. package/dist/esm/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.js +2 -2
  105. package/dist/esm/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.js.map +1 -1
  106. package/dist/esm/instrumentations/crashReporting/types.js.map +1 -1
  107. package/dist/esm/instrumentations/errors/index.js +8 -3
  108. package/dist/esm/instrumentations/errors/index.js.map +1 -1
  109. package/dist/esm/instrumentations/frameMonitoring/instrumentation.js +17 -32
  110. package/dist/esm/instrumentations/frameMonitoring/instrumentation.js.map +1 -1
  111. package/dist/esm/instrumentations/frameMonitoring/types.js.map +1 -1
  112. package/dist/esm/instrumentations/performance/index.js +2 -6
  113. package/dist/esm/instrumentations/performance/index.js.map +1 -1
  114. package/dist/esm/instrumentations/session/FaroSessionActivityBoundary.js +34 -0
  115. package/dist/esm/instrumentations/session/FaroSessionActivityBoundary.js.map +1 -0
  116. package/dist/esm/instrumentations/session/directSessionActivity.js +32 -0
  117. package/dist/esm/instrumentations/session/directSessionActivity.js.map +1 -0
  118. package/dist/esm/instrumentations/session/index.js +207 -46
  119. package/dist/esm/instrumentations/session/index.js.map +1 -1
  120. package/dist/esm/instrumentations/session/sessionActivity.js +38 -0
  121. package/dist/esm/instrumentations/session/sessionActivity.js.map +1 -0
  122. package/dist/esm/instrumentations/session/sessionAttributes.js +7 -7
  123. package/dist/esm/instrumentations/session/sessionAttributes.js.map +1 -1
  124. package/dist/esm/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.js +190 -27
  125. package/dist/esm/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.js.map +1 -1
  126. package/dist/esm/instrumentations/session/sessionManager/VolatileSessionManager.js +31 -14
  127. package/dist/esm/instrumentations/session/sessionManager/VolatileSessionManager.js.map +1 -1
  128. package/dist/esm/instrumentations/session/sessionManager/persistentSessionRecord.js +56 -0
  129. package/dist/esm/instrumentations/session/sessionManager/persistentSessionRecord.js.map +1 -0
  130. package/dist/esm/instrumentations/session/sessionManager/sessionConstants.js +1 -1
  131. package/dist/esm/instrumentations/session/sessionManager/sessionConstants.js.map +1 -1
  132. package/dist/esm/instrumentations/session/sessionManager/sessionManagerUtils.js +120 -44
  133. package/dist/esm/instrumentations/session/sessionManager/sessionManagerUtils.js.map +1 -1
  134. package/dist/esm/instrumentations/session/sessionManager/types.js.map +1 -1
  135. package/dist/esm/instrumentations/session/sessionProcess.js +89 -0
  136. package/dist/esm/instrumentations/session/sessionProcess.js.map +1 -0
  137. package/dist/esm/instrumentations/startup/index.js +2 -6
  138. package/dist/esm/instrumentations/startup/index.js.map +1 -1
  139. package/dist/esm/navigation/utils.js +2 -1
  140. package/dist/esm/navigation/utils.js.map +1 -1
  141. package/dist/esm/transports/fetch/index.js.map +1 -1
  142. package/dist/esm/transports/fetch/transport.js +38 -14
  143. package/dist/esm/transports/fetch/transport.js.map +1 -1
  144. package/dist/esm/transports/fetch/types.js.map +1 -1
  145. package/dist/esm/transports/offline/ConnectivityService.js +1 -1
  146. package/dist/esm/transports/offline/ConnectivityService.js.map +1 -1
  147. package/dist/esm/transports/offline/OfflineCache.js +2 -2
  148. package/dist/esm/transports/offline/OfflineCache.js.map +1 -1
  149. package/dist/esm/transports/offline/transport.js +3 -4
  150. package/dist/esm/transports/offline/transport.js.map +1 -1
  151. package/dist/esm/userPersistence/UserPersistence.js +1 -2
  152. package/dist/esm/userPersistence/UserPersistence.js.map +1 -1
  153. package/dist/types/config/getRNInstrumentations.d.ts +1 -2
  154. package/dist/types/config/sampling.d.ts +0 -3
  155. package/dist/types/config/types.d.ts +5 -3
  156. package/dist/types/dataCollection/DataCollectionPolicy.d.ts +1 -2
  157. package/dist/types/generated/faroRNPackageMeta.d.ts +1 -1
  158. package/dist/types/index.d.ts +4 -1
  159. package/dist/types/instrumentations/anr/instrumentation.d.ts +1 -1
  160. package/dist/types/instrumentations/appState/index.d.ts +1 -1
  161. package/dist/types/instrumentations/console/index.d.ts +1 -1
  162. package/dist/types/instrumentations/crashReporting/BaseCrashReportingInstrumentation.d.ts +14 -4
  163. package/dist/types/instrumentations/crashReporting/NoOpCrashReportingInstrumentation.d.ts +1 -1
  164. package/dist/types/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.d.ts +27 -0
  165. package/dist/types/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.d.ts +3 -3
  166. package/dist/types/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.d.ts +3 -3
  167. package/dist/types/instrumentations/crashReporting/types.d.ts +6 -0
  168. package/dist/types/instrumentations/frameMonitoring/instrumentation.d.ts +3 -3
  169. package/dist/types/instrumentations/frameMonitoring/types.d.ts +4 -6
  170. package/dist/types/instrumentations/http/index.d.ts +1 -1
  171. package/dist/types/instrumentations/performance/index.d.ts +1 -3
  172. package/dist/types/instrumentations/session/FaroSessionActivityBoundary.d.ts +8 -0
  173. package/dist/types/instrumentations/session/directSessionActivity.d.ts +13 -0
  174. package/dist/types/instrumentations/session/index.d.ts +21 -2
  175. package/dist/types/instrumentations/session/sessionActivity.d.ts +9 -0
  176. package/dist/types/instrumentations/session/sessionAttributes.d.ts +6 -3
  177. package/dist/types/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.d.ts +16 -3
  178. package/dist/types/instrumentations/session/sessionManager/VolatileSessionManager.d.ts +9 -3
  179. package/dist/types/instrumentations/session/sessionManager/persistentSessionRecord.d.ts +4 -0
  180. package/dist/types/instrumentations/session/sessionManager/sessionManagerUtils.d.ts +19 -7
  181. package/dist/types/instrumentations/session/sessionManager/types.d.ts +6 -0
  182. package/dist/types/instrumentations/session/sessionProcess.d.ts +17 -0
  183. package/dist/types/instrumentations/startup/index.d.ts +3 -7
  184. package/dist/types/instrumentations/userActions/index.d.ts +1 -1
  185. package/dist/types/instrumentations/view/index.d.ts +1 -1
  186. package/dist/types/instrumentations/xhr/index.d.ts +1 -1
  187. package/dist/types/navigation/utils.d.ts +1 -0
  188. package/dist/types/testUtils/mockFaroReactNativeTracing.d.ts +1 -1
  189. package/dist/types/transports/console/transport.d.ts +1 -1
  190. package/dist/types/transports/fetch/index.d.ts +1 -1
  191. package/dist/types/transports/fetch/transport.d.ts +9 -2
  192. package/dist/types/transports/fetch/types.d.ts +5 -0
  193. package/dist/types/transports/offline/ConnectivityService.d.ts +1 -1
  194. package/dist/types/transports/offline/OfflineCache.d.ts +2 -2
  195. package/dist/types/transports/offline/transport.d.ts +4 -5
  196. package/dist/types/userPersistence/UserPersistence.d.ts +1 -2
  197. package/ios/FaroCrashReporter.swift +214 -21
  198. package/ios/FaroReactNative.swift +24 -0
  199. package/ios/FaroReactNativeModule.h +2 -1
  200. package/ios/FaroReactNativeModule.mm +121 -6
  201. package/ios/RefreshRateVitals.swift +34 -38
  202. package/package.json +8 -6
  203. package/src/config/getRNInstrumentations.ts +6 -8
  204. package/src/config/makeRNConfig.test.ts +6 -0
  205. package/src/config/sampling.ts +0 -3
  206. package/src/config/types.ts +5 -3
  207. package/src/dataCollection/DataCollectionPolicy.ts +1 -2
  208. package/src/generated/faroRNPackageMeta.ts +1 -1
  209. package/src/index.ts +8 -2
  210. package/src/instrumentations/anr/instrumentation.ts +8 -1
  211. package/src/instrumentations/appState/index.ts +10 -2
  212. package/src/instrumentations/crashReporting/BaseCrashReportingInstrumentation.ts +28 -8
  213. package/src/instrumentations/crashReporting/RecoveredCrashReportingInstrumentation.ts +277 -0
  214. package/src/instrumentations/crashReporting/android/AndroidCrashReportingInstrumentation.ts +2 -2
  215. package/src/instrumentations/crashReporting/instrumentation.test.ts +433 -5
  216. package/src/instrumentations/crashReporting/ios/IosCrashReportingInstrumentation.ts +2 -2
  217. package/src/instrumentations/crashReporting/types.ts +7 -0
  218. package/src/instrumentations/errors/index.ts +12 -4
  219. package/src/instrumentations/errors/instrumentation.test.ts +35 -0
  220. package/src/instrumentations/frameMonitoring/instrumentation.test.ts +155 -0
  221. package/src/instrumentations/frameMonitoring/instrumentation.ts +19 -38
  222. package/src/instrumentations/frameMonitoring/types.ts +4 -6
  223. package/src/instrumentations/performance/index.ts +2 -6
  224. package/src/instrumentations/session/FaroSessionActivityBoundary.test.ts +60 -0
  225. package/src/instrumentations/session/FaroSessionActivityBoundary.tsx +41 -0
  226. package/src/instrumentations/session/beforeSendHook.test.ts +246 -0
  227. package/src/instrumentations/session/directSessionActivity.test.ts +158 -0
  228. package/src/instrumentations/session/directSessionActivity.ts +36 -0
  229. package/src/instrumentations/session/index.ts +269 -46
  230. package/src/instrumentations/session/persistentColdStart.test.ts +196 -0
  231. package/src/instrumentations/session/sessionActivity.test.ts +68 -0
  232. package/src/instrumentations/session/sessionActivity.ts +58 -0
  233. package/src/instrumentations/session/sessionAttributes.test.ts +37 -2
  234. package/src/instrumentations/session/sessionAttributes.ts +12 -6
  235. package/src/instrumentations/session/sessionCleanup.test.ts +410 -0
  236. package/src/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.test.ts +552 -0
  237. package/src/instrumentations/session/sessionManager/MmkvPersistentSessionsManager.ts +249 -33
  238. package/src/instrumentations/session/sessionManager/VolatileSessionManager.ts +38 -18
  239. package/src/instrumentations/session/sessionManager/persistentSessionRecord.test.ts +103 -0
  240. package/src/instrumentations/session/sessionManager/persistentSessionRecord.ts +78 -0
  241. package/src/instrumentations/session/sessionManager/sampling.test.ts +1 -1
  242. package/src/instrumentations/session/sessionManager/sessionConstants.ts +1 -1
  243. package/src/instrumentations/session/sessionManager/sessionManagerUtils.test.ts +256 -18
  244. package/src/instrumentations/session/sessionManager/sessionManagerUtils.ts +180 -39
  245. package/src/instrumentations/session/sessionManager/types.ts +8 -0
  246. package/src/instrumentations/session/sessionManagerInstance.test.ts +65 -0
  247. package/src/instrumentations/session/sessionProcess.test.ts +136 -0
  248. package/src/instrumentations/session/sessionProcess.ts +114 -0
  249. package/src/instrumentations/session/startNewSession.test.ts +337 -0
  250. package/src/instrumentations/startup/index.ts +2 -6
  251. package/src/navigation/utils.ts +3 -1
  252. package/src/transports/fetch/index.ts +6 -1
  253. package/src/transports/fetch/transport.test.ts +86 -2
  254. package/src/transports/fetch/transport.ts +38 -16
  255. package/src/transports/fetch/types.ts +7 -0
  256. package/src/transports/offline/ConnectivityService.ts +1 -1
  257. package/src/transports/offline/OfflineCache.ts +2 -2
  258. package/src/transports/offline/transport.ts +3 -4
  259. package/src/userPersistence/UserPersistence.ts +1 -2
  260. package/dist/cjs/utils/throttle.js +0 -42
  261. package/dist/cjs/utils/throttle.js.map +0 -1
  262. package/dist/esm/utils/throttle.js +0 -34
  263. package/dist/esm/utils/throttle.js.map +0 -1
  264. package/dist/types/utils/throttle.d.ts +0 -9
  265. package/src/utils/throttle.test.ts +0 -141
  266. package/src/utils/throttle.ts +0 -34
@@ -34,6 +34,18 @@ object FaroCrashReporter {
34
34
  private const val PREFS_NAME = "com.grafana.faro.crash_reporter"
35
35
  private const val KEY_LAST_PROCESSED_TIMESTAMP = "last_processed_timestamp"
36
36
 
37
+ private data class ExitGroupKey(
38
+ val timestampMs: Long,
39
+ val pid: Int,
40
+ val processName: String,
41
+ )
42
+
43
+ private sealed interface ExportResult {
44
+ data class Report(val json: String) : ExportResult
45
+ data object Ignored : ExportResult
46
+ data object Failed : ExportResult
47
+ }
48
+
37
49
  /**
38
50
  * Persists a text tombstone backtrace for the next [REASON_CRASH_NATIVE] replay.
39
51
  *
@@ -50,13 +62,18 @@ object FaroCrashReporter {
50
62
  *
51
63
  * Returns a list of JSON strings, each representing a crash report.
52
64
  * The JSON format matches the iOS implementation for consistency.
53
- * Only returns new crash reports since the last call.
65
+ * Retrieval is non-destructive. Reports remain pending until JavaScript
66
+ * acknowledges successful delivery or an intentional local filter.
54
67
  *
55
68
  * @param context Android context
56
69
  * @return List of crash report JSON strings, or null if no crashes or unsupported API
57
70
  */
71
+ @JvmOverloads
58
72
  @JvmStatic
59
- fun getCrashReports(context: Context): List<String>? {
73
+ fun getCrashReports(
74
+ context: Context,
75
+ nowMs: Long = System.currentTimeMillis(),
76
+ ): List<String>? {
60
77
  FaroUncaughtExceptionHandler.install(context)
61
78
  // ApplicationExitInfo requires Android 11 (API 30)
62
79
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
@@ -76,59 +93,126 @@ object FaroCrashReporter {
76
93
  return null
77
94
  }
78
95
 
79
- // Get the last processed timestamp to avoid duplicate reports
96
+ // Preserve the old watermark as a one-way migration boundary. New reports
97
+ // use stable IDs and are acknowledged only after delivery.
80
98
  val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
81
- val lastProcessedTimestamp = prefs.getLong(KEY_LAST_PROCESSED_TIMESTAMP, 0)
99
+ val legacyLastProcessedTimestamp = prefs.getLong(KEY_LAST_PROCESSED_TIMESTAMP, 0)
82
100
 
83
- val pendingTrace = FaroCrashTraceCache.peekPendingCrashTrace(context)
84
- var usedPendingTrace = false
101
+ val pendingTrace = FaroCrashTraceCache.peekPendingCrashTrace(context, nowMs)
85
102
 
86
103
  val crashReports = mutableListOf<String>()
87
- var latestTimestamp = lastProcessedTimestamp
88
- val reportedTimestamps = mutableSetOf<Long>()
89
104
 
90
105
  // Android can return multiple ApplicationExitInfo rows for one fatal exit
91
106
  // (e.g. REASON_CRASH plus a companion row with no trace). Collapse to one
92
- // report per timestamp and share the UncaughtExceptionHandler cache.
107
+ // report per process/timestamp and share the UncaughtExceptionHandler cache.
93
108
  val candidates = exitInfoList
94
- .filter { it.timestamp > lastProcessedTimestamp && isCrashReason(it.reason) }
95
- .groupBy { it.timestamp }
109
+ .filter {
110
+ it.timestamp > legacyLastProcessedTimestamp &&
111
+ isCrashReason(it.reason)
112
+ }
113
+ .groupBy {
114
+ ExitGroupKey(
115
+ timestampMs = it.timestamp,
116
+ pid = it.pid,
117
+ processName = it.processName.orEmpty(),
118
+ )
119
+ }
96
120
  .mapValues { (_, exits) -> pickBestExitInfo(exits) }
97
- .toSortedMap()
98
-
99
- for ((timestamp, exitInfo) in candidates) {
100
- if (reportedTimestamps.contains(timestamp)) {
121
+ .entries
122
+ .sortedBy { it.key.timestampMs }
123
+
124
+ for ((_, exitInfo) in candidates) {
125
+ val reportId = FaroCrashIds.reportId(
126
+ packageName = context.packageName,
127
+ timestampMs = exitInfo.timestamp,
128
+ pid = exitInfo.pid,
129
+ processName = exitInfo.processName.orEmpty(),
130
+ )
131
+ if (FaroCrashSessionStore.isReportAcknowledged(context, reportId, nowMs)) {
101
132
  continue
102
133
  }
103
-
104
- if (exitInfo.timestamp > latestTimestamp) {
105
- latestTimestamp = exitInfo.timestamp
134
+ if (!isWithinReplayWindow(exitInfo.timestamp, nowMs)) {
135
+ FaroCrashSessionStore.acknowledgeReports(
136
+ context = context,
137
+ reportIds = listOf(reportId),
138
+ nowMs = nowMs,
139
+ )
140
+ continue
106
141
  }
107
142
 
108
- val jsonString = exportExitInfoAsJSON(context, exitInfo, pendingTrace) { consumed ->
143
+ val sessionContext = FaroCrashSessionStore.contextForPendingReport(context, reportId, nowMs)
144
+ ?: FaroCrashSessionStore.findMatchingContext(
145
+ context = context,
146
+ crashTimestampMs = exitInfo.timestamp,
147
+ pid = exitInfo.pid,
148
+ processName = exitInfo.processName.orEmpty(),
149
+ nowMs = nowMs,
150
+ )
151
+ var usedPendingTrace = false
152
+
153
+ val exportResult = exportExitInfoAsJSON(
154
+ context = context,
155
+ exitInfo = exitInfo,
156
+ reportId = reportId,
157
+ sessionContext = sessionContext,
158
+ pendingTrace = pendingTrace,
159
+ ) { consumed ->
109
160
  if (consumed) {
110
161
  usedPendingTrace = true
111
162
  }
112
163
  }
113
164
 
114
- if (jsonString != null) {
115
- crashReports.add(jsonString)
116
- reportedTimestamps.add(timestamp)
165
+ when (exportResult) {
166
+ is ExportResult.Report -> {
167
+ FaroCrashSessionStore.rememberPendingReport(
168
+ context = context,
169
+ reportId = reportId,
170
+ crashTimestampMs = exitInfo.timestamp,
171
+ sessionContext = sessionContext,
172
+ usesPendingTrace = usedPendingTrace,
173
+ nowMs = nowMs,
174
+ )
175
+ crashReports.add(exportResult.json)
176
+ }
177
+ ExportResult.Ignored -> {
178
+ // Prevent intentionally filtered companion or ANR rows from
179
+ // being reconsidered on every subsequent app launch.
180
+ FaroCrashSessionStore.acknowledgeReports(
181
+ context = context,
182
+ reportIds = listOf(reportId),
183
+ nowMs = nowMs,
184
+ )
185
+ }
186
+ ExportResult.Failed -> Unit
117
187
  }
118
188
  }
119
189
 
120
- if (usedPendingTrace) {
190
+ return if (crashReports.isEmpty()) null else crashReports
191
+ }
192
+
193
+ @JvmStatic
194
+ fun acknowledgeCrashReports(
195
+ context: Context,
196
+ reportIds: Collection<String>,
197
+ ): Boolean {
198
+ val result = FaroCrashSessionStore.acknowledgeReports(context, reportIds)
199
+ if (!result.persisted) {
200
+ return false
201
+ }
202
+ if (result.clearPendingTrace) {
121
203
  FaroCrashTraceCache.clearPendingCrashTrace(context)
122
204
  }
205
+ return true
206
+ }
123
207
 
124
- // Update the last processed timestamp
125
- if (latestTimestamp > lastProcessedTimestamp) {
126
- prefs.edit()
127
- .putLong(KEY_LAST_PROCESSED_TIMESTAMP, latestTimestamp)
128
- .apply()
208
+ internal fun isWithinReplayWindow(
209
+ crashTimestampMs: Long,
210
+ nowMs: Long,
211
+ ): Boolean {
212
+ if (crashTimestampMs <= 0L || crashTimestampMs > nowMs) {
213
+ return false
129
214
  }
130
-
131
- return if (crashReports.isEmpty()) null else crashReports
215
+ return nowMs - crashTimestampMs <= FaroCrashSessionStore.MAX_CONTEXT_AGE_MS
132
216
  }
133
217
 
134
218
  /**
@@ -188,16 +272,20 @@ object FaroCrashReporter {
188
272
  private fun exportExitInfoAsJSON(
189
273
  context: Context,
190
274
  exitInfo: ApplicationExitInfo,
275
+ reportId: String,
276
+ sessionContext: FaroCrashSessionStore.SessionContext?,
191
277
  pendingTrace: FaroCrashTraceCache.PendingTrace?,
192
278
  onPendingTraceUsed: (Boolean) -> Unit,
193
- ): String? {
279
+ ): ExportResult {
194
280
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
195
- return null
281
+ return ExportResult.Failed
196
282
  }
197
283
 
198
284
  return try {
199
285
  val json = JSONObject()
200
286
 
287
+ json.put("reportId", reportId)
288
+
201
289
  // Reason - maps to crash type
202
290
  json.put("reason", getReasonString(exitInfo.reason))
203
291
 
@@ -220,9 +308,17 @@ object FaroCrashReporter {
220
308
  // Importance (Android-specific)
221
309
  json.put("importance", exitInfo.importance)
222
310
 
311
+ if (sessionContext != null) {
312
+ json.put("sessionId", sessionContext.sessionId)
313
+ sessionContext.isSampled?.let { json.put("isSampled", it) }
314
+ sessionContext.appVersion?.let { json.put("appVersion", it) }
315
+ sessionContext.appRelease?.let { json.put("appRelease", it) }
316
+ sessionContext.appBundleId?.let { json.put("appBundleId", it) }
317
+ }
318
+
223
319
  if (isAnrTimeoutDescription(rawDescription)) {
224
320
  // ANRInstrumentation reports these with type ANR.
225
- return null
321
+ return ExportResult.Ignored
226
322
  }
227
323
 
228
324
  val parsedExitTrace = readExitTrace(exitInfo)
@@ -239,7 +335,7 @@ object FaroCrashReporter {
239
335
  ) {
240
336
  // Suppress empty generic crash rows that duplicate a nearby ANR report.
241
337
  // Real crashes with a trace still surface even when an ANR happened nearby.
242
- return null
338
+ return ExportResult.Ignored
243
339
  }
244
340
 
245
341
  if (trace.isNotEmpty()) {
@@ -270,12 +366,12 @@ object FaroCrashReporter {
270
366
  "[Faro crash native] Skipping CRASH_NATIVE without tombstone trace (traceInputStream was null and no cached native backtrace)",
271
367
  )
272
368
  }
273
- return null
369
+ return ExportResult.Ignored
274
370
  }
275
371
 
276
- json.toString()
277
- } catch (e: Exception) {
278
- null
372
+ ExportResult.Report(json.toString())
373
+ } catch (_: Exception) {
374
+ ExportResult.Failed
279
375
  }
280
376
  }
281
377
 
@@ -0,0 +1,433 @@
1
+ package com.grafana.faro.reactnative
2
+
3
+ import android.content.Context
4
+ import android.os.Process
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+
8
+ /**
9
+ * Persists the minimum process/session context needed to associate a historical
10
+ * ApplicationExitInfo crash with the Faro session that was active before process death.
11
+ */
12
+ internal object FaroCrashSessionStore {
13
+ // Keep aligned with AndroidCrashReportingInstrumentation.MAX_REPLAY_AGE_MS.
14
+ internal const val MAX_CONTEXT_AGE_MS = 7L * 24L * 60L * 60L * 1000L
15
+
16
+ private const val PREFS_NAME = "com.grafana.faro.crash_session_store"
17
+ private const val KEY_SESSION_CONTEXTS = "session_contexts"
18
+ private const val KEY_PENDING_REPORTS = "pending_reports"
19
+ private const val KEY_ACKNOWLEDGED_REPORTS = "acknowledged_reports"
20
+ private const val MAX_SESSION_CONTEXTS = 100
21
+ private const val MAX_PENDING_REPORTS = 50
22
+ private const val MAX_ACKNOWLEDGED_REPORTS = 100
23
+ private const val SCHEMA_VERSION = 1
24
+
25
+ data class SessionContext(
26
+ val id: String,
27
+ val sessionId: String,
28
+ val activatedAtMs: Long,
29
+ val updatedAtMs: Long,
30
+ val pid: Int,
31
+ val processName: String,
32
+ val isSampled: Boolean?,
33
+ val appVersion: String?,
34
+ val appRelease: String?,
35
+ val appBundleId: String?,
36
+ )
37
+
38
+ private data class PendingReport(
39
+ val reportId: String,
40
+ val contextId: String?,
41
+ val crashTimestampMs: Long,
42
+ val usesPendingTrace: Boolean,
43
+ )
44
+
45
+ private data class AcknowledgedReport(
46
+ val reportId: String,
47
+ val acknowledgedAtMs: Long,
48
+ )
49
+
50
+ private data class State(
51
+ val contexts: MutableList<SessionContext>,
52
+ val pendingReports: MutableList<PendingReport>,
53
+ val acknowledgedReports: MutableList<AcknowledgedReport>,
54
+ )
55
+
56
+ data class AcknowledgeResult(
57
+ val persisted: Boolean,
58
+ val clearPendingTrace: Boolean,
59
+ )
60
+
61
+ @Synchronized
62
+ fun recordSessionContext(
63
+ context: Context,
64
+ sessionId: String,
65
+ activatedAtMs: Long,
66
+ isSampled: Boolean?,
67
+ appVersion: String?,
68
+ appRelease: String?,
69
+ appBundleId: String?,
70
+ nowMs: Long = System.currentTimeMillis(),
71
+ pid: Int = Process.myPid(),
72
+ processName: String = currentProcessName(context.applicationContext),
73
+ ): Boolean {
74
+ val normalizedSessionId = sessionId.trim()
75
+ if (normalizedSessionId.isEmpty() || activatedAtMs <= 0L) {
76
+ return false
77
+ }
78
+
79
+ val appContext = context.applicationContext
80
+ val normalizedProcessName = processName.trim()
81
+ if (pid <= 0 || normalizedProcessName.isEmpty()) {
82
+ return false
83
+ }
84
+ val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
85
+ val state = readState(prefs, nowMs)
86
+ val existingIndex = state.contexts.indexOfFirst {
87
+ it.sessionId == normalizedSessionId &&
88
+ it.pid == pid &&
89
+ it.processName == normalizedProcessName
90
+ }
91
+
92
+ val existing = state.contexts.getOrNull(existingIndex)
93
+ val firstActivation = existing?.activatedAtMs ?: activatedAtMs
94
+ val sessionContext = SessionContext(
95
+ id = existing?.id ?: FaroCrashIds.sessionContextId(
96
+ normalizedSessionId,
97
+ firstActivation,
98
+ pid,
99
+ normalizedProcessName,
100
+ ),
101
+ sessionId = normalizedSessionId,
102
+ activatedAtMs = firstActivation,
103
+ updatedAtMs = nowMs,
104
+ pid = pid,
105
+ processName = normalizedProcessName,
106
+ isSampled = isSampled,
107
+ appVersion = appVersion.normalized(),
108
+ appRelease = appRelease.normalized(),
109
+ appBundleId = appBundleId.normalized(),
110
+ )
111
+
112
+ if (existingIndex >= 0) {
113
+ state.contexts[existingIndex] = sessionContext
114
+ } else {
115
+ state.contexts.add(sessionContext)
116
+ }
117
+
118
+ state.contexts.sortBy { it.activatedAtMs }
119
+ trimSessionContexts(state)
120
+ return writeState(prefs, state)
121
+ }
122
+
123
+ @Synchronized
124
+ fun findMatchingContext(
125
+ context: Context,
126
+ crashTimestampMs: Long,
127
+ pid: Int,
128
+ processName: String,
129
+ nowMs: Long = System.currentTimeMillis(),
130
+ ): SessionContext? {
131
+ if (crashTimestampMs <= 0L) {
132
+ return null
133
+ }
134
+
135
+ val normalizedProcessName = processName.trim()
136
+ if (pid <= 0 && normalizedProcessName.isEmpty()) {
137
+ return null
138
+ }
139
+
140
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
141
+ return readState(prefs, nowMs).contexts
142
+ .asSequence()
143
+ .filter { it.activatedAtMs <= crashTimestampMs }
144
+ .filter { pid <= 0 || it.pid == pid }
145
+ .filter { normalizedProcessName.isEmpty() || it.processName == normalizedProcessName }
146
+ .maxByOrNull { it.activatedAtMs }
147
+ }
148
+
149
+ @Synchronized
150
+ fun contextForPendingReport(
151
+ context: Context,
152
+ reportId: String,
153
+ nowMs: Long = System.currentTimeMillis(),
154
+ ): SessionContext? {
155
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
156
+ val state = readState(prefs, nowMs)
157
+ val contextId = state.pendingReports.firstOrNull { it.reportId == reportId }?.contextId ?: return null
158
+ return state.contexts.firstOrNull { it.id == contextId }
159
+ }
160
+
161
+ @Synchronized
162
+ fun rememberPendingReport(
163
+ context: Context,
164
+ reportId: String,
165
+ crashTimestampMs: Long,
166
+ sessionContext: SessionContext?,
167
+ usesPendingTrace: Boolean,
168
+ nowMs: Long = System.currentTimeMillis(),
169
+ ) {
170
+ if (reportId.isBlank() || crashTimestampMs <= 0L) {
171
+ return
172
+ }
173
+
174
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
175
+ val state = readState(prefs, nowMs)
176
+ val existingIndex = state.pendingReports.indexOfFirst { it.reportId == reportId }
177
+ val existing = state.pendingReports.getOrNull(existingIndex)
178
+ val pendingReport = PendingReport(
179
+ reportId = reportId,
180
+ contextId = existing?.contextId ?: sessionContext?.id,
181
+ crashTimestampMs = crashTimestampMs,
182
+ usesPendingTrace = existing?.usesPendingTrace == true || usesPendingTrace,
183
+ )
184
+
185
+ if (existingIndex >= 0) {
186
+ state.pendingReports[existingIndex] = pendingReport
187
+ } else {
188
+ state.pendingReports.add(pendingReport)
189
+ }
190
+
191
+ state.pendingReports.sortBy { it.crashTimestampMs }
192
+ trimOldest(state.pendingReports, MAX_PENDING_REPORTS)
193
+ writeState(prefs, state)
194
+ }
195
+
196
+ @Synchronized
197
+ fun isReportAcknowledged(
198
+ context: Context,
199
+ reportId: String,
200
+ nowMs: Long = System.currentTimeMillis(),
201
+ ): Boolean {
202
+ if (reportId.isBlank()) {
203
+ return false
204
+ }
205
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
206
+ return readState(prefs, nowMs).acknowledgedReports.any { it.reportId == reportId }
207
+ }
208
+
209
+ @Synchronized
210
+ fun acknowledgeReports(
211
+ context: Context,
212
+ reportIds: Collection<String>,
213
+ nowMs: Long = System.currentTimeMillis(),
214
+ ): AcknowledgeResult {
215
+ val normalizedIds = reportIds.map(String::trim).filter(String::isNotEmpty).toSet()
216
+ if (normalizedIds.isEmpty()) {
217
+ return AcknowledgeResult(persisted = true, clearPendingTrace = false)
218
+ }
219
+
220
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
221
+ val state = readState(prefs, nowMs)
222
+ val acknowledgedMappings = state.pendingReports.filter { it.reportId in normalizedIds }
223
+ val contextIds = acknowledgedMappings.mapNotNull { it.contextId }.toSet()
224
+ val acknowledgedPendingTrace = acknowledgedMappings.any { it.usesPendingTrace }
225
+
226
+ state.pendingReports.removeAll { it.reportId in normalizedIds }
227
+ val remainingContextIds = state.pendingReports.mapNotNullTo(mutableSetOf()) { it.contextId }
228
+ state.contexts.removeAll { it.id in contextIds && it.id !in remainingContextIds }
229
+ val clearPendingTrace = acknowledgedPendingTrace && state.pendingReports.none { it.usesPendingTrace }
230
+
231
+ state.acknowledgedReports.removeAll { it.reportId in normalizedIds }
232
+ normalizedIds.forEach { reportId ->
233
+ state.acknowledgedReports.add(AcknowledgedReport(reportId, nowMs))
234
+ }
235
+ trimOldest(state.acknowledgedReports, MAX_ACKNOWLEDGED_REPORTS)
236
+ val persisted = writeState(prefs, state)
237
+
238
+ return AcknowledgeResult(
239
+ persisted = persisted,
240
+ clearPendingTrace = persisted && clearPendingTrace,
241
+ )
242
+ }
243
+
244
+ @Synchronized
245
+ internal fun sessionContextsForTest(
246
+ context: Context,
247
+ nowMs: Long,
248
+ ): List<SessionContext> {
249
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
250
+ return readState(prefs, nowMs).contexts
251
+ }
252
+
253
+ private fun currentProcessName(context: Context): String {
254
+ return FaroSessionProcess.identifier()
255
+ ?: context.applicationInfo.processName.orEmpty().ifBlank { context.packageName }
256
+ }
257
+
258
+ private fun readState(
259
+ prefs: android.content.SharedPreferences,
260
+ nowMs: Long,
261
+ ): State {
262
+ val cutoff = nowMs - MAX_CONTEXT_AGE_MS
263
+ val contexts = readArray(prefs, KEY_SESSION_CONTEXTS)
264
+ .mapNotNull(::sessionContextFromJson)
265
+ .filterTo(mutableListOf()) { it.updatedAtMs >= cutoff }
266
+ val pendingReports = readArray(prefs, KEY_PENDING_REPORTS)
267
+ .mapNotNull(::pendingReportFromJson)
268
+ .filterTo(mutableListOf()) { it.crashTimestampMs >= cutoff }
269
+ val acknowledgedReports = readArray(prefs, KEY_ACKNOWLEDGED_REPORTS)
270
+ .mapNotNull(::acknowledgedReportFromJson)
271
+ .filterTo(mutableListOf()) { it.acknowledgedAtMs >= cutoff }
272
+
273
+ return State(contexts, pendingReports, acknowledgedReports)
274
+ }
275
+
276
+ private fun writeState(
277
+ prefs: android.content.SharedPreferences,
278
+ state: State,
279
+ ): Boolean {
280
+ return prefs.edit()
281
+ .putString(KEY_SESSION_CONTEXTS, JSONArray(state.contexts.map(::sessionContextToJson)).toString())
282
+ .putString(KEY_PENDING_REPORTS, JSONArray(state.pendingReports.map(::pendingReportToJson)).toString())
283
+ .putString(
284
+ KEY_ACKNOWLEDGED_REPORTS,
285
+ JSONArray(state.acknowledgedReports.map(::acknowledgedReportToJson)).toString(),
286
+ )
287
+ .commit()
288
+ }
289
+
290
+ private fun readArray(
291
+ prefs: android.content.SharedPreferences,
292
+ key: String,
293
+ ): List<JSONObject> {
294
+ val array = try {
295
+ JSONArray(prefs.getString(key, "[]") ?: "[]")
296
+ } catch (_: Exception) {
297
+ JSONArray()
298
+ }
299
+
300
+ return buildList {
301
+ for (index in 0 until array.length()) {
302
+ array.optJSONObject(index)?.let(::add)
303
+ }
304
+ }
305
+ }
306
+
307
+ private fun sessionContextToJson(context: SessionContext): JSONObject {
308
+ return JSONObject()
309
+ .put("schemaVersion", SCHEMA_VERSION)
310
+ .put("id", context.id)
311
+ .put("sessionId", context.sessionId)
312
+ .put("activatedAt", context.activatedAtMs)
313
+ .put("updatedAt", context.updatedAtMs)
314
+ .put("pid", context.pid)
315
+ .put("processName", context.processName)
316
+ .putOptional("isSampled", context.isSampled)
317
+ .putOptional("appVersion", context.appVersion)
318
+ .putOptional("appRelease", context.appRelease)
319
+ .putOptional("appBundleId", context.appBundleId)
320
+ }
321
+
322
+ private fun sessionContextFromJson(json: JSONObject): SessionContext? {
323
+ if (json.optInt("schemaVersion", 0) != SCHEMA_VERSION) {
324
+ return null
325
+ }
326
+
327
+ val id = json.optString("id").trim()
328
+ val sessionId = json.optString("sessionId").trim()
329
+ val activatedAtMs = json.optLong("activatedAt", 0L)
330
+ val updatedAtMs = json.optLong("updatedAt", activatedAtMs)
331
+ val pid = json.optInt("pid", 0)
332
+ val processName = json.optString("processName").trim()
333
+ if (
334
+ id.isEmpty() ||
335
+ sessionId.isEmpty() ||
336
+ activatedAtMs <= 0L ||
337
+ updatedAtMs <= 0L ||
338
+ pid <= 0 ||
339
+ processName.isEmpty()
340
+ ) {
341
+ return null
342
+ }
343
+
344
+ return SessionContext(
345
+ id = id,
346
+ sessionId = sessionId,
347
+ activatedAtMs = activatedAtMs,
348
+ updatedAtMs = updatedAtMs,
349
+ pid = pid,
350
+ processName = processName,
351
+ isSampled = json.optionalBoolean("isSampled"),
352
+ appVersion = json.optionalString("appVersion"),
353
+ appRelease = json.optionalString("appRelease"),
354
+ appBundleId = json.optionalString("appBundleId"),
355
+ )
356
+ }
357
+
358
+ private fun pendingReportToJson(report: PendingReport): JSONObject {
359
+ return JSONObject()
360
+ .put("reportId", report.reportId)
361
+ .putOptional("contextId", report.contextId)
362
+ .put("crashTimestamp", report.crashTimestampMs)
363
+ .put("usesPendingTrace", report.usesPendingTrace)
364
+ }
365
+
366
+ private fun pendingReportFromJson(json: JSONObject): PendingReport? {
367
+ val reportId = json.optString("reportId").trim()
368
+ val crashTimestampMs = json.optLong("crashTimestamp", 0L)
369
+ if (reportId.isEmpty() || crashTimestampMs <= 0L) {
370
+ return null
371
+ }
372
+ return PendingReport(
373
+ reportId = reportId,
374
+ contextId = json.optionalString("contextId"),
375
+ crashTimestampMs = crashTimestampMs,
376
+ usesPendingTrace = json.optBoolean("usesPendingTrace", false),
377
+ )
378
+ }
379
+
380
+ private fun acknowledgedReportToJson(report: AcknowledgedReport): JSONObject {
381
+ return JSONObject()
382
+ .put("reportId", report.reportId)
383
+ .put("acknowledgedAt", report.acknowledgedAtMs)
384
+ }
385
+
386
+ private fun acknowledgedReportFromJson(json: JSONObject): AcknowledgedReport? {
387
+ val reportId = json.optString("reportId").trim()
388
+ val acknowledgedAtMs = json.optLong("acknowledgedAt", 0L)
389
+ if (reportId.isEmpty() || acknowledgedAtMs <= 0L) {
390
+ return null
391
+ }
392
+ return AcknowledgedReport(reportId, acknowledgedAtMs)
393
+ }
394
+
395
+ private fun JSONObject.putOptional(key: String, value: Any?): JSONObject {
396
+ if (value != null) {
397
+ put(key, value)
398
+ }
399
+ return this
400
+ }
401
+
402
+ private fun JSONObject.optionalString(key: String): String? {
403
+ if (!has(key) || isNull(key)) {
404
+ return null
405
+ }
406
+ return optString(key).normalized()
407
+ }
408
+
409
+ private fun JSONObject.optionalBoolean(key: String): Boolean? {
410
+ if (!has(key) || isNull(key)) {
411
+ return null
412
+ }
413
+ return optBoolean(key)
414
+ }
415
+
416
+ private fun String?.normalized(): String? {
417
+ return this?.trim()?.takeIf(String::isNotEmpty)
418
+ }
419
+
420
+ private fun trimSessionContexts(state: State) {
421
+ val referencedContextIds = state.pendingReports.mapNotNullTo(mutableSetOf()) { it.contextId }
422
+ while (state.contexts.size > MAX_SESSION_CONTEXTS) {
423
+ val removableIndex = state.contexts.indexOfFirst { it.id !in referencedContextIds }
424
+ state.contexts.removeAt(if (removableIndex >= 0) removableIndex else 0)
425
+ }
426
+ }
427
+
428
+ private fun <T> trimOldest(items: MutableList<T>, maximumSize: Int) {
429
+ while (items.size > maximumSize) {
430
+ items.removeAt(0)
431
+ }
432
+ }
433
+ }