@nathanclaire/edge-telemetry-sdk 3.0.0 → 4.0.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 (298) hide show
  1. package/README.md +831 -186
  2. package/dist/appLifecycle-CECgRVu_.cjs +2 -0
  3. package/dist/appLifecycle-CECgRVu_.cjs.map +1 -0
  4. package/dist/appLifecycle-DQErzJIR.js +22 -0
  5. package/dist/appLifecycle-DQErzJIR.js.map +1 -0
  6. package/dist/batch-CwoO7noL.js +56 -0
  7. package/dist/batch-CwoO7noL.js.map +1 -0
  8. package/dist/batch-DNu8Xr9T.cjs +2 -0
  9. package/dist/batch-DNu8Xr9T.cjs.map +1 -0
  10. package/dist/crashCapture-BOJ8nSAZ.cjs +4 -0
  11. package/dist/crashCapture-BOJ8nSAZ.cjs.map +1 -0
  12. package/dist/crashCapture-BW8WuyaU.js +70 -0
  13. package/dist/crashCapture-BW8WuyaU.js.map +1 -0
  14. package/dist/crashHandler.web-8F_k2veo.js +29 -0
  15. package/dist/crashHandler.web-8F_k2veo.js.map +1 -0
  16. package/dist/crashHandler.web-CWmF56mJ.cjs +2 -0
  17. package/dist/crashHandler.web-CWmF56mJ.cjs.map +1 -0
  18. package/dist/crashHandlerNative.native-C2kSikDt.cjs +2 -0
  19. package/dist/crashHandlerNative.native-C2kSikDt.cjs.map +1 -0
  20. package/dist/crashHandlerNative.native-FupMnr1U.js +39 -0
  21. package/dist/crashHandlerNative.native-FupMnr1U.js.map +1 -0
  22. package/dist/deviceInfo.native-BJ-3Jj1T.js +53 -0
  23. package/dist/deviceInfo.native-BJ-3Jj1T.js.map +1 -0
  24. package/dist/deviceInfo.native-BoeTZcD6.cjs +2 -0
  25. package/dist/deviceInfo.native-BoeTZcD6.cjs.map +1 -0
  26. package/dist/deviceInfo.web-3L3IDQQA.cjs +2 -0
  27. package/dist/deviceInfo.web-3L3IDQQA.cjs.map +1 -0
  28. package/dist/deviceInfo.web-BFB1-pcA.js +41 -0
  29. package/dist/deviceInfo.web-BFB1-pcA.js.map +1 -0
  30. package/dist/frameTracker-CHG6sIx9.cjs +2 -0
  31. package/dist/frameTracker-CHG6sIx9.cjs.map +1 -0
  32. package/dist/frameTracker-DdB8W_kC.js +54 -0
  33. package/dist/frameTracker-DdB8W_kC.js.map +1 -0
  34. package/dist/httpAttributes-BrrtM8zh.js +70 -0
  35. package/dist/httpAttributes-BrrtM8zh.js.map +1 -0
  36. package/dist/httpAttributes-D0m6UID7.cjs +2 -0
  37. package/dist/httpAttributes-D0m6UID7.cjs.map +1 -0
  38. package/dist/index-BtLlV1HA.js +57 -0
  39. package/dist/index-BtLlV1HA.js.map +1 -0
  40. package/dist/{deviceInfo.native-D2k0LDhI.js → index-C2XQ-EYw.js} +362 -411
  41. package/dist/index-C2XQ-EYw.js.map +1 -0
  42. package/dist/index-CXsVZY35.cjs +2 -0
  43. package/dist/index-CXsVZY35.cjs.map +1 -0
  44. package/dist/index-DLquF88M.cjs +2 -0
  45. package/dist/index-DLquF88M.cjs.map +1 -0
  46. package/dist/index.native.cjs +1 -1
  47. package/dist/index.native.cjs.map +1 -1
  48. package/dist/index.native.js +94 -59
  49. package/dist/index.native.js.map +1 -1
  50. package/dist/index.web.cjs +1 -1
  51. package/dist/index.web.cjs.map +1 -1
  52. package/dist/index.web.js +88 -33
  53. package/dist/index.web.js.map +1 -1
  54. package/dist/interactionWeb.web-Bl_cZlgh.cjs +2 -0
  55. package/dist/interactionWeb.web-Bl_cZlgh.cjs.map +1 -0
  56. package/dist/interactionWeb.web-CJAviVQ7.js +109 -0
  57. package/dist/interactionWeb.web-CJAviVQ7.js.map +1 -0
  58. package/dist/interceptFetchWeb.web-T88kO-bE.cjs +2 -0
  59. package/dist/interceptFetchWeb.web-T88kO-bE.cjs.map +1 -0
  60. package/dist/interceptFetchWeb.web-UXC2Lb6X.js +61 -0
  61. package/dist/interceptFetchWeb.web-UXC2Lb6X.js.map +1 -0
  62. package/dist/interceptHttpNative.native-BVpFuoNB.cjs +2 -0
  63. package/dist/interceptHttpNative.native-BVpFuoNB.cjs.map +1 -0
  64. package/dist/interceptHttpNative.native-rX-lPNST.js +18 -0
  65. package/dist/interceptHttpNative.native-rX-lPNST.js.map +1 -0
  66. package/dist/loadingTime-Bhomd9qq.cjs +2 -0
  67. package/dist/loadingTime-Bhomd9qq.cjs.map +1 -0
  68. package/dist/loadingTime-ZkYvFijA.js +70 -0
  69. package/dist/loadingTime-ZkYvFijA.js.map +1 -0
  70. package/dist/memoryNative.native-C42h9SjK.js +54 -0
  71. package/dist/memoryNative.native-C42h9SjK.js.map +1 -0
  72. package/dist/memoryNative.native-CA-7w9w1.cjs +2 -0
  73. package/dist/memoryNative.native-CA-7w9w1.cjs.map +1 -0
  74. package/dist/memoryStore-BIGZmmfD.cjs +2 -0
  75. package/dist/memoryStore-BIGZmmfD.cjs.map +1 -0
  76. package/dist/memoryStore-F5I-GQKM.js +193 -0
  77. package/dist/memoryStore-F5I-GQKM.js.map +1 -0
  78. package/dist/nativeSender-CfQaRuDK.cjs +2 -0
  79. package/dist/nativeSender-CfQaRuDK.cjs.map +1 -0
  80. package/dist/nativeSender-pgVSIv-X.js +59 -0
  81. package/dist/nativeSender-pgVSIv-X.js.map +1 -0
  82. package/dist/navigationRef-BzYbs8cm.js +28 -0
  83. package/dist/navigationRef-BzYbs8cm.js.map +1 -0
  84. package/dist/navigationRef-C-_qhb43.cjs +2 -0
  85. package/dist/navigationRef-C-_qhb43.cjs.map +1 -0
  86. package/dist/{navigationTracker-Cts68dgG.js → navigationTracker-BmywA_Bg.js} +4 -4
  87. package/dist/navigationTracker-BmywA_Bg.js.map +1 -0
  88. package/dist/navigationTracker-HkwVyywz.cjs +2 -0
  89. package/dist/navigationTracker-HkwVyywz.cjs.map +1 -0
  90. package/dist/navigationWeb.web-CW_z7Ca4.cjs +2 -0
  91. package/dist/navigationWeb.web-CW_z7Ca4.cjs.map +1 -0
  92. package/dist/navigationWeb.web-CcwgKuIZ.js +55 -0
  93. package/dist/navigationWeb.web-CcwgKuIZ.js.map +1 -0
  94. package/dist/{networkInfo.native-BK5fpAOL.js → networkInfo.native-CGrx9cnH.js} +15 -15
  95. package/dist/{networkInfo.native-BK5fpAOL.js.map → networkInfo.native-CGrx9cnH.js.map} +1 -1
  96. package/dist/{networkInfo.native-cxuw93Uy.cjs → networkInfo.native-YVySdv3S.cjs} +2 -2
  97. package/dist/{networkInfo.native-cxuw93Uy.cjs.map → networkInfo.native-YVySdv3S.cjs.map} +1 -1
  98. package/dist/{networkInfo.web-twcNn_hc.js → networkInfo.web-B2YlLNsI.js} +3 -3
  99. package/dist/networkInfo.web-B2YlLNsI.js.map +1 -0
  100. package/dist/networkInfo.web-PltJTHsy.cjs +2 -0
  101. package/dist/networkInfo.web-PltJTHsy.cjs.map +1 -0
  102. package/dist/runtimeReady.native-Bh7EcYmK.js +14 -0
  103. package/dist/runtimeReady.native-Bh7EcYmK.js.map +1 -0
  104. package/dist/runtimeReady.native-jUmuE1lV.cjs +2 -0
  105. package/dist/runtimeReady.native-jUmuE1lV.cjs.map +1 -0
  106. package/dist/runtimeReady.web-C8fdIbVR.js +25 -0
  107. package/dist/runtimeReady.web-C8fdIbVR.js.map +1 -0
  108. package/dist/runtimeReady.web-DHW682RN.cjs +2 -0
  109. package/dist/runtimeReady.web-DHW682RN.cjs.map +1 -0
  110. package/dist/seedRuntimeReady-BKwkBEAF.cjs +2 -0
  111. package/dist/seedRuntimeReady-BKwkBEAF.cjs.map +1 -0
  112. package/dist/seedRuntimeReady-Cz8nOKoo.js +10 -0
  113. package/dist/seedRuntimeReady-Cz8nOKoo.js.map +1 -0
  114. package/dist/src/adapters/appLifecycle.d.ts +8 -1
  115. package/dist/src/adapters/batch.d.ts +2 -0
  116. package/dist/src/adapters/crashCapture.d.ts +38 -9
  117. package/dist/src/adapters/failedEvents.d.ts +43 -0
  118. package/dist/src/adapters/frameAggregate.d.ts +8 -3
  119. package/dist/src/adapters/frameTracker.d.ts +30 -0
  120. package/dist/src/adapters/httpAttributes.d.ts +18 -0
  121. package/dist/src/adapters/loadingTime.d.ts +67 -0
  122. package/dist/src/adapters/native/interceptHttpNative.native.d.ts +18 -0
  123. package/dist/src/adapters/native/memoryNative.native.d.ts +15 -11
  124. package/dist/src/adapters/native/runtimeReady.native.d.ts +1 -0
  125. package/dist/src/adapters/native/store.native.d.ts +10 -0
  126. package/dist/src/adapters/nativeSender.d.ts +2 -2
  127. package/dist/src/adapters/navigationRef.d.ts +21 -0
  128. package/dist/src/adapters/navigationTracker.d.ts +1 -1
  129. package/dist/src/adapters/screenTiming.d.ts +8 -2
  130. package/dist/src/adapters/seedRuntimeReady.d.ts +8 -0
  131. package/dist/src/adapters/traceHeader.d.ts +33 -0
  132. package/dist/src/adapters/traceManager.d.ts +131 -0
  133. package/dist/src/adapters/uiInteraction.d.ts +111 -0
  134. package/dist/src/adapters/viewManager.d.ts +161 -0
  135. package/dist/src/adapters/viewport.d.ts +15 -0
  136. package/dist/src/adapters/web/interactionWeb.web.d.ts +36 -0
  137. package/dist/src/adapters/web/interceptFetchWeb.web.d.ts +3 -11
  138. package/dist/src/adapters/web/navigationWeb.web.d.ts +10 -0
  139. package/dist/src/adapters/web/runtimeReady.web.d.ts +5 -0
  140. package/dist/src/adapters/web/store.web.d.ts +12 -0
  141. package/dist/src/adapters/web/webVitals.web.d.ts +49 -0
  142. package/dist/src/adapters/webSender.d.ts +2 -2
  143. package/dist/src/adapters/webVitals.d.ts +25 -0
  144. package/dist/src/adapters/xhrIntercept.d.ts +53 -0
  145. package/dist/src/core/beforeSend.d.ts +20 -0
  146. package/dist/src/core/debug.d.ts +7 -0
  147. package/dist/src/core/memoryStore.d.ts +23 -0
  148. package/dist/src/core/store.d.ts +41 -0
  149. package/dist/src/core/telemetry.d.ts +305 -35
  150. package/dist/src/core/userProfile.d.ts +12 -0
  151. package/dist/src/core/utils/json.d.ts +7 -0
  152. package/dist/src/core/utils/uuid.d.ts +10 -1
  153. package/dist/src/createTelemetry.native.d.ts +7 -0
  154. package/dist/src/createTelemetry.web.d.ts +7 -0
  155. package/dist/src/index.base.d.ts +17 -1
  156. package/dist/src/index.native.d.ts +46 -9
  157. package/dist/src/index.web.d.ts +33 -2
  158. package/dist/store.native-BYBJcy6o.js +32 -0
  159. package/dist/store.native-BYBJcy6o.js.map +1 -0
  160. package/dist/store.native-TPqXngmF.cjs +2 -0
  161. package/dist/store.native-TPqXngmF.cjs.map +1 -0
  162. package/dist/store.web-CvqqN-_Z.cjs +2 -0
  163. package/dist/store.web-CvqqN-_Z.cjs.map +1 -0
  164. package/dist/store.web-DJAlN0nc.js +31 -0
  165. package/dist/store.web-DJAlN0nc.js.map +1 -0
  166. package/dist/telemetry-ByDJIz_9.js +1280 -0
  167. package/dist/telemetry-ByDJIz_9.js.map +1 -0
  168. package/dist/telemetry-CrU7i1Jc.cjs +2 -0
  169. package/dist/telemetry-CrU7i1Jc.cjs.map +1 -0
  170. package/dist/uiInteraction-CdzvEI1p.cjs +2 -0
  171. package/dist/uiInteraction-CdzvEI1p.cjs.map +1 -0
  172. package/dist/uiInteraction-DAqxxz0q.js +104 -0
  173. package/dist/uiInteraction-DAqxxz0q.js.map +1 -0
  174. package/dist/viewport-Dd85GUNO.cjs +2 -0
  175. package/dist/viewport-Dd85GUNO.cjs.map +1 -0
  176. package/dist/viewport-Dzmt5QwK.js +15 -0
  177. package/dist/viewport-Dzmt5QwK.js.map +1 -0
  178. package/dist/web-vitals.attribution-CVCPiX-h.cjs +2 -0
  179. package/dist/web-vitals.attribution-CVCPiX-h.cjs.map +1 -0
  180. package/dist/web-vitals.attribution-xw9N5MKQ.js +488 -0
  181. package/dist/web-vitals.attribution-xw9N5MKQ.js.map +1 -0
  182. package/dist/webSender-BIwslfpT.js +63 -0
  183. package/dist/webSender-BIwslfpT.js.map +1 -0
  184. package/dist/webSender-CVtE4sOF.cjs +2 -0
  185. package/dist/webSender-CVtE4sOF.cjs.map +1 -0
  186. package/dist/webVitals.web-CNIpsKlA.js +85 -0
  187. package/dist/webVitals.web-CNIpsKlA.js.map +1 -0
  188. package/dist/webVitals.web-DpWJ-iYp.cjs +2 -0
  189. package/dist/webVitals.web-DpWJ-iYp.cjs.map +1 -0
  190. package/dist/xhrIntercept-BADex6Cq.cjs +2 -0
  191. package/dist/xhrIntercept-BADex6Cq.cjs.map +1 -0
  192. package/dist/xhrIntercept-BNiA41Mj.js +53 -0
  193. package/dist/xhrIntercept-BNiA41Mj.js.map +1 -0
  194. package/docs/migration-v4.md +190 -0
  195. package/package.json +12 -4
  196. package/dist/appLifecycle-Cynw0kAv.js +0 -14
  197. package/dist/appLifecycle-Cynw0kAv.js.map +0 -1
  198. package/dist/appLifecycle-DvJIt7ym.cjs +0 -2
  199. package/dist/appLifecycle-DvJIt7ym.cjs.map +0 -1
  200. package/dist/batch-CLBFgYPC.cjs +0 -2
  201. package/dist/batch-CLBFgYPC.cjs.map +0 -1
  202. package/dist/batch-DLG_gtOm.js +0 -12
  203. package/dist/batch-DLG_gtOm.js.map +0 -1
  204. package/dist/crashCapture-BYTM4FLC.cjs +0 -2
  205. package/dist/crashCapture-BYTM4FLC.cjs.map +0 -1
  206. package/dist/crashCapture-D_rkkBVa.js +0 -31
  207. package/dist/crashCapture-D_rkkBVa.js.map +0 -1
  208. package/dist/crashHandler.web-BSxDpFTo.cjs +0 -2
  209. package/dist/crashHandler.web-BSxDpFTo.cjs.map +0 -1
  210. package/dist/crashHandler.web-D59bmhg9.js +0 -32
  211. package/dist/crashHandler.web-D59bmhg9.js.map +0 -1
  212. package/dist/crashHandlerNative.native-DeAFze4E.cjs +0 -2
  213. package/dist/crashHandlerNative.native-DeAFze4E.cjs.map +0 -1
  214. package/dist/crashHandlerNative.native-d7qJkzA6.js +0 -41
  215. package/dist/crashHandlerNative.native-d7qJkzA6.js.map +0 -1
  216. package/dist/debug-DsvQ9y3W.js +0 -68
  217. package/dist/debug-DsvQ9y3W.js.map +0 -1
  218. package/dist/debug-rnOOxFpN.cjs +0 -2
  219. package/dist/debug-rnOOxFpN.cjs.map +0 -1
  220. package/dist/deviceInfo.native-7hbRYixR.cjs +0 -2
  221. package/dist/deviceInfo.native-7hbRYixR.cjs.map +0 -1
  222. package/dist/deviceInfo.native-D2k0LDhI.js.map +0 -1
  223. package/dist/deviceInfo.web-ByNAlEJh.cjs +0 -2
  224. package/dist/deviceInfo.web-ByNAlEJh.cjs.map +0 -1
  225. package/dist/deviceInfo.web-DzQyEku6.js +0 -40
  226. package/dist/deviceInfo.web-DzQyEku6.js.map +0 -1
  227. package/dist/frameAggregate-C_HoVFcK.cjs +0 -2
  228. package/dist/frameAggregate-C_HoVFcK.cjs.map +0 -1
  229. package/dist/frameAggregate-Cwh1juw1.js +0 -17
  230. package/dist/frameAggregate-Cwh1juw1.js.map +0 -1
  231. package/dist/frameDropsNative.native-CP7zrXDf.js +0 -23
  232. package/dist/frameDropsNative.native-CP7zrXDf.js.map +0 -1
  233. package/dist/frameDropsNative.native-DKqEDqip.cjs +0 -2
  234. package/dist/frameDropsNative.native-DKqEDqip.cjs.map +0 -1
  235. package/dist/frameDropsWeb.web-C9Lt3Fuj.js +0 -22
  236. package/dist/frameDropsWeb.web-C9Lt3Fuj.js.map +0 -1
  237. package/dist/frameDropsWeb.web-azpwQCUz.cjs +0 -2
  238. package/dist/frameDropsWeb.web-azpwQCUz.cjs.map +0 -1
  239. package/dist/httpAttributes-BV50UKQU.js +0 -19
  240. package/dist/httpAttributes-BV50UKQU.js.map +0 -1
  241. package/dist/httpAttributes-xXOD9trQ.cjs +0 -2
  242. package/dist/httpAttributes-xXOD9trQ.cjs.map +0 -1
  243. package/dist/interaction-CH8yoY5-.js +0 -23
  244. package/dist/interaction-CH8yoY5-.js.map +0 -1
  245. package/dist/interaction-DcNFBW7a.cjs +0 -2
  246. package/dist/interaction-DcNFBW7a.cjs.map +0 -1
  247. package/dist/interceptFetchNative.native-VlhtMG-U.js +0 -49
  248. package/dist/interceptFetchNative.native-VlhtMG-U.js.map +0 -1
  249. package/dist/interceptFetchNative.native-jEAldH14.cjs +0 -2
  250. package/dist/interceptFetchNative.native-jEAldH14.cjs.map +0 -1
  251. package/dist/interceptFetchWeb.web-DFzZliik.cjs +0 -2
  252. package/dist/interceptFetchWeb.web-DFzZliik.cjs.map +0 -1
  253. package/dist/interceptFetchWeb.web-DfsMfoRP.js +0 -77
  254. package/dist/interceptFetchWeb.web-DfsMfoRP.js.map +0 -1
  255. package/dist/memoryNative.native-BWLh2S9L.js +0 -48
  256. package/dist/memoryNative.native-BWLh2S9L.js.map +0 -1
  257. package/dist/memoryNative.native-DCXSOd4Q.cjs +0 -2
  258. package/dist/memoryNative.native-DCXSOd4Q.cjs.map +0 -1
  259. package/dist/memoryWeb.web-B3YuV-cB.js +0 -48
  260. package/dist/memoryWeb.web-B3YuV-cB.js.map +0 -1
  261. package/dist/memoryWeb.web-CheXAcJn.cjs +0 -2
  262. package/dist/memoryWeb.web-CheXAcJn.cjs.map +0 -1
  263. package/dist/nativeSender-BE_mzw_S.js +0 -70
  264. package/dist/nativeSender-BE_mzw_S.js.map +0 -1
  265. package/dist/nativeSender-DyAUGY7c.cjs +0 -2
  266. package/dist/nativeSender-DyAUGY7c.cjs.map +0 -1
  267. package/dist/navigationNative.native-3tO5JxSd.cjs +0 -2
  268. package/dist/navigationNative.native-3tO5JxSd.cjs.map +0 -1
  269. package/dist/navigationNative.native-BUCGPQX2.js +0 -25
  270. package/dist/navigationNative.native-BUCGPQX2.js.map +0 -1
  271. package/dist/navigationTracker-B2GMwyKk.cjs +0 -2
  272. package/dist/navigationTracker-B2GMwyKk.cjs.map +0 -1
  273. package/dist/navigationTracker-Cts68dgG.js.map +0 -1
  274. package/dist/navigationWeb.web-B0necr5j.cjs +0 -2
  275. package/dist/navigationWeb.web-B0necr5j.cjs.map +0 -1
  276. package/dist/navigationWeb.web-CORzE_Id.js +0 -46
  277. package/dist/navigationWeb.web-CORzE_Id.js.map +0 -1
  278. package/dist/networkInfo.web-DJmw1sNK.cjs +0 -2
  279. package/dist/networkInfo.web-DJmw1sNK.cjs.map +0 -1
  280. package/dist/networkInfo.web-twcNn_hc.js.map +0 -1
  281. package/dist/src/adapters/interaction.d.ts +0 -17
  282. package/dist/src/adapters/native/frameDropsNative.native.d.ts +0 -16
  283. package/dist/src/adapters/native/interceptFetchNative.native.d.ts +0 -23
  284. package/dist/src/adapters/native/navigationNative.native.d.ts +0 -12
  285. package/dist/src/adapters/web/frameDropsWeb.web.d.ts +0 -16
  286. package/dist/src/adapters/web/memoryWeb.web.d.ts +0 -26
  287. package/dist/telemetry-Co4MYdwa.cjs +0 -2
  288. package/dist/telemetry-Co4MYdwa.cjs.map +0 -1
  289. package/dist/telemetry-KI0x4gzC.js +0 -389
  290. package/dist/telemetry-KI0x4gzC.js.map +0 -1
  291. package/dist/v4-BysszJq8.js +0 -31
  292. package/dist/v4-BysszJq8.js.map +0 -1
  293. package/dist/v4-CqdK_b8Y.cjs +0 -2
  294. package/dist/v4-CqdK_b8Y.cjs.map +0 -1
  295. package/dist/webSender-B27tmT1w.js +0 -42
  296. package/dist/webSender-B27tmT1w.js.map +0 -1
  297. package/dist/webSender-BWi4x1df.cjs +0 -2
  298. package/dist/webSender-BWi4x1df.cjs.map +0 -1
package/README.md CHANGED
@@ -4,47 +4,66 @@
4
4
  [![React Native](https://img.shields.io/badge/React%20Native-0.64%2B-61DAFB.svg?style=flat&logo=react)](https://reactnative.dev/)
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6.svg?style=flat&logo=typescript)](https://www.typescriptlang.org/)
6
6
 
7
- A lightweight Real User Monitoring (RUM) SDK for **React Native** apps (and a web build via
8
- React Native Web). It captures performance, errors, network requests, device/network context,
9
- navigation and user interactions, then ships them as JSON to the shared **EdgeTelemetryProcessor**
10
- backend — the same backend the web, Android (Ionic) and iOS SDKs feed.
7
+ Real User Monitoring for **React Native** and **React Native Web**. Add it once and it reports
8
+ what your users actually experience — crashes, slow screens, failing requests, dead taps — to
9
+ your EdgeTelemetryProcessor collector.
11
10
 
12
- One SDK, two builds. The bundler picks `index.native.js` or `index.web.js` from the
13
- `package.json` `exports` map — you always import from `@nathanclaire/edge-telemetry-sdk`.
11
+ You import from one package name. The bundler picks the native or the web build for you.
12
+
13
+ ```typescript
14
+ import { createTelemetry } from "@nathanclaire/edge-telemetry-sdk";
15
+
16
+ const telemetry = createTelemetry({
17
+ apiKey: "edge_xxxxxxxx",
18
+ endpoint: "https://collector.example.com/telemetry",
19
+ });
20
+ ```
21
+
22
+ That's the whole setup. Crash capture, HTTP interception, screen tracking, frame and memory
23
+ sampling and session management all start on their own — there is nothing else to wire up.
24
+
25
+ ## Requirements
26
+
27
+ | | Minimum |
28
+ |---|---|
29
+ | React Native | 0.64 |
30
+ | React / React DOM | 17 |
31
+ | Platforms | iOS, Android, and any modern browser via React Native Web |
32
+ | TypeScript | Optional — types ship with the package |
14
33
 
15
34
  ---
16
35
 
17
36
  ## Contents
18
37
 
19
- - [Upgrading to v3](#upgrading-to-v3-breaking)
38
+ **Getting started**
20
39
  - [Install](#install)
21
40
  - [Quick start](#quick-start)
41
+ - [Verify it's working](#verify-its-working)
22
42
  - [Configuration](#configuration)
43
+
44
+ **How-to guides**
45
+ - [Send a custom event](#send-a-custom-event)
46
+ - [Identify the signed-in user](#identify-the-signed-in-user)
47
+ - [Track screens](#track-screens)
48
+ - [Track taps](#track-taps)
49
+ - [Report handled errors](#report-handled-errors)
50
+ - [Make crash stacks readable](#make-crash-stacks-readable)
51
+ - [Strip PII before it leaves the device](#strip-pii-before-it-leaves-the-device)
52
+ - [Sample a percentage of sessions](#sample-a-percentage-of-sessions)
53
+ - [Connect your traces to your backend](#connect-your-traces-to-your-backend)
54
+
55
+ **Reference**
56
+ - [What the SDK captures](#what-the-sdk-captures)
23
57
  - [API](#api)
24
- - [What gets captured](#what-gets-captured)
58
+ - [The Context block](#the-context-block)
25
59
  - [Wire format](#wire-format)
26
- - [Identity & IDs](#identity--ids)
27
- - [Reliability](#reliability)
28
- - [Debugging](#debugging)
29
- - [Development](#development)
30
-
31
- ---
60
+ - [Identity and IDs](#identity-and-ids)
61
+ - [Delivery and reliability](#delivery-and-reliability)
32
62
 
33
- ## Upgrading to v3 (breaking)
34
-
35
- v3 is a **single clean break** onto the EdgeRum wire contract — no dual-emit, no v2 fallback.
36
- If you're coming from v2, the headlines:
37
-
38
- - `createTelemetry({ apiKey, endpoint, ... })` — **`apiKey` is now required** (must start with
39
- `edge_`; sent as `X-API-Key`; init throws otherwise).
40
- - The POST body is the `telemetry_batch` envelope; timestamps are ISO-8601; device/network data
41
- rides as a Context block on **every** event (the standalone `device_info`/`network_info`
42
- events are gone).
43
- - Several event names changed and only the allowlist ships (`network_request` → `http.request`,
44
- `screen_view` → `navigation`, …); other `log()` names arrive as `custom_event`.
45
- - New `debug` option (default `false`) silences all SDK console output.
46
-
47
- Full breaking-change list and migration checklist: **[CHANGELOG.md](./CHANGELOG.md)**.
63
+ **Help**
64
+ - [Troubleshooting](#troubleshooting)
65
+ - [Upgrading](#upgrading)
66
+ - [Development](#development)
48
67
 
49
68
  ---
50
69
 
@@ -54,279 +73,895 @@ Full breaking-change list and migration checklist: **[CHANGELOG.md](./CHANGELOG.
54
73
  npm install @nathanclaire/edge-telemetry-sdk
55
74
  ```
56
75
 
57
- ### React Native peer dependencies
76
+ ### React Native one extra step
58
77
 
59
- Native capture relies on these (install in your app):
78
+ The native modules the SDK reads device and network state from ship as dependencies, so npm
79
+ installs them for you. On iOS, link them:
60
80
 
61
81
  ```bash
62
- npm install @react-native-async-storage/async-storage react-native-device-info @react-native-community/netinfo
63
82
  cd ios && pod install
64
83
  ```
65
84
 
66
- | Peer dep | Used for |
67
- |---|---|
68
- | `@react-native-async-storage/async-storage` | persisted replay of failed batches |
69
- | `react-native-device-info` | `device.*` context (optional degrades gracefully) |
70
- | `@react-native-community/netinfo` | `network.*` context + `network_change` |
85
+ | Module | What it provides | If it's missing |
86
+ |---|---|---|
87
+ | `@react-native-async-storage/async-storage` | Saves failed batches for replay | Telemetry is lost on a network outage instead of retried |
88
+ | `@react-native-community/netinfo` | `network.*` context, `network_change` events | No connectivity data |
89
+ | `react-native-device-info` | `device.*` detail, the `memory_usage` metric | Degrades gracefully — this one is an **optional** peer |
90
+
91
+ `react` and `react-dom` (>= 17) are required peers and will already be in your app.
92
+
93
+ ### React Native Web
71
94
 
72
- The **web build** uses browser APIs (`navigator`, `localStorage`) and needs none of the above.
95
+ The web build uses `fetch`, `localStorage` and `navigator` only. Nothing to link, nothing to
96
+ configure.
73
97
 
74
98
  ---
75
99
 
76
100
  ## Quick start
77
101
 
78
- `createTelemetry()` returns a `TelemetryNative` or `TelemetryWeb` instance the bundler chooses.
79
- **Every public method is `async`** (each awaits a lazily-built core), so `await` your calls.
80
-
81
- ### React Native
102
+ Create the instance once, as early in your app's startup as you can, and export it.
82
103
 
83
104
  ```typescript
105
+ // telemetry.ts
84
106
  import { createTelemetry } from "@nathanclaire/edge-telemetry-sdk";
85
107
 
86
- const telemetry = createTelemetry({
87
- apiKey: "edge_xxxxxxxx", // required; must start with "edge_"
88
- endpoint: "https://collector.example.com/collector/telemetry", // full POST URL, used verbatim
89
- batchSize: 20,
90
- flushIntervalMs: 10000,
108
+ export const telemetry = createTelemetry({
109
+ apiKey: "edge_xxxxxxxx", // required, must start with "edge_"
110
+ endpoint: "https://collector.example.com/telemetry", // your collector's POST URL
111
+ buildId: process.env.GIT_SHA, // optional, makes crashes symbolicatable
91
112
  });
113
+ ```
114
+
115
+ ```typescript
116
+ // App.tsx
117
+ import { telemetry } from "./telemetry";
92
118
 
93
119
  await telemetry.log("checkout_started", { cart_value: 42 });
94
120
  ```
95
121
 
96
- ### React Web
122
+ **Create it early.** Everything before `createTelemetry()` runs is invisible to the SDK — a crash
123
+ during startup, the first screen's load time, the requests your splash screen fires. The first
124
+ line of your entry file is the right place.
125
+
126
+ > **Every public method returns a Promise.** Each one waits on a lazily-built core, so `await`
127
+ > your calls — or at minimum attach a `.catch()`. They never throw at you; the SDK swallows its
128
+ > own failures rather than taking your app down with it.
129
+
130
+ ---
131
+
132
+ ## Verify it's working
133
+
134
+ Turn on debug logging, send one event, and force a flush:
97
135
 
98
136
  ```typescript
99
- import { createTelemetry } from "@nathanclaire/edge-telemetry-sdk";
137
+ const telemetry = createTelemetry({
138
+ apiKey: "edge_xxxxxxxx",
139
+ endpoint: "https://collector.example.com/telemetry",
140
+ debug: true, // routes the SDK's internal diagnostics to the console
141
+ });
142
+
143
+ await telemetry.log("hello_world");
144
+ await telemetry.flush(); // don't wait for the 30-second timer
145
+ ```
100
146
 
147
+ You should see the batch POST in your console and a `200` from the collector. If you don't, start
148
+ at [Troubleshooting](#troubleshooting).
149
+
150
+ **The SDK is silent by default.** With `debug` off — the default — it writes nothing to your
151
+ console, including when its own sends fail. That is deliberate: a monitoring tool should not be
152
+ the noisiest thing in your log. Turn it on while integrating, then turn it off.
153
+
154
+ ---
155
+ ## Configuration
156
+
157
+ Everything is passed to `createTelemetry()`. There are no runtime setters for the options marked
158
+ **constructor-only** — see the note below the table.
159
+
160
+ | Option | Type | Default | What it does |
161
+ |---|---|---|---|
162
+ | `apiKey` | `string` | — | **Required.** Must start with `edge_`. Sent as both `X-API-Key` and `Authorization: Bearer`. |
163
+ | `endpoint` | `string` | placeholder | Your collector's full POST URL. **Always set this** — the default resolves nowhere. |
164
+ | `batchSize` | `number` | `50` | Events per flush. |
165
+ | `flushIntervalMs` | `number` | `30000` | How often to flush. `<= 0` disables the timer. |
166
+ | `captureConsole` | `boolean` | `false` | Turns `console.error` into `app.error` and `console.warn` into a breadcrumb. |
167
+ | `debug` | `boolean` | `false` | Prints the SDK's internal diagnostics. Off means completely silent. |
168
+ | `buildId` | `string` | omitted | Your build's identifier, for [symbolicating crashes](#make-crash-stacks-readable). |
169
+ | `beforeSend` | `(event) => event \| null` | none | [Scrubbing hook](#strip-pii-before-it-leaves-the-device). Constructor-only. |
170
+ | `sessionSampleRate` | `number` | `1` | [Fraction of sessions to keep](#sample-a-percentage-of-sessions), `0`–`1`. Constructor-only. |
171
+ | `traceHostAllowlist` | `string[]` | `[]` (empty) | [Hosts allowed to receive `traceparent`](#connect-your-traces-to-your-backend). Constructor-only. |
172
+ | `store` | `Store` | platform default | Replaces `localStorage` / `AsyncStorage`. Mostly for tests. |
173
+ | `sender` | `Sender` | platform default | Replaces the HTTP transport. Mostly for tests. |
174
+
175
+ ```typescript
101
176
  const telemetry = createTelemetry({
102
177
  apiKey: "edge_xxxxxxxx",
103
- endpoint: "https://collector.example.com/collector/telemetry",
104
- batchSize: 20,
105
- flushIntervalMs: 10000,
178
+ endpoint: "https://collector.example.com/telemetry",
179
+ batchSize: 50,
180
+ flushIntervalMs: 30_000,
181
+ buildId: process.env.GIT_SHA,
182
+ sessionSampleRate: 1,
183
+ traceHostAllowlist: ["api.example.com"],
106
184
  });
185
+ ```
107
186
 
108
- await telemetry.log("checkout_started", { cart_value: 42 });
187
+ **Why some options are constructor-only.** `beforeSend`, `sessionSampleRate` and
188
+ `traceHostAllowlist` cannot be set after init, because the gap between the two is not empty: by
189
+ the time you called a setter, `session.started`, the launch trace root and your first HTTP
190
+ requests are already queued. A scrubber that misses the first events is not a scrubber.
191
+
192
+ ### Two things to get right
193
+
194
+ **`apiKey` is validated immediately.** A key that doesn't start with `edge_` throws from
195
+ `createTelemetry()`, so a typo fails at startup instead of silently dropping data for a week. The
196
+ value may be an API key *or* an `edge_`-prefixed JWT; the collector accepts both. You never send a
197
+ tenant ID — the backend resolves your tenant from the key.
198
+
199
+ **`endpoint` is used verbatim.** The collector terminates `POST /telemetry`. If you leave it
200
+ unset, every send fails against a placeholder host.
201
+
202
+ ---
203
+
204
+ ## Send a custom event
205
+
206
+ ```typescript
207
+ await telemetry.log("checkout_started", { cart_value: 42, currency: "KES" });
109
208
  ```
110
209
 
111
- Once constructed, the SDK **auto-starts** its collectors you don't wire them up: session start,
112
- crash/error capture, HTTP interception, frame + memory sampling, app foreground/background, and
113
- (web) navigation. You only opt in to navigation and interaction capture on native (below).
210
+ Your `data` is flattened into dot-notation attributes and must hold **primitive values** string,
211
+ number, boolean. Objects are flattened; arrays and anything nested deeper than 8 levels are
212
+ JSON-stringified.
213
+
214
+ > **Names outside the allowlist still arrive.** Only [allowlisted event names](#what-the-sdk-captures)
215
+ > travel under their own name. Any other name — `checkout_started` above — is sent as
216
+ > `custom_event` with your name preserved in `attributes["event.name"]`. Nothing is dropped, so
217
+ > query custom events by `event.name`, not by `event_name`.
114
218
 
115
219
  ---
116
220
 
117
- ## Configuration
221
+ ## Identify the signed-in user
118
222
 
119
223
  ```typescript
120
- type TelemetryOpts = {
121
- apiKey: string; // REQUIRED — must start with "edge_"; sent as X-API-Key
122
- endpoint?: string; // full collector POST URL (used verbatim). Default is a placeholder — always set it
123
- batchSize?: number; // events per flush. Core default 2 — set higher (e.g. 20) for production
124
- flushIntervalMs?: number; // periodic flush. Default 10000; <= 0 disables the timer
125
- captureConsole?: boolean; // funnel console.error/warn into app.crash. Default on (opt-out)
126
- debug?: boolean; // SDK-internal console diagnostics. Default false (silent)
127
- sender?: Sender; // override the transport (mainly for tests)
128
- };
224
+ await telemetry.identify({
225
+ userId: "u-42",
226
+ name: "Ada Lovelace",
227
+ email: "ada@example.com",
228
+ phone: "+254700000000",
229
+ customAttributes: { plan: "pro", org: "acme" },
230
+ });
129
231
  ```
130
232
 
131
- - **`apiKey`** is validated at `createTelemetry()` a key not starting with `edge_` throws
132
- immediately, so misconfiguration fails fast instead of silently dropping data. `tenant_id`
133
- is never sent; the backend resolves the tenant from the key.
134
- - **`endpoint`** is the exact URL the SDK POSTs to. Point it at your collector's telemetry path.
233
+ `identify()` does two things: it sets `user.id` on every subsequent event, and it emits one
234
+ `user.profile.update` event carrying the profile.
235
+
236
+ > **Warning if you are upgrading from v3, read this.** `setUserProfile()`, `setUserDetails()`,
237
+ > `updateUserProfile()`, `setUserName()` and `setUserContact()` now record state **only**. They no
238
+ > longer put anything on the wire. If your app used one of them and never called `identify()`,
239
+ > your profile data stopped arriving at the v4 upgrade. Add one `identify()` call.
240
+
241
+ **Why the change:** `user.name`, `user.email` and `user.phone` used to ride the Context block of
242
+ *every* event, so a 10,000-event session put 10,000 copies of an email address on the wire and at
243
+ rest, to populate a table that needs it once. They now ride `user.profile.update` and no other
244
+ event. Because the profile is held in memory, `identify()` re-fires on every launch and the table
245
+ stays current.
246
+
247
+ **Limits.** `name` and `email` truncate at 255 characters, `phone` at **50** (the column is
248
+ `VARCHAR(50)`, and an over-long value loses the whole profile behind a `200`).
249
+ `customAttributes` is capped at 64 keys, 64-character keys and 255-character values;
250
+ non-primitive values are JSON-stringified then truncated. Anything dropped is counted in
251
+ `user.custom_dropped` and warned about once, in development. A bad payload is never a throw.
252
+
253
+ **`user.id` is yours.** The SDK never mints one. Omit `userId` and your traffic stays anonymous —
254
+ still fully joinable on `device.id`, just not attributed to a person.
135
255
 
136
256
  ---
137
257
 
138
- ## API
258
+ ## Track screens
259
+
260
+ The SDK opens a **view** at startup and closes it at each of four boundaries: a route change,
261
+ backgrounding, a session rotation, and process death. Each closed view emits one `view` event
262
+ with its name, dwell time, request count and load time.
263
+
264
+ ### React Navigation (recommended, both platforms)
265
+
266
+ ```tsx
267
+ import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
268
+ import { telemetry } from "./telemetry";
269
+
270
+ export default function App() {
271
+ const navRef = useNavigationContainerRef();
272
+
273
+ return (
274
+ <NavigationContainer
275
+ ref={navRef}
276
+ onReady={() => { telemetry.attachNavigation(navRef); }}
277
+ >
278
+ {/* your screens */}
279
+ </NavigationContainer>
280
+ );
281
+ }
282
+ ```
139
283
 
140
- All methods return Promises. Available on both platforms unless marked **native-only**.
284
+ One call covers native **and** web `getCurrentRoute()` is a navigation-tree API, not a native
285
+ one.
141
286
 
142
- ### Core
287
+ ### Naming screens manually
143
288
 
144
289
  ```typescript
145
- log(event: string, data?: Record<string, any>): Promise<void> // custom event (see allowlist note)
146
- flush(): Promise<void> // force-send the queue now
147
- shutdown(): Promise<void> // clear the flush timer + final flush
290
+ await telemetry.screenStart("Checkout"); // native only
291
+ await telemetry.screenEnd("Checkout"); // native only
292
+ ```
293
+
294
+ > **Warning — don't mix `screenStart()` with `attachNavigation()`.** Screen names have ranks:
295
+ > an explicit name outranks a route name, which outranks a URL. A lower-ranked name never
296
+ > overwrites a higher-ranked one — so a single `screenStart()` call pins the view to that name
297
+ > and later route changes stop opening new views. Pick one approach per screen.
298
+
299
+ ### Reading the results
300
+
301
+ `view` events carry `view.loading_time`, measured as **network settle**: the point where the
302
+ screen's in-flight requests go quiet for a second. It is deliberately `null`, never `0`, for a
303
+ screen that fetched nothing — a `0` would make your p75 track the cache-hit rate, so a backend
304
+ caching win would show up as a frontend regression.
305
+
306
+ `view.loading_time_outcome` always ships and tells the nulls apart:
307
+
308
+ | Outcome | Meaning |
309
+ |---|---|
310
+ | `settled` | Requests finished. `view.loading_time` has a number. |
311
+ | `no_activity` | The screen started no requests. Null. |
312
+ | `capped` | Still loading after 30 seconds. Null — **not** the cap, so 30s and 90s stay distinguishable. |
313
+ | `abandoned` | The user left while it was still loading. Null. |
314
+
315
+ Query **p75 where `outcome = 'settled'`**, alongside **% `capped`**. A plain
316
+ `AVG(loading_time)` averages four different populations.
317
+
318
+ > One screen visit can produce several `view` rows — backgrounding and returning splits it. Sum by
319
+ > `view.name`.
320
+
321
+ ---
322
+ ## Track taps
323
+
324
+ ### Web — automatic
325
+
326
+ Every click emits a `ui.interaction` event. No wiring: the SDK attaches one capture-phase
327
+ listener to `document`.
328
+
329
+ **Names are read only from elements that are actionable by role** — `<button>`, `<a href>`,
330
+ `<input type="submit|button|reset">`, `<summary>`, `<option>`, or an explicit
331
+ `role="button|link|tab|checkbox|radio|switch|menuitem|option"`. That gate is what stops a
332
+ clickable `<div>` full of customer data from being auto-named. On React Native Web, a `Pressable`
333
+ lands on a role-bearing element automatically.
334
+
335
+ To name anything yourself — including a role-less element — use `data-edge-action-name`:
336
+
337
+ ```html
338
+ <div data-edge-action-name="Checkout — Step 2 of 3">…</div>
148
339
  ```
149
340
 
150
- > **Allowlist note:** only the allowlisted event names reach the backend as-is. Any other name
151
- > you pass to `log()` is shipped as `custom_event` with your original name in
152
- > `attributes["event.name"]` — so custom events are preserved, not dropped.
341
+ It wins over everything else and ships **exactly as written**, uncapped and unnormalized, because
342
+ you should be able to predict the value you just set. Below it the SDK tries `data-testid`,
343
+ `aria-label`, `title`, then `textContent`each lowercased, with non-alphanumerics folded to `_`
344
+ and capped at 64 characters, so `"Add to cart!"` becomes `add_to_cart`.
345
+
346
+ When nothing matched, `ui.target` tells you why:
347
+
348
+ | The click landed on | `ui.target` | Read it as |
349
+ |---|---|---|
350
+ | A role-bearing element, a name was found | the name | — |
351
+ | A role-bearing element, nothing readable | `unnamed` | An instrumentation gap. Add `data-edge-action-name`. |
352
+ | A role-less element with `cursor: pointer` | `unnamed` | Same gap. |
353
+ | A role-less element, default cursor | `surface` | Someone tapped padding. Nothing to fix. |
354
+
355
+ Two frustration signals ride along automatically: **`ui.rage`** (3+ clicks within a second on the
356
+ same element) and **`ui.dead`** (no DOM change, no request and no navigation within a second of an
357
+ actionable click).
358
+
359
+ > **Warning — the role gate is not a privacy control.** `<button>Delete John Kamau</button>` still
360
+ > ships that text as the event name. Use [`beforeSend`](#strip-pii-before-it-leaves-the-device)
361
+ > to scrub it. There is deliberately no per-element masking attribute.
153
362
 
154
- ### Identity
363
+ ### Native — name the taps you care about
364
+
365
+ ```tsx
366
+ <Pressable
367
+ onPress={() => {
368
+ telemetry.trackTap("checkout");
369
+ navigation.navigate("Cart");
370
+ }}
371
+ />
372
+ ```
373
+
374
+ Native taps are explicit-only. React Native gives a press handler a numeric node tag with no
375
+ public API to resolve it, so a root-level listener genuinely cannot tell a button from padding —
376
+ anything auto-derived would be wrong, or a PII leak. Name the controls that matter.
377
+
378
+ The name ships uncapped and unnormalized, exactly as you wrote it.
379
+
380
+ > **Your interaction coverage equals your instrumentation coverage.** An uninstrumented screen
381
+ > looks identical to an unused one. `ui.rage` on native is gated to named taps too, so a low rage
382
+ > count means *few named taps*, not happy users. And `ui.dead` never appears on native at all —
383
+ > there is no DOM, so there is no mutation signal — so any dead-click rate must filter to web.
384
+
385
+ **The tap's identity is captured at the tap, not at send.** A tap that navigates is still
386
+ attributed to the screen it happened on, not the one it opened.
387
+
388
+ ---
389
+
390
+ ## Report handled errors
391
+
392
+ Unhandled errors are captured for you. For the ones you catch, call `captureError()`:
155
393
 
156
394
  ```typescript
157
- identify(profile: { // EdgeRum-style — emits user.profile.update,
158
- name?: string; email?: string; // attaches identity to subsequent events, and
159
- phone?: string; avatar?: string; // PRESERVES the anonymous user.id
160
- customAttributes?: Record<string, any>;
161
- }): Promise<void>
395
+ try {
396
+ await pay();
397
+ } catch (err) {
398
+ await telemetry.captureError(err, { "checkout.step": "pay" });
399
+ }
400
+ ```
162
401
 
163
- setUserId(id: string): Promise<void>
164
- generateUserId(): Promise<string>
165
- setUserProfile(profile): Promise<void>
166
- setUserDetails(details): Promise<void> // fullName/firstName/lastName/email/phone/avatar/customAttributes
167
- updateUserProfile(updates): Promise<void>
168
- getUserProfile(): Promise<UserProfile | undefined>
169
- clearUserProfile(): Promise<void>
170
- setUserName(fullName, firstName?, lastName?): Promise<void>
171
- setUserContact(email?, phone?): Promise<void>
402
+ It takes `unknown` on purpose — real `catch` blocks receive strings and axios rejection objects,
403
+ and you shouldn't have to prove to TypeScript that it's an `Error` first.
404
+
405
+ ### Two event names, and why it matters
406
+
407
+ | | `app.crash` | `app.error` |
408
+ |---|---|---|
409
+ | Means | Unhandled | Handled |
410
+ | Comes from | `ErrorUtils`, `window.onerror`, unhandled rejections | `captureError()`, opted-in `console.error` |
411
+ | Carries breadcrumbs | Yes — the last 20 actions | No |
412
+
413
+ The split exists for one query: crash-free rate is
414
+ `COUNT(event_name = 'app.crash') / sessions`, with no `WHERE` clause anyone can forget.
415
+
416
+ > **`app.crash` is SDK-owned.** `log("app.crash", …)` is routed to `app.error` rather than
417
+ > manufacturing a crash row. A reported error is data — it just isn't a crash.
418
+
419
+ > **Warning — `error.fatal` is a trap on web.** Nothing is fatal in a browser: `window.onerror`
420
+ > fires and the page keeps running, so `error.fatal` is native-only. A cross-platform
421
+ > `1 − COUNT(fatal = true) / sessions` scores web at 100% forever. Use the unfiltered
422
+ > `COUNT(app.crash)`, or exclude web.
423
+
424
+ ### Capturing console output
425
+
426
+ ```typescript
427
+ createTelemetry({ apiKey: "edge_xxxxxxxx", endpoint: "…", captureConsole: true });
172
428
  ```
173
429
 
430
+ Off by default. With it on, `console.error` becomes an `app.error` and `console.warn` becomes a
431
+ breadcrumb. It defaults off because React's own development-mode warnings were the single largest
432
+ contributor to v3 crash counts, which made "is this release crashing more?" unanswerable.
433
+
434
+ ---
435
+
436
+ ## Make crash stacks readable
437
+
438
+ React Native ships every crash stack minified, so each frame reads as a single letter. Two lines
439
+ fix that, and the SDK writes neither on your behalf.
440
+
174
441
  ```typescript
175
- await telemetry.identify({ name: "Ada Lovelace", email: "ada@example.com" });
442
+ // 1. In your entry file, before anything else runs:
443
+ Error.stackTraceLimit = 50;
444
+
445
+ // 2. In your telemetry config:
446
+ const telemetry = createTelemetry({
447
+ apiKey: "edge_xxxxxxxx",
448
+ endpoint: "https://collector.example.com/telemetry",
449
+ buildId: process.env.GIT_SHA, // ships as app.build_id
450
+ });
451
+ ```
452
+
453
+ **`Error.stackTraceLimit`** defaults to **10 frames** — well short of what the SDK can carry, and
454
+ ten frames of a rejected promise can be entirely library internals. The SDK never assigns it,
455
+ because raising it globally would make every `new Error()` in *your* app more expensive,
456
+ invisibly. It warns once in development and leaves the line to you.
457
+
458
+ **`buildId`** is the symbolication join key. Use a git SHA or a CI run number. Crashes resolve
459
+ against `(app, device.platform, app.build_id)` — three parts, because one commit produces
460
+ different Metro output for iOS and Android, and a one-part key would resolve an Android crash
461
+ against the iOS source map and give you frames that are plausible and wrong.
462
+
463
+ > **Warning — never derive `buildId` from your app version.** `${version}-${buildNumber}` is
464
+ > correct for most apps, which is exactly what makes it dangerous: under Expo Updates or CodePush
465
+ > the native binary is unchanged, so the derived key fetches the **wrong** source map and resolves
466
+ > to plausible-wrong line numbers, with nothing on the row marking them untrustworthy. The SDK has
467
+ > no OTA awareness and will not guess. Leave `buildId` unset and crashes stay unsymbolicated —
468
+ > which is at least honest.
469
+
470
+ Stack traces travel raw and byte-for-byte so `metro-symbolicate` can consume them. Over the
471
+ 2000-character cap the tail is dropped on a frame boundary and marked `… [truncated]`.
472
+
473
+ ---
474
+ ## Strip PII before it leaves the device
475
+
476
+ `beforeSend` runs on every event and metric, synchronously, **at enqueue** — before anything can
477
+ reach the network *or* the offline store. Return the event to keep it, or `null` to drop it.
478
+
479
+ ```typescript
480
+ import { createTelemetry, type BeforeSend } from "@nathanclaire/edge-telemetry-sdk";
481
+
482
+ const scrub: BeforeSend = (event) => {
483
+ const attrs = event.attributes;
484
+ if (!attrs) return event;
485
+
486
+ // Drop a key outright
487
+ delete attrs["ui.target"];
488
+
489
+ // Or rewrite one
490
+ if (typeof attrs["user.email"] === "string") {
491
+ attrs["user.email"] = hash(attrs["user.email"]);
492
+ }
493
+
494
+ // Or drop the whole event
495
+ if (event.eventName === "custom_event" && attrs["event.name"] === "internal_debug") {
496
+ return null;
497
+ }
498
+
499
+ return event;
500
+ };
501
+
502
+ const telemetry = createTelemetry({ apiKey: "edge_xxxxxxxx", endpoint: "…", beforeSend: scrub });
176
503
  ```
177
504
 
178
- ### Context accessors
505
+ **It runs at enqueue, not at flush,** because a failed send persists the batch to disk — and on
506
+ native that disk outlives the process. A flush-time hook would let unscrubbed PII land there
507
+ first. **It is synchronous** because crashes flush during teardown, and a Promise-returning hook
508
+ would put your `await` in a dying app's path.
509
+
510
+ **It covers metrics too.** `vital.target` is a raw CSS selector and the least sanitized value the
511
+ SDK sends.
512
+
513
+ ### What you can and cannot change
514
+
515
+ Your hook gets a **copy**. Three tiers are re-stamped from the original after it returns:
516
+
517
+ | Tier | Keys | What happens |
518
+ |---|---|---|
519
+ | **A — immutable** | `type`, `eventName`/`metricName`, `timestamp`, `session.*`, `event.sequence`, everything under `sdk.` and `app.`, `device.platform`, `trace.id`, `span.id`, `parent.span.id`, `rum.action.id`, `view.id` | Restored — covers deletion *and* forgery |
520
+ | **B — rewritable, not deletable** | `device.id` | Hash it if you like; delete it and the collector rejects the batch |
521
+ | **C — yours** | Everything else: `user.*`, `http.*`, `error.*`, `ui.target`, `vital.target`, your own `log()` data | Untouched. This is where the PII lives. |
522
+
523
+ The tiers are enforced by re-stamping, **never by throwing**. The realistic hook is a `delete`
524
+ loop and the realistic bug is over-deletion — an over-broad hook must not be able to get your
525
+ whole feed silently discarded behind a `200`.
526
+
527
+ **A hook that throws fails closed:** the event is dropped, never sent in its original form,
528
+ because a bug in a scrubber must not ship the exact field the scrubber existed to remove. Only an
529
+ explicit `null` counts as a deliberate drop. The two outcomes are counted separately on the wire
530
+ — `sdk.hook_dropped` and `sdk.hook_failed` — because "my volume is down 40%" needs to distinguish
531
+ a working scrubber from a broken one.
532
+
533
+ ---
534
+
535
+ ## Sample a percentage of sessions
536
+
537
+ ```typescript
538
+ createTelemetry({ apiKey: "edge_xxxxxxxx", endpoint: "…", sessionSampleRate: 0.1 });
539
+ ```
540
+
541
+ The dice are rolled **once per session**, persisted with the session record and re-rolled when
542
+ the session rotates — never per event, which would desynchronise the per-view counters. A
543
+ sampled-out session sends **nothing at all**.
544
+
545
+ > **Warning — crashes are not exempt.** Sampling out a session drops its crashes too. It has to:
546
+ > keeping 100% of crashes over 10% of sessions makes the unfiltered crash-free query read ten
547
+ > times too healthy, with no `WHERE` clause available to repair it.
548
+
549
+ Every row carries `session.sample_rate`, so extrapolating back up is arithmetic. A rate outside
550
+ `0`–`1`, or a `NaN`, warns and falls back to `1` — `Math.random() < NaN` is always false and
551
+ would silently mute your entire deployment.
552
+
553
+ ---
554
+
555
+ ## Connect your traces to your backend
556
+
557
+ The SDK can attach a W3C `traceparent` header to your API calls, so a slow screen in your RUM
558
+ data links to the exact backend trace that caused it.
559
+
560
+ **This is off by default.** The allowlist starts empty, so upgrading can never break your network
561
+ calls on day one. Opt hosts in explicitly:
562
+
563
+ ```typescript
564
+ createTelemetry({
565
+ apiKey: "edge_xxxxxxxx",
566
+ endpoint: "https://collector.example.com/telemetry",
567
+ traceHostAllowlist: ["api.example.com", "checkout.example.com"],
568
+ });
569
+ ```
570
+
571
+ > **Warning — do this first.** Add `traceparent` to `Access-Control-Allow-Headers` on every host
572
+ > you list, *before* you list it. Missing that header is what *"our app broke when we turned on
573
+ > tracing"* actually means. Adding a header also turns previously-simple cross-origin GETs into
574
+ > preflighted ones, so set `Access-Control-Max-Age` while you're there.
575
+
576
+ ### The rules, in full
577
+
578
+ - **Bare hosts, exact match.** `api.example.com`, not `https://api.example.com/v2` and not
579
+ `*.example.com`. Listing a host is your assertion that *that host's* CORS allows the header,
580
+ and nobody can make that assertion over a wildcard.
581
+ - **Ports are ignored** when matching. Note that `http.host` on the wire *keeps* the port — the
582
+ two are deliberately different, so don't join on them.
583
+ - **`traceparent` is the only header the SDK writes, and the only one it reads.** No `b3`, no
584
+ `tracestate`, ever. That is a sentence you should be able to state in a security review.
585
+ - **The SDK never strips or rewrites a header it didn't add.** Set your own `traceparent` and it
586
+ adopts yours and steps aside.
587
+ - **No retry without the header.** A rejected preflight and a dead server are the same
588
+ `TypeError`, so retrying would double-send a non-idempotent POST. A tracing feature must not be
589
+ able to double-charge a card.
590
+ - **A malformed allowlist entry throws in development and is dropped in production.** A RUM SDK
591
+ crashing a shipped banking app over a config typo is the one failure worse than no tracing.
592
+
593
+ ### Debugging a missing join
594
+
595
+ Every request stamps `traceparent.outcome`, so "the trace isn't joining" is a query rather than a
596
+ mystery:
597
+
598
+ | Value | What happened |
599
+ |---|---|
600
+ | *(absent)* | Not traced — you have no allowlist |
601
+ | `skipped_off_allowlist` | You have an allowlist and this host isn't on it |
602
+ | `skipped_no_cors` | The request's `mode` ruled it out (fetch only) |
603
+ | `skipped_consumer_set` | You set a `traceparent` and it couldn't be parsed |
604
+ | `adopted` | You set a valid one; the SDK used yours |
605
+ | `injected_attributed` | Injected and joined to a live user action — the healthy case |
606
+ | `injected_expired` | Injected, but the action it belonged to had aged out |
607
+ | `injected_unattributed` | Injected with no user action in flight at all |
608
+
609
+ Every skip still stamps local IDs even though no header goes out. That is what makes *"the row is
610
+ here but the backend join is missing"* mean **the header was stripped in transit** rather than
611
+ something ambiguous.
612
+
613
+ > An unsampled session injects no header at all — not a `flags=00` one.
614
+
615
+ ---
616
+ ## What the SDK captures
617
+
618
+ All of this starts in the constructor. You don't call anything to enable it.
619
+
620
+ ### Events
621
+
622
+ | Event | Fires when | Platform |
623
+ |---|---|---|
624
+ | `app.start` | Once per process, at init | Both |
625
+ | `session.started` / `session.finalized` | Session begins / rotates | Both |
626
+ | `app_lifecycle` | Foreground ⇄ background | Both |
627
+ | `view` | A screen visit ends (route change, background, session rotation) | Both |
628
+ | `http.request` | Every `fetch` / `XHR` completes | Both |
629
+ | `app.crash` | Unhandled error or promise rejection | Both |
630
+ | `app.error` | `captureError()`, or an opted-in `console.error` | Both |
631
+ | `ui.interaction` | Every click (web) / every `trackTap()` (native) | Both |
632
+ | `network_change` | Connectivity type changes | Both |
633
+ | `user.profile.update` | `identify()` | Both |
634
+ | `navigation` | Route change or `screenStart()` | Both *(deprecated)* |
635
+ | `screen.duration` | `screenEnd()` | Native *(deprecated)* |
636
+ | `custom_event` | Any `log()` name not in this table | Both |
637
+
638
+ ### Metrics
639
+
640
+ | Metric | What it measures | Platform |
641
+ |---|---|---|
642
+ | `frame_render_time` | p95 frame time per window; closes every 10s **or** at a screen change | Both |
643
+ | `memory_usage` | Resident memory (RSS) in MB, sampled every 30s | **Native only** |
644
+ | `LCP` `FCP` `TTFB` | Core Web Vitals, once per page load | **Web only** |
645
+ | `CLS` `INP` | Running values, sent when the tab is hidden | **Web only** |
646
+
647
+ Metrics carry `metric.unit` — `ms`, `MB`, or `score` for CLS.
648
+
649
+ > **Web-only metrics are web-only on purpose.** Native emits no Core Web Vitals, and web emits no
650
+ > `memory_usage` — `performance.memory` is Chromium-only, so the metric's mere presence was a
651
+ > browser-detection signal wearing a memory label.
652
+
653
+ > **Warning — Core Web Vitals are page-load-scoped, not screen-scoped.** `LCP`, `FCP` and `TTFB`
654
+ > physically cannot recur on a soft navigation, so `view.id` on a vital row is always the
655
+ > **initial** view's. `GROUP BY view.name` over vitals reads as *"by entry point"* — a useful
656
+ > dashboard, as long as you label it that way.
657
+
658
+ ### Retired names
659
+
660
+ `page_load`, `resource_timing`, `long_task` and `user.interaction` were removed in v4. They are
661
+ unreachable — `log("page_load")` now arrives as `custom_event`. Their jobs are covered by `view`,
662
+ `view.loading_time`, the Core Web Vitals and `frame_render_time`.
663
+
664
+ ---
665
+
666
+ ## API
667
+
668
+ Every method returns a Promise.
669
+
670
+ ### Core — both platforms
179
671
 
180
672
  ```typescript
673
+ log(event: string, data?: Record<string, any>): Promise<void>
674
+ captureError(error: unknown, context?: Record<string, any>): Promise<void>
675
+ flush(): Promise<void> // send the queue now
676
+ shutdown(): Promise<void> // stop timers and do a final flush
677
+ attachNavigation(navigationRef: any): Promise<void>
678
+ trackErrors(options?: { captureConsole?: boolean }): Promise<void>
181
679
  getDeviceInfo(): Promise<DeviceInfo>
182
680
  getNetworkInfo(): Promise<NetworkInfo>
183
681
  ```
184
682
 
185
- ### Navigation & screens — **native-only**
683
+ ### Identity both platforms
186
684
 
187
685
  ```typescript
188
- attachNavigation(navigationRef): Promise<void> // React Navigation container ref → auto route tracking
189
- trackRoute(from: string, to: string): Promise<void>
190
- screenStart(name: string): Promise<void> // → navigation
191
- screenEnd(name: string): Promise<void> // → screen.duration (dwell ms)
686
+ identify(profile: {
687
+ userId?: string;
688
+ name?: string;
689
+ email?: string;
690
+ phone?: string;
691
+ avatar?: string;
692
+ customAttributes?: Record<string, any>;
693
+ }): Promise<void> // the only method that puts a profile on the wire
694
+
695
+ setUserId(id: string): Promise<void> // "" clears it — no empty string is ever sent
696
+ getUserProfile(): Promise<UserProfile | undefined>
697
+ clearUserProfile(): Promise<void> // clears user.id; leaves device.id alone
698
+
699
+ // State-only. These record a profile but emit nothing — call identify() to send it.
700
+ setUserProfile(profile): Promise<void>
701
+ setUserDetails(details): Promise<void>
702
+ updateUserProfile(updates): Promise<void>
703
+ setUserName(fullName: string, firstName?: string, lastName?: string): Promise<void>
704
+ setUserContact(email?: string, phone?: string): Promise<void>
192
705
  ```
193
706
 
194
- ```typescript
195
- import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
707
+ > `fullName`, `firstName`, `lastName`, `avatar`, `createdAt` and `updatedAt` are `@deprecated` and
708
+ > go away in v5. `fullName` still works — it's what `user.name` is built from. The other five
709
+ > reach no wire key at all.
196
710
 
197
- const navRef = useNavigationContainerRef();
198
- // after the container mounts:
199
- await telemetry.attachNavigation(navRef);
711
+ ### Native only
712
+
713
+ ```typescript
714
+ trackTap(name: string): Promise<void> // → ui.interaction. Never rejects.
715
+ trackMemoryUsage(): Promise<void> // auto-started
716
+ screenStart(name: string): Promise<void> // → navigation (deprecated feed)
717
+ screenEnd(name: string): Promise<void> // → screen.duration (deprecated feed)
718
+ trackRoute(from: string, to: string): Promise<void> // (deprecated feed)
200
719
  ```
201
720
 
202
- On **web**, navigation is auto-tracked (History API)no wiring needed.
721
+ Prefer `attachNavigation()` over `screenStart` / `screenEnd` / `trackRoute` those three feed
722
+ `navigation` and `screen.duration`, both superseded by the `view` event.
723
+
724
+ ### Web only
725
+
726
+ ```typescript
727
+ trackInteractions(): Promise<void> // auto-started
728
+ autoTrackNavigation(): Promise<void> // auto-started
729
+ ```
203
730
 
204
- ### User interactions (taps) **native-only, best-effort**
731
+ `trackFrameDrops()`, `trackNetworkRequests()` and `attachAppLifecycle()` exist on both classes and
732
+ are auto-started too. Core Web Vitals capture is intentionally private — there is nothing to
733
+ configure, and a vital that only fires when someone remembers to call a method is a vital nobody
734
+ has.
205
735
 
206
- Native has no global tap stream, so the SDK gives you responder props to spread on your app root.
207
- Each tap emits `user.interaction` with `interaction.type` and the current screen when known. It
208
- observes the gesture without stealing it (no DOM `target_tag`/`target_class` — those are web-only).
736
+ ### Exports
209
737
 
210
- ```tsx
211
- function Root() {
212
- const [props, setProps] = React.useState({});
213
- React.useEffect(() => { telemetry.interactionProps().then(setProps); }, []);
214
- return <View style={{ flex: 1 }} {...props}><App /></View>;
215
- }
738
+ ```typescript
739
+ import {
740
+ createTelemetry, // the factory — start here
741
+ memoryStore, // an in-memory Store, for tests
742
+ type TelemetryOpts,
743
+ type BeforeSend,
744
+ type Store, type SyncStore, type AsyncStore, type StoreRead, type StoreWrite,
745
+ type MemoryStoreOpts,
746
+ } from "@nathanclaire/edge-telemetry-sdk";
216
747
  ```
217
748
 
749
+ `TelemetryWeb` and `TelemetryNative` are also exported, but **use `createTelemetry()`** — it is
750
+ the only path that validates your `apiKey` and your trace allowlist. Constructing a class
751
+ directly skips both checks.
752
+
753
+ > `TelemetryEvent`, `DeviceInfo`, `NetworkInfo` and `UserProfile` are documented in this README as
754
+ > wire and return shapes, but are **not currently exported as types**. For a `beforeSend` hook,
755
+ > annotate with the exported `BeforeSend` and let the event type infer.
756
+
218
757
  ---
219
758
 
220
- ## What gets captured
759
+ ## The Context block
221
760
 
222
- Auto-started in the constructor (both platforms unless noted):
761
+ Every event and metric carries a flat, dot-namespaced set of attributes describing *who, where and
762
+ when*. It means each row is self-describing — you never join against a separate device or session
763
+ event.
223
764
 
224
- | Signal | `eventName` / `metricName` | Type |
225
- |---|---|---|
226
- | Session start / end | `session.started`, `session.finalized` | event |
227
- | App foreground/background | `app_lifecycle` | event |
228
- | Route change / screen entry | `navigation` | event |
229
- | Screen dwell time | `screen.duration` | event |
230
- | HTTP request (fetch/XHR) | `http.request` | event |
231
- | Connectivity change | `network_change` | event |
232
- | JS error / crash / (opt-out) console errors | `app.crash` | event |
233
- | Identity update via `identify()` | `user.profile.update` | event |
234
- | Tap (native, best-effort) | `user.interaction` | event |
235
- | Custom `log()` name (non-allowlisted) | `custom_event` | event |
236
- | Memory sample | `memory_usage` | metric |
237
- | Frame render window | `frame_render_time` | metric |
238
-
239
- `app.crash` carries a `cause` discriminator and `crash.breadcrumbs` (last 20 actions,
240
- JSON-stringified). Sessions rotate after 30 minutes of inactivity; `session.finalized` flushes
241
- immediately and includes a journey summary + `sdk.error_count`.
242
-
243
- **Web-only signals** (`page_load`, `resource_timing`, `long_task`, and Web Vitals
244
- LCP/FCP/CLS/INP/TTFB) are emitted only by the web build — native never reports metrics it can't
245
- honestly measure.
765
+ ```
766
+ session.id session.start_time session.sequence session.sample_rate event.sequence
767
+ user.id device.id device.id_ephemeral
768
+ view.id view.name
769
+ sdk.platform sdk.version sdk.events_dropped sdk.drop_reason sdk.hook_dropped sdk.hook_failed
770
+ app.name app.version app.build_number app.package_name app.build_id
771
+ device.platform device.platform_version device.model device.manufacturer device.brand
772
+ device.screen_density device.screen_width_px device.screen_height_px device.orientation
773
+ device.cpu_abi device.low_ram ← native only
774
+ network.type network.is_connected
775
+ ```
776
+
777
+ It is **frozen at 39 keys**. A key the SDK has nothing for is **omitted entirely** — never sent as
778
+ `null` or `undefined`. Absence means "we had nothing", consistently, everywhere.
779
+
780
+ Your `log()` data is flattened on top, so it can override any `app.*`, `device.*` or `network.*`
781
+ key but **not** the identity keys, which are stamped afterwards.
782
+
783
+ **Network and device state are read at log time**, not at init: a request that failed when the
784
+ Wi-Fi dropped is better described by the network at the moment it completed.
246
785
 
247
786
  ---
248
787
 
249
788
  ## Wire format
250
789
 
251
- ```
252
- POST <endpoint>
790
+ ```http
791
+ POST /telemetry HTTP/1.1
253
792
  Content-Type: application/json
254
793
  X-API-Key: edge_xxxxxxxx
794
+ Authorization: Bearer edge_xxxxxxxx
795
+ ```
255
796
 
797
+ ```json
256
798
  {
257
799
  "type": "telemetry_batch",
258
- "timestamp": "2026-07-02T06:00:00.000Z", // ISO 8601
800
+ "timestamp": "2026-07-02T06:00:00.000Z",
259
801
  "batch_size": 3,
260
- "events": [ /* TelemetryEvent, ... */ ]
802
+ "events": [ /* */ ]
261
803
  }
262
804
  ```
263
805
 
264
- Each event/metric:
806
+ Each entry:
265
807
 
266
808
  ```typescript
267
809
  type TelemetryEvent = {
268
810
  type: "event" | "metric";
269
- eventName?: string; // events
270
- metricName?: string; // metrics
271
- value?: number; // metrics
272
- timestamp: string; // ISO 8601
273
- attributes?: Record<string, any>; // flat, dot-namespaced includes the Context block
811
+ eventName?: string; // events
812
+ metricName?: string; // metrics
813
+ value?: number; // metrics
814
+ timestamp: string; // ISO 8601, never a millisecond epoch
815
+ attributes?: Record<string, any>; // flat, dot-namespaced, includes the Context block
274
816
  };
275
817
  ```
276
818
 
277
- Every record carries the flattened **Context block** in `attributes`: `app.*`, `device.*`,
278
- `network.*`, `session.*`, `user.id`, and `sdk.*` (`sdk.platform = "react-native"`,
279
- `sdk.version`). This makes each record self-describing and joinable without correlating against
280
- separate context events.
819
+ **Both credential headers are always sent, with the same value.** A shared collector reads
820
+ `X-API-Key`; a segmented one running `AUTH_MODE=jwt` reads `Authorization`. One build serves
821
+ both, so nothing has to sniff the credential's shape.
281
822
 
282
823
  ---
283
824
 
284
- ## Identity & IDs
285
-
286
- IDs use 16 hex chars of entropy; device/session are suffixed with the device OS, user is not:
825
+ ## Identity and IDs
287
826
 
288
827
  ```
289
- device_{ms}_{16hex}_{ios|android}
290
- session_{ms}_{16hex}_{ios|android}
291
- user_{ms}_{16hex}
828
+ device_{ms}_{16 hex}_{ios|android|web}
829
+ session_{ms}_{16 hex}_{ios|android|web}
292
830
  ```
293
831
 
294
- Conform to `^(session|device|user)_\d+_[0-9a-f]{16}(_(ios|android))?$`. The **web build** omits
295
- the OS suffix (`device.platform = "web"` still rides as an attribute).
832
+ Entropy comes from `crypto.getRandomValues`. There is no `Math.random()` fallback a weak ID
833
+ that persists forever is worse than refusing to mint one.
296
834
 
297
- `identify()` attaches host-app identity (`user.name`/`email`/`phone`) to subsequent events and
298
- emits one `user.profile.update` — it never changes the SDK-owned anonymous `user.id`.
835
+ **`device.id` is the SDK's. `user.id` is yours.**
836
+
837
+ | | `device.id` | `user.id` |
838
+ |---|---|---|
839
+ | Minted by | The SDK, once | You, via `setUserId()` / `identify({ userId })` |
840
+ | Rotates | **Never** — not on `identify()`, not on logout | Whenever you set it |
841
+ | Present | Always | Only after you set one |
842
+ | Answers | Anonymous reach | Known-user reach |
843
+
844
+ Because `device.id` survives the login transition, `GROUP BY device.id` stitches a user's
845
+ anonymous and signed-in sessions together.
846
+
847
+ **When storage is unavailable** — incognito, a partitioned iframe, Safari ITP eviction, a full
848
+ disk — `device.id` lives for one process only and the row carries `device.id_ephemeral: true`.
849
+ Exclude that population from device counts; without the flag, a set of users who never return
850
+ inflates `COUNT(DISTINCT device.id)` and reads as growth.
851
+
852
+ ### Sessions
853
+
854
+ A session ends after **30 minutes of inactivity** or **4 hours** total, whichever comes first.
855
+
856
+ The session record is persisted, so **process death, a tab close, a hard reload and a bfcache
857
+ restore all resume the same session** if the gap is inside the idle window. A resumed session
858
+ deliberately does *not* re-emit `session.started` — otherwise `COUNT(session.started)` would stop
859
+ equalling your session count.
299
860
 
300
861
  ---
301
862
 
302
- ## Reliability
863
+ ## Delivery and reliability
864
+
865
+ | | Behaviour |
866
+ |---|---|
867
+ | **Batching** | Flushes at `batchSize` events or every `flushIntervalMs`, whichever comes first |
868
+ | **Retry** | 3 attempts — exponential backoff with jitter on native, linear on web |
869
+ | **Offline queue** | After the last retry, the batch is written to `telemetry_failed_events` and replayed on the next init |
870
+ | **Caps** | 500 events in memory, 500 events / 1 MB on disk. Both drop oldest first, and **`app.crash` is evicted last** |
871
+ | **Crashes** | Don't wait for the batch — the queue is persisted and one batch sent immediately, crash first |
872
+ | **Page unload** | The web sender uses `fetch({ keepalive: true })`, so in-flight batches outlive the document |
873
+
874
+ Anything dropped is reported on the wire as `sdk.events_dropped` and `sdk.drop_reason`, so loss is
875
+ visible rather than silent.
303
876
 
304
- - **Batching:** events queue and flush when `batchSize` is reached or every `flushIntervalMs`.
305
- - **Retry:** failed sends retry with exponential backoff + jitter.
306
- - **Persisted replay:** after final failure, batches are persisted (AsyncStorage on native,
307
- `localStorage` on web, key `telemetry_failed_events`) and replayed on next init telemetry
308
- survives transient network loss.
309
- - **Web unload:** the web sender uses `fetch({ keepalive: true })` (not `sendBeacon`, which can't
310
- set the required `X-API-Key` header) so in-flight batches survive page unload.
877
+ **Crashes are evicted last on purpose.** Evicting them first would make your crash-free rate look
878
+ *better* the worse the user's network was.
879
+
880
+ > **Note — `flush()` sends one batch per call.** It does not loop. A large backlog still drains
881
+ > one batch per interval.
311
882
 
312
883
  ---
884
+ ## Troubleshooting
313
885
 
314
- ## Debugging
886
+ Start by setting `debug: true` and calling `await telemetry.flush()`. Almost everything below
887
+ shows up in that output.
315
888
 
316
- The SDK is **silent by default**. To see its internal diagnostics while developing:
889
+ ### Nothing arrives at all
317
890
 
318
- ```typescript
319
- createTelemetry({ apiKey: "edge_xxx", endpoint: "...", debug: true });
320
- ```
891
+ | Check | How |
892
+ |---|---|
893
+ | Is the endpoint right? | The collector terminates **`POST /telemetry`**. Leave `endpoint` unset and every send goes to a placeholder host that resolves nowhere. |
894
+ | Did `createTelemetry()` throw? | An `apiKey` not starting with `edge_` throws synchronously, at startup. |
895
+ | Is the session sampled out? | With `sessionSampleRate < 1`, a sampled-out session sends **nothing**, crashes included. |
896
+ | Is `beforeSend` eating events? | A hook that throws drops the event. Check `sdk.hook_failed` on the rows that do arrive. |
897
+ | Did you flush? | Without `flush()` the first batch waits for 50 events or 30 seconds. |
898
+
899
+ ### Events arrive but a dashboard is empty
900
+
901
+ Your event name is probably being rewritten. Only [allowlisted names](#what-the-sdk-captures)
902
+ travel as themselves; everything else arrives as `custom_event` with your name in
903
+ `attributes["event.name"]`. Query on `event.name` for custom events.
321
904
 
322
- `debug: true` routes the SDK's `log`/`warn`/`error` to the console; with it off (the default),
323
- the SDK writes nothing to the host app's console.
905
+ If the name *is* allowlisted and the column is still empty, the backend may not have the name
906
+ enabled on its own ingest allowlist yet unlisted names are dropped on arrival. That is a
907
+ backend configuration question, not an SDK one.
908
+
909
+ ### Common surprises
910
+
911
+ | Symptom | Cause |
912
+ |---|---|
913
+ | Profile data stopped arriving after upgrading to v4 | `setUserProfile()` and friends are state-only now. Call `identify()`. |
914
+ | "Our app broke when we enabled tracing" | The host is missing `Access-Control-Allow-Headers: traceparent`. |
915
+ | Crash stacks are single letters | Set `buildId` and `Error.stackTraceLimit = 50`. See [Make crash stacks readable](#make-crash-stacks-readable). |
916
+ | `device.id` changes every launch | Storage is unavailable. Look for `device.id_ephemeral: true` on the rows. |
917
+ | No `http.request` events from axios | Should work — axios rides XHR, and XHR is the SDK's interception point on both builds. If it doesn't, check that the request isn't going to your collector's own endpoint, which is excluded by design. |
918
+ | No Core Web Vitals on native | Correct — they're web-only, by design. |
919
+ | No `memory_usage` on web | Correct — it's native-only, by design. |
920
+ | No dead-click data on native | Correct — no DOM, so no mutation signal. Filter dead-click rates to web. |
921
+ | `screen.duration` never fires | You're using `attachNavigation()`. Use the `view` event instead — it supersedes both deprecated feeds. |
922
+ | A screen's name is stuck on an old value | You mixed `screenStart()` with `attachNavigation()`. An explicit name outranks a route name and pins the view. |
923
+ | `view.loading_time` is null | Read `view.loading_time_outcome` — it tells you which of the three reasons applies. |
924
+
925
+ ### Requests the SDK can't see
926
+
927
+ Interception covers JavaScript `fetch` and `XHR`. It does **not** see `rn-fetch-blob`, React
928
+ Native Firebase, native Apollo links, `expo-file-system`, `Image` loading or WebSockets. A screen
929
+ whose real wait is one of those reports `no_activity`.
930
+
931
+ ---
932
+
933
+ ## Upgrading
934
+
935
+ ### To v4
936
+
937
+ **Read [`docs/migration-v4.md`](docs/migration-v4.md) before you deploy.** It ships inside the npm
938
+ package, not just in the repo.
939
+
940
+ Of the twenty behavioural changes between 3.1.0 and v4, **exactly one breaks a build** — the
941
+ `getDeviceInfo()` / `getNetworkInfo()` return shapes. The other nineteen are silent: no compile
942
+ error, no runtime error, no log line. They surface as a saved filter returning zero rows, or a
943
+ chart stepping at the release boundary. That is precisely why the note exists.
944
+
945
+ **The one action item most apps have:** if you call `setUserProfile()`, `setUserDetails()`,
946
+ `updateUserProfile()`, `setUserName()` or `setUserContact()` and never call `identify()`, your
947
+ profile data no longer reaches the wire. Add one `identify()` call.
948
+
949
+ The migration note also lists **the columns React Native will never write** — ANR, hangs,
950
+ battery, `app.exit`, native crashes — so nobody builds a panel that stays empty forever.
951
+
952
+ ### To v3
953
+
954
+ v3 was a single clean break onto the EdgeRum wire contract: `apiKey` became required, the POST
955
+ body became the `telemetry_batch` envelope, timestamps became ISO-8601, device and network data
956
+ moved onto every event as the Context block, and several event names changed. Full list in
957
+ [CHANGELOG.md](./CHANGELOG.md).
324
958
 
325
959
  ---
326
960
 
327
961
  ## Development
328
962
 
329
963
  ```bash
964
+ npm install
330
965
  npm run build # vite build → dist/ (web + native entries, ESM + CJS)
331
966
  npm run dev # vite in watch mode
332
967
  npm test # vitest run
@@ -334,15 +969,25 @@ npm run typecheck # tsc --noEmit
334
969
  npm run clean # remove dist/
335
970
  ```
336
971
 
337
- `dist/` and `*.tgz` are git-ignored `dist/` is regenerated by the build and published via the
338
- `package.json` `files` list. `prepare` runs the build, so installing this as a git dependency
339
- builds it automatically.
972
+ `prepare` runs the build, so installing this as a git dependency builds it automatically. `dist/`
973
+ and `*.tgz` are git-ignored.
974
+
975
+ **Platform code is split by filename**, not by runtime branching: `*.native.ts` and `*.web.ts`,
976
+ with the bundler choosing. Shared logic lives in a plain `.ts` and is tested once, which is how
977
+ the two builds stay in lockstep. When you add a capability, add both halves.
978
+
979
+ > One test (`src/vitals.native.test.ts`) reads `dist/` to prove the web-vitals dependency
980
+ > contributes zero bytes to the native bundle. It is red after `npm run clean` until the next
981
+ > build. That's deliberate — a source-graph proxy would pass a bundler change that a real chunk
982
+ > graph catches.
340
983
 
341
- Platform code is split by filename (`*.native.ts` / `*.web.ts`), not by runtime branching — the
342
- bundler selects the file. When adding a capability, keep the native and web adapters in lockstep.
984
+ Architecture, conventions and the reasoning behind the design decisions live in
985
+ [`CLAUDE.md`](./CLAUDE.md). The wire contract every key, its type and its null discipline — is
986
+ [`docs/backend-wire-contract.md`](./docs/backend-wire-contract.md), and it outranks everything
987
+ else where they disagree.
343
988
 
344
989
  ---
345
990
 
346
991
  ## License
347
992
 
348
- ISC
993
+ MIT