expo-observe 56.0.7 → 56.0.8

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 (60) hide show
  1. package/android/src/main/java/expo/modules/observe/OpenTelemetry.kt +2 -1
  2. package/build/integrations/expo-router/ObserveRouterIntegrationProvider.d.ts.map +1 -1
  3. package/build/integrations/expo-router/ObserveRouterIntegrationProvider.js +6 -5
  4. package/build/integrations/expo-router/ObserveRouterIntegrationProvider.js.map +1 -1
  5. package/build/integrations/expo-router/init.d.ts.map +1 -1
  6. package/build/integrations/expo-router/init.js +11 -5
  7. package/build/integrations/expo-router/init.js.map +1 -1
  8. package/expo-module.config.json +1 -1
  9. package/ios/CursorRepair.swift +55 -0
  10. package/ios/Event.swift +61 -45
  11. package/ios/Observability.swift +96 -94
  12. package/ios/ObserveUserDefaults.swift +13 -13
  13. package/ios/OpenTelemetry.swift +29 -18
  14. package/ios/Tests/CursorRepairTests.swift +94 -0
  15. package/ios/Tests/OTAnyValueTests.swift +37 -5
  16. package/ios/Tests/OpenTelemetryTests.swift +30 -1
  17. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7-sources.jar → 56.0.8/expo.modules.observe-56.0.8-sources.jar} +0 -0
  18. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8-sources.jar.md5 +1 -0
  19. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8-sources.jar.sha1 +1 -0
  20. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8-sources.jar.sha256 +1 -0
  21. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8-sources.jar.sha512 +1 -0
  22. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.aar → 56.0.8/expo.modules.observe-56.0.8.aar} +0 -0
  23. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.aar.md5 +1 -0
  24. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.aar.sha1 +1 -0
  25. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.aar.sha256 +1 -0
  26. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.aar.sha512 +1 -0
  27. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.module → 56.0.8/expo.modules.observe-56.0.8.module} +23 -23
  28. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.module.md5 +1 -0
  29. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.module.sha1 +1 -0
  30. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.module.sha256 +1 -0
  31. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.module.sha512 +1 -0
  32. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.pom → 56.0.8/expo.modules.observe-56.0.8.pom} +2 -2
  33. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.pom.md5 +1 -0
  34. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.pom.sha1 +1 -0
  35. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.pom.sha256 +1 -0
  36. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.8/expo.modules.observe-56.0.8.pom.sha512 +1 -0
  37. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml +4 -4
  38. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.md5 +1 -1
  39. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha1 +1 -1
  40. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha256 +1 -1
  41. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha512 +1 -1
  42. package/package.json +4 -4
  43. package/src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx +5 -4
  44. package/src/integrations/expo-router/init.ts +12 -5
  45. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.md5 +0 -1
  46. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha1 +0 -1
  47. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha256 +0 -1
  48. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha512 +0 -1
  49. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.md5 +0 -1
  50. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha1 +0 -1
  51. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha256 +0 -1
  52. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha512 +0 -1
  53. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.md5 +0 -1
  54. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha1 +0 -1
  55. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha256 +0 -1
  56. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha512 +0 -1
  57. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.md5 +0 -1
  58. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.sha1 +0 -1
  59. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.sha256 +0 -1
  60. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.sha512 +0 -1
@@ -191,7 +191,8 @@ private val metricNameMap = mapOf(
191
191
  (MetricCategory.Updates.categoryName to "updateDownloadTime") to "expo.updates.download_time",
192
192
 
193
193
  // Navigation
194
- (MetricCategory.Navigation.categoryName to "ttr") to "expo.navigation.ttr",
194
+ (MetricCategory.Navigation.categoryName to "cold_ttr") to "expo.navigation.cold_ttr",
195
+ (MetricCategory.Navigation.categoryName to "warm_ttr") to "expo.navigation.warm_ttr",
195
196
  (MetricCategory.Navigation.categoryName to "tti") to "expo.navigation.tti"
196
197
  )
197
198
 
@@ -1 +1 @@
1
- {"version":3,"file":"ObserveRouterIntegrationProvider.d.ts","sourceRoot":"","sources":["../../../src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,iBAAiB,EAA+B,MAAM,OAAO,CAAC;AAI3F,OAAO,EAAkC,KAAK,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAE1F,eAAO,MAAM,+BAA+B,0DAAuD,CAAC;AAEpG,wBAAgB,gCAAgC,CAAC,EAAE,QAAQ,EAAE,EAAE,iBAAiB,2CAsB/E"}
1
+ {"version":3,"file":"ObserveRouterIntegrationProvider.d.ts","sourceRoot":"","sources":["../../../src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,iBAAiB,EAA+B,MAAM,OAAO,CAAC;AAI3F,OAAO,EAAkC,KAAK,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAE1F,eAAO,MAAM,+BAA+B,0DAAuD,CAAC;AAEpG,wBAAgB,gCAAgC,CAAC,EAAE,QAAQ,EAAE,EAAE,iBAAiB,2CAuB/E"}
@@ -6,15 +6,16 @@ import { createRouterIntegrationStorage } from './storage';
6
6
  export const ObserveRouterIntegrationContext = createContext(null);
7
7
  export function ObserveRouterIntegrationProvider({ children }) {
8
8
  const [storage] = useState(() => isInitialized() ? createRouterIntegrationStorage() : null);
9
+ const [listenersCleanup] = useState(() => {
10
+ if (!storage || !optionalRouter)
11
+ return;
12
+ return initListeners(storage, optionalRouter.unstable_navigationEvents);
13
+ });
9
14
  const prevInitialized = useRef(isInitialized());
10
15
  if (prevInitialized.current !== isInitialized()) {
11
16
  throw new Error(`[expo-observe] Router integration was ${isInitialized() ? 'enabled' : 'disabled'} after application mounted. Call ExpoObserve.configure() before mounting AppMetricsRoot.`);
12
17
  }
13
- useEffect(() => {
14
- if (!storage || !optionalRouter)
15
- return;
16
- return initListeners(storage, optionalRouter.unstable_navigationEvents);
17
- }, [storage]);
18
+ useEffect(() => listenersCleanup, [listenersCleanup]);
18
19
  return (_jsx(ObserveRouterIntegrationContext.Provider, { value: storage, children: children }));
19
20
  }
20
21
  //# sourceMappingURL=ObserveRouterIntegrationProvider.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ObserveRouterIntegrationProvider.js","sourceRoot":"","sources":["../../../src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAA0B,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE3F,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,8BAA8B,EAAiC,MAAM,WAAW,CAAC;AAE1F,MAAM,CAAC,MAAM,+BAA+B,GAAG,aAAa,CAAkC,IAAI,CAAC,CAAC;AAEpG,MAAM,UAAU,gCAAgC,CAAC,EAAE,QAAQ,EAAqB;IAC9E,MAAM,CAAC,OAAO,CAAC,GAAG,QAAQ,CAAkC,GAAG,EAAE,CAC/D,aAAa,EAAE,CAAC,CAAC,CAAC,8BAA8B,EAAE,CAAC,CAAC,CAAC,IAAI,CAC1D,CAAC;IAEF,MAAM,eAAe,GAAG,MAAM,CAAC,aAAa,EAAE,CAAC,CAAC;IAChD,IAAI,eAAe,CAAC,OAAO,KAAK,aAAa,EAAE,EAAE,CAAC;QAChD,MAAM,IAAI,KAAK,CACb,yCAAyC,aAAa,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,0FAA0F,CAC5K,CAAC;IACJ,CAAC;IAED,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,OAAO,IAAI,CAAC,cAAc;YAAE,OAAO;QACxC,OAAO,aAAa,CAAC,OAAO,EAAE,cAAc,CAAC,yBAAyB,CAAC,CAAC;IAC1E,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;IAEd,OAAO,CACL,KAAC,+BAA+B,CAAC,QAAQ,IAAC,KAAK,EAAE,OAAO,YACrD,QAAQ,GACgC,CAC5C,CAAC;AACJ,CAAC","sourcesContent":["import { createContext, type PropsWithChildren, useEffect, useRef, useState } from 'react';\n\nimport { initListeners, isInitialized } from './init';\nimport { optionalRouter } from './router';\nimport { createRouterIntegrationStorage, type RouterIntegrationStorage } from './storage';\n\nexport const ObserveRouterIntegrationContext = createContext<RouterIntegrationStorage | null>(null);\n\nexport function ObserveRouterIntegrationProvider({ children }: PropsWithChildren) {\n const [storage] = useState<RouterIntegrationStorage | null>(() =>\n isInitialized() ? createRouterIntegrationStorage() : null\n );\n\n const prevInitialized = useRef(isInitialized());\n if (prevInitialized.current !== isInitialized()) {\n throw new Error(\n `[expo-observe] Router integration was ${isInitialized() ? 'enabled' : 'disabled'} after application mounted. Call ExpoObserve.configure() before mounting AppMetricsRoot.`\n );\n }\n\n useEffect(() => {\n if (!storage || !optionalRouter) return;\n return initListeners(storage, optionalRouter.unstable_navigationEvents);\n }, [storage]);\n\n return (\n <ObserveRouterIntegrationContext.Provider value={storage}>\n {children}\n </ObserveRouterIntegrationContext.Provider>\n );\n}\n"]}
1
+ {"version":3,"file":"ObserveRouterIntegrationProvider.js","sourceRoot":"","sources":["../../../src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAA0B,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE3F,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,8BAA8B,EAAiC,MAAM,WAAW,CAAC;AAE1F,MAAM,CAAC,MAAM,+BAA+B,GAAG,aAAa,CAAkC,IAAI,CAAC,CAAC;AAEpG,MAAM,UAAU,gCAAgC,CAAC,EAAE,QAAQ,EAAqB;IAC9E,MAAM,CAAC,OAAO,CAAC,GAAG,QAAQ,CAAkC,GAAG,EAAE,CAC/D,aAAa,EAAE,CAAC,CAAC,CAAC,8BAA8B,EAAE,CAAC,CAAC,CAAC,IAAI,CAC1D,CAAC;IACF,MAAM,CAAC,gBAAgB,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE;QACvC,IAAI,CAAC,OAAO,IAAI,CAAC,cAAc;YAAE,OAAO;QACxC,OAAO,aAAa,CAAC,OAAO,EAAE,cAAc,CAAC,yBAAyB,CAAC,CAAC;IAC1E,CAAC,CAAC,CAAC;IAEH,MAAM,eAAe,GAAG,MAAM,CAAC,aAAa,EAAE,CAAC,CAAC;IAChD,IAAI,eAAe,CAAC,OAAO,KAAK,aAAa,EAAE,EAAE,CAAC;QAChD,MAAM,IAAI,KAAK,CACb,yCAAyC,aAAa,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,0FAA0F,CAC5K,CAAC;IACJ,CAAC;IAED,SAAS,CAAC,GAAG,EAAE,CAAC,gBAAgB,EAAE,CAAC,gBAAgB,CAAC,CAAC,CAAC;IAEtD,OAAO,CACL,KAAC,+BAA+B,CAAC,QAAQ,IAAC,KAAK,EAAE,OAAO,YACrD,QAAQ,GACgC,CAC5C,CAAC;AACJ,CAAC","sourcesContent":["import { createContext, type PropsWithChildren, useEffect, useRef, useState } from 'react';\n\nimport { initListeners, isInitialized } from './init';\nimport { optionalRouter } from './router';\nimport { createRouterIntegrationStorage, type RouterIntegrationStorage } from './storage';\n\nexport const ObserveRouterIntegrationContext = createContext<RouterIntegrationStorage | null>(null);\n\nexport function ObserveRouterIntegrationProvider({ children }: PropsWithChildren) {\n const [storage] = useState<RouterIntegrationStorage | null>(() =>\n isInitialized() ? createRouterIntegrationStorage() : null\n );\n const [listenersCleanup] = useState(() => {\n if (!storage || !optionalRouter) return;\n return initListeners(storage, optionalRouter.unstable_navigationEvents);\n });\n\n const prevInitialized = useRef(isInitialized());\n if (prevInitialized.current !== isInitialized()) {\n throw new Error(\n `[expo-observe] Router integration was ${isInitialized() ? 'enabled' : 'disabled'} after application mounted. Call ExpoObserve.configure() before mounting AppMetricsRoot.`\n );\n }\n\n useEffect(() => listenersCleanup, [listenersCleanup]);\n\n return (\n <ObserveRouterIntegrationContext.Provider value={storage}>\n {children}\n </ObserveRouterIntegrationContext.Provider>\n );\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../src/integrations/expo-router/init.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,KAAK,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAS1D,eAAO,MAAM,aAAa,eAAoB,CAAC;AAE/C,wBAAgB,qBAAqB,SAGpC;AAED,KAAK,gBAAgB,GAAG,WAAW,CAAC,OAAO,cAAc,CAAC,CAAC,2BAA2B,CAAC,CAAC;AAExF,wBAAgB,aAAa,CAC3B,OAAO,EAAE,wBAAwB,EACjC,gBAAgB,EAAE,gBAAgB,GACjC,MAAM,IAAI,CAyEZ"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../src/integrations/expo-router/init.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,KAAK,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAS1D,eAAO,MAAM,aAAa,eAAoB,CAAC;AAE/C,wBAAgB,qBAAqB,SAGpC;AAED,KAAK,gBAAgB,GAAG,WAAW,CAAC,OAAO,cAAc,CAAC,CAAC,2BAA2B,CAAC,CAAC;AAExF,wBAAgB,aAAa,CAC3B,OAAO,EAAE,wBAAwB,EACjC,gBAAgB,EAAE,gBAAgB,GACjC,MAAM,IAAI,CAgFZ"}
@@ -15,7 +15,6 @@ export function initListeners(storage, navigationEvents) {
15
15
  const appLaunchTime = performance.now();
16
16
  const cleanup = new Set();
17
17
  const unsubscribeAction = navigationEvents.addListener('actionDispatched', (event) => {
18
- // TODO(@ubax): Handle screen preloading
19
18
  // PRELOAD comes from router.prefetch() — a route warm-up, not a user
20
19
  // navigation — so it must not seed dispatchTime.
21
20
  if (event.actionType === 'PRELOAD')
@@ -26,6 +25,12 @@ export function initListeners(storage, navigationEvents) {
26
25
  });
27
26
  });
28
27
  cleanup.add(unsubscribeAction);
28
+ const unsubscribePreload = navigationEvents.addListener('pagePreloaded', (e) => {
29
+ // The screen rendered as part of a preload. Mark it as already rendered so
30
+ // the eventual `pageFocused` resolves to `warm_ttr` rather than `cold_ttr`.
31
+ storage.renderedScreensIds.add(e.screenId);
32
+ });
33
+ cleanup.add(unsubscribePreload);
29
34
  const unsubscribeFocus = navigationEvents.addListener('pageFocused', async (e) => {
30
35
  // Snapshot both clocks once so every metric written below is stamped with
31
36
  // the moment the focus event fired, not the moment `addCustomMetricToSession`
@@ -34,6 +39,7 @@ export function initListeners(storage, navigationEvents) {
34
39
  const timestamp = new Date().toISOString();
35
40
  const isInitial = !storage.renderedScreensIds.has(e.screenId);
36
41
  storage.renderedScreensIds.add(e.screenId);
42
+ const name = isInitial ? 'cold_ttr' : 'warm_ttr';
37
43
  const mainSessionId = (await AppMetrics.getMainSession())?.id;
38
44
  if (!mainSessionId) {
39
45
  return;
@@ -46,10 +52,10 @@ export function initListeners(storage, navigationEvents) {
46
52
  sessionId: mainSessionId,
47
53
  timestamp,
48
54
  category: 'navigation',
49
- name: 'ttr',
55
+ name,
50
56
  routeName: e.pathname,
51
57
  value: appLaunchTtrSeconds,
52
- params: { isInitial, isAppLaunch: true, routeParams: e.params },
58
+ params: { isAppLaunch: true, routeParams: e.params },
53
59
  });
54
60
  return;
55
61
  }
@@ -66,10 +72,10 @@ export function initListeners(storage, navigationEvents) {
66
72
  sessionId: mainSessionId,
67
73
  timestamp,
68
74
  category: 'navigation',
69
- name: 'ttr',
75
+ name,
70
76
  routeName: e.pathname,
71
77
  value: (now - dispatchTime) / 1000,
72
- params: { isInitial, isAppLaunch: false, routeParams: e.params },
78
+ params: { isAppLaunch: false, routeParams: e.params },
73
79
  });
74
80
  }
75
81
  storage.pendingActions.length = 0;
@@ -1 +1 @@
1
- {"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/integrations/expo-router/init.ts"],"names":[],"mappings":"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","sourcesContent":["import AppMetrics from 'expo-app-metrics';\n\nimport { optionalRouter } from './router';\nimport { type RouterIntegrationStorage } from './storage';\n\n// TODO(@ubax): split this module into `.native.ts` / `.web.ts` variants so the\n// web bundle doesn't pull in `expo-app-metrics`' native bridge calls. The web\n// version should be an explicit no-op (return a noop cleanup) rather than\n// relying on the web stubs in `expo-app-metrics/module.web.ts`.\n\nlet initialized = false;\n\nexport const isInitialized = () => initialized;\n\nexport function initRouterIntegration() {\n initialized = true;\n optionalRouter?.unstable_navigationEvents.enable();\n}\n\ntype NavigationEvents = NonNullable<typeof optionalRouter>['unstable_navigationEvents'];\n\nexport function initListeners(\n storage: RouterIntegrationStorage,\n navigationEvents: NavigationEvents\n): () => void {\n const appLaunchTime = performance.now();\n const cleanup = new Set<() => void>();\n\n const unsubscribeAction = navigationEvents.addListener('actionDispatched', (event) => {\n // TODO(@ubax): Handle screen preloading\n // PRELOAD comes from router.prefetch() — a route warm-up, not a user\n // navigation — so it must not seed dispatchTime.\n if (event.actionType === 'PRELOAD') return;\n storage.pendingActions.push({\n actionType: event.actionType,\n dispatchTime: performance.now(),\n });\n });\n cleanup.add(unsubscribeAction);\n\n const unsubscribeFocus = navigationEvents.addListener('pageFocused', async (e) => {\n // Snapshot both clocks once so every metric written below is stamped with\n // the moment the focus event fired, not the moment `addCustomMetricToSession`\n // happens to run after the awaited `getMainSession()` round-trip.\n const now = performance.now();\n const timestamp = new Date().toISOString();\n const isInitial = !storage.renderedScreensIds.has(e.screenId);\n storage.renderedScreensIds.add(e.screenId);\n const mainSessionId = (await AppMetrics.getMainSession())?.id;\n if (!mainSessionId) {\n return;\n }\n\n if (!storage.hasRecordedInitialTtr) {\n // Stored in seconds to match the OTel `unit = \"s\"` convention\n const appLaunchTtrSeconds = (now - appLaunchTime) / 1000;\n storage.hasRecordedInitialTtr = true;\n AppMetrics.addCustomMetricToSession({\n sessionId: mainSessionId,\n timestamp,\n category: 'navigation',\n name: 'ttr',\n routeName: e.pathname,\n value: appLaunchTtrSeconds,\n params: { isInitial, isAppLaunch: true, routeParams: e.params },\n });\n return;\n }\n\n if (storage.pendingActions.length === 0) return;\n\n const last = storage.pendingActions[storage.pendingActions.length - 1];\n if (last) {\n const dispatchTime = last.dispatchTime;\n storage.screenTimes[e.screenId] = {\n ...storage.screenTimes[e.screenId],\n dispatchTime,\n };\n\n AppMetrics.addCustomMetricToSession({\n sessionId: mainSessionId,\n timestamp,\n category: 'navigation',\n name: 'ttr',\n routeName: e.pathname,\n value: (now - dispatchTime) / 1000,\n params: { isInitial, isAppLaunch: false, routeParams: e.params },\n });\n }\n storage.pendingActions.length = 0;\n });\n cleanup.add(unsubscribeFocus);\n\n return () => {\n cleanup.forEach((c) => c());\n cleanup.clear();\n };\n}\n"]}
1
+ {"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/integrations/expo-router/init.ts"],"names":[],"mappings":"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","sourcesContent":["import AppMetrics from 'expo-app-metrics';\n\nimport { optionalRouter } from './router';\nimport { type RouterIntegrationStorage } from './storage';\n\n// TODO(@ubax): split this module into `.native.ts` / `.web.ts` variants so the\n// web bundle doesn't pull in `expo-app-metrics`' native bridge calls. The web\n// version should be an explicit no-op (return a noop cleanup) rather than\n// relying on the web stubs in `expo-app-metrics/module.web.ts`.\n\nlet initialized = false;\n\nexport const isInitialized = () => initialized;\n\nexport function initRouterIntegration() {\n initialized = true;\n optionalRouter?.unstable_navigationEvents.enable();\n}\n\ntype NavigationEvents = NonNullable<typeof optionalRouter>['unstable_navigationEvents'];\n\nexport function initListeners(\n storage: RouterIntegrationStorage,\n navigationEvents: NavigationEvents\n): () => void {\n const appLaunchTime = performance.now();\n const cleanup = new Set<() => void>();\n\n const unsubscribeAction = navigationEvents.addListener('actionDispatched', (event) => {\n // PRELOAD comes from router.prefetch() — a route warm-up, not a user\n // navigation — so it must not seed dispatchTime.\n if (event.actionType === 'PRELOAD') return;\n storage.pendingActions.push({\n actionType: event.actionType,\n dispatchTime: performance.now(),\n });\n });\n cleanup.add(unsubscribeAction);\n\n const unsubscribePreload = navigationEvents.addListener('pagePreloaded', (e) => {\n // The screen rendered as part of a preload. Mark it as already rendered so\n // the eventual `pageFocused` resolves to `warm_ttr` rather than `cold_ttr`.\n storage.renderedScreensIds.add(e.screenId);\n });\n cleanup.add(unsubscribePreload);\n\n const unsubscribeFocus = navigationEvents.addListener('pageFocused', async (e) => {\n // Snapshot both clocks once so every metric written below is stamped with\n // the moment the focus event fired, not the moment `addCustomMetricToSession`\n // happens to run after the awaited `getMainSession()` round-trip.\n const now = performance.now();\n const timestamp = new Date().toISOString();\n const isInitial = !storage.renderedScreensIds.has(e.screenId);\n storage.renderedScreensIds.add(e.screenId);\n const name = isInitial ? 'cold_ttr' : 'warm_ttr';\n const mainSessionId = (await AppMetrics.getMainSession())?.id;\n if (!mainSessionId) {\n return;\n }\n\n if (!storage.hasRecordedInitialTtr) {\n // Stored in seconds to match the OTel `unit = \"s\"` convention\n const appLaunchTtrSeconds = (now - appLaunchTime) / 1000;\n storage.hasRecordedInitialTtr = true;\n AppMetrics.addCustomMetricToSession({\n sessionId: mainSessionId,\n timestamp,\n category: 'navigation',\n name,\n routeName: e.pathname,\n value: appLaunchTtrSeconds,\n params: { isAppLaunch: true, routeParams: e.params },\n });\n return;\n }\n\n if (storage.pendingActions.length === 0) return;\n\n const last = storage.pendingActions[storage.pendingActions.length - 1];\n if (last) {\n const dispatchTime = last.dispatchTime;\n storage.screenTimes[e.screenId] = {\n ...storage.screenTimes[e.screenId],\n dispatchTime,\n };\n\n AppMetrics.addCustomMetricToSession({\n sessionId: mainSessionId,\n timestamp,\n category: 'navigation',\n name,\n routeName: e.pathname,\n value: (now - dispatchTime) / 1000,\n params: { isAppLaunch: false, routeParams: e.params },\n });\n }\n storage.pendingActions.length = 0;\n });\n cleanup.add(unsubscribeFocus);\n\n return () => {\n cleanup.forEach((c) => c());\n cleanup.clear();\n };\n}\n"]}
@@ -9,7 +9,7 @@
9
9
  "publication": {
10
10
  "groupId": "expo.modules.observe",
11
11
  "artifactId": "expo.modules.observe",
12
- "version": "56.0.7",
12
+ "version": "56.0.8",
13
13
  "repository": "local-maven-repo"
14
14
  }
15
15
  }
@@ -0,0 +1,55 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoAppMetrics
4
+
5
+ /**
6
+ Resets a dispatch cursor to `-1` if it has fallen past the largest id currently in its source
7
+ table. The cursors live in UserDefaults; their source tables can be wiped from underneath them
8
+ (notably on a schema-version mismatch in `expo-app-metrics`). Without this check the cursor would
9
+ skip every new row until enough accumulated to pass the stale value.
10
+
11
+ - `signalName`: short human-readable label ("metric" / "log") for log messages.
12
+ - `readCursor`: returns the persisted cursor value.
13
+ - `writeCursor`: persists a new cursor value.
14
+ - `readMaxId`: returns the largest id in the source table, or nil when empty.
15
+ */
16
+ @AppMetricsActor
17
+ internal func repairCursorIfStale(
18
+ signalName: String,
19
+ readCursor: () -> Int64,
20
+ writeCursor: (Int64) -> Void,
21
+ readMaxId: () throws -> Int64?
22
+ ) {
23
+ let cursor = readCursor()
24
+ let maxId: Int64?
25
+ do {
26
+ maxId = try readMaxId()
27
+ } catch {
28
+ observeLogger.warn("[Observe] Failed to read max \(signalName) id while repairing cursor: \(error.localizedDescription)")
29
+ return
30
+ }
31
+ if cursor > (maxId ?? -1) {
32
+ observeLogger.info("[Observe] Resetting stale \(signalName) dispatch cursor (was \(cursor), max id is \(maxId.map(String.init) ?? "<empty>"))")
33
+ writeCursor(-1)
34
+ }
35
+ }
36
+
37
+ @AppMetricsActor
38
+ internal func repairMetricCursorIfStale() {
39
+ repairCursorIfStale(
40
+ signalName: "metric",
41
+ readCursor: { ObserveUserDefaults.lastDispatchedMetricId },
42
+ writeCursor: { ObserveUserDefaults.lastDispatchedMetricId = $0 },
43
+ readMaxId: { try AppMetrics.getMaxMetricId() }
44
+ )
45
+ }
46
+
47
+ @AppMetricsActor
48
+ internal func repairLogCursorIfStale() {
49
+ repairCursorIfStale(
50
+ signalName: "log",
51
+ readCursor: { ObserveUserDefaults.lastDispatchedLogId },
52
+ writeCursor: { ObserveUserDefaults.lastDispatchedLogId = $0 },
53
+ readMaxId: { try AppMetrics.getMaxLogId() }
54
+ )
55
+ }
package/ios/Event.swift CHANGED
@@ -1,4 +1,6 @@
1
1
  import ExpoAppMetrics
2
+ import ExpoModulesCore
3
+ import Foundation
2
4
 
3
5
  /**
4
6
  An object representing an event providing some app metrics and the information about the app and the device.
@@ -64,58 +66,72 @@ struct Event: Codable, Sendable {
64
66
  }
65
67
 
66
68
  /**
67
- Creates a new event for EAS, based on the objects from `expo-app-metrics` package.
69
+ Builds an `Event` from a session row plus its metric/log batch. The session row carries all the
70
+ metadata that used to live on `Entry`/`AppInfo`/`DeviceInfo`; metrics and logs are passed
71
+ separately so a partial dispatch (only the rows past a cursor) can still produce a valid event.
68
72
  */
69
- static func create(app: AppInfo, device: DeviceInfo, sessions: [Session], environment: String? = nil) -> Event {
73
+ static func from(session: SessionRow, metrics: [MetricRow], logs: [LogRow]) -> Event {
74
+ let updatesInfo = AppInfo.UpdatesInfo(
75
+ updateId: session.appUpdateId,
76
+ runtimeVersion: session.appUpdateRuntimeVersion,
77
+ requestHeaders: decodeRequestHeaders(session.appUpdateRequestHeaders)
78
+ )
70
79
  return Event(
71
80
  metadata: Metadata(
72
- appName: app.appName,
73
- appIdentifier: app.appId,
74
- appVersion: app.appVersion,
75
- appBuildNumber: app.buildNumber,
76
- appEasBuildId: app.easBuildId,
77
- appUpdatesInfo: app.updatesInfo,
78
-
79
- deviceName: device.modelName,
80
- deviceModel: device.modelIdentifier,
81
- deviceOs: device.systemName,
82
- deviceOsVersion: device.systemVersion,
83
-
84
- reactNativeVersion: app.reactNativeVersion,
85
- expoSdkVersion: app.expoSdkVersion,
81
+ appName: session.appName,
82
+ appIdentifier: session.appIdentifier,
83
+ appVersion: session.appVersion,
84
+ appBuildNumber: session.appBuildNumber,
85
+ appEasBuildId: session.appEasBuildId,
86
+ appUpdatesInfo: updatesInfo.isEmpty ? nil : updatesInfo,
87
+ deviceName: session.deviceName ?? "",
88
+ deviceModel: session.deviceModel ?? "",
89
+ deviceOs: session.deviceOs ?? "",
90
+ deviceOsVersion: session.deviceOsVersion ?? "",
91
+ reactNativeVersion: session.reactNativeVersion ?? "",
92
+ expoSdkVersion: session.expoSdkVersion ?? "",
86
93
  clientVersion: ObserveVersions.clientVersion,
87
-
88
- languageTag: Locale.preferredLanguages.first ?? "en-US",
89
- environment: environment
94
+ languageTag: session.languageTag ?? Locale.preferredLanguages.first ?? "en-US",
95
+ environment: session.environment
90
96
  ),
91
- metrics: sessions.flatMap { session in
92
- return session.metrics.map { metric in
93
- return Metric(
94
- category: metric.category?.rawValue,
95
- name: metric.name,
96
- value: metric.value,
97
- timestamp: metric.timestamp,
98
- sessionId: session.id,
99
- parentSessionId: nil,
100
- routeName: metric.routeName,
101
- updateId: metric.updateId,
102
- customParams: metric.params
103
- )
104
- }
97
+ metrics: metrics.map { metric in
98
+ return Metric(
99
+ category: metric.category,
100
+ name: metric.name,
101
+ value: metric.value,
102
+ timestamp: metric.timestamp,
103
+ sessionId: metric.sessionId,
104
+ parentSessionId: nil,
105
+ routeName: metric.routeName,
106
+ updateId: metric.updateId,
107
+ customParams: decodeCustomParams(metric.params)
108
+ )
105
109
  },
106
- logs: sessions.flatMap { session in
107
- return session.logs.map { log in
108
- return Log(
109
- name: log.name,
110
- body: log.body,
111
- timestamp: log.timestamp,
112
- severity: log.severity,
113
- attributes: log.attributes,
114
- droppedAttributesCount: log.droppedAttributesCount,
115
- sessionId: session.id
116
- )
117
- }
110
+ logs: logs.map { log in
111
+ return Log(
112
+ name: log.name,
113
+ body: log.body,
114
+ timestamp: log.timestamp,
115
+ severity: Severity(rawValue: log.severity) ?? .info,
116
+ attributes: decodeCustomParams(log.attributes),
117
+ droppedAttributesCount: log.droppedAttributesCount,
118
+ sessionId: log.sessionId
119
+ )
118
120
  }
119
121
  )
120
122
  }
121
123
  }
124
+
125
+ private func decodeRequestHeaders(_ json: String?) -> [String: String]? {
126
+ guard let json, let data = json.data(using: .utf8) else {
127
+ return nil
128
+ }
129
+ return try? JSONSerialization.jsonObject(with: data) as? [String: String]
130
+ }
131
+
132
+ private func decodeCustomParams(_ json: String?) -> AnyCodable? {
133
+ guard let json, let data = json.data(using: .utf8) else {
134
+ return nil
135
+ }
136
+ return try? JSONDecoder().decode(AnyCodable.self, from: data)
137
+ }
@@ -10,155 +10,156 @@ internal struct ObservabilityManager {
10
10
  private static var projectId: String? = nil
11
11
  private static var useOpenTelemetry = false
12
12
 
13
- /**
14
- Returns entries from AppMetrics storage whose id is newer than the supplied
15
- cursor.
16
-
17
- The first (current) entry may have a lower id than the cursor when storage
18
- was wiped, empty, or failed to decode — ids restart from 0 in that case.
19
- We return all entries so the cursor can be repaired on the next dispatch.
20
- */
21
- private static func entriesNewerThan(cursor: Int) -> [MetricsStorage.Entry] {
22
- let entries = AppMetrics.storage.getAllEntries()
23
- if let firstEntry = entries.first, firstEntry.id < cursor {
24
- return entries
25
- }
26
- return entries.filter { $0.id > cursor }
27
- }
28
-
29
- /**
30
- Entries whose metrics have not been dispatched yet.
31
- */
32
- internal static func getEntriesToDispatch() -> [MetricsStorage.Entry] {
33
- return entriesNewerThan(cursor: ObserveUserDefaults.lastDispatchedEntryId)
34
- }
35
-
36
- /**
37
- Entries whose logs have not been dispatched yet. Tracked independently from
38
- metrics so the two signals can advance in isolation.
39
- */
40
- internal static func getLogEntriesToDispatch() -> [MetricsStorage.Entry] {
41
- return entriesNewerThan(cursor: ObserveUserDefaults.lastDispatchedLogEntryId)
42
- }
43
-
44
13
  internal static func dispatch() async {
45
- // Compute once and reuse for both signals — `shouldDispatch()` reads the
46
- // persisted config, the bundle defaults, and computes a sample-rate hash.
47
- // Both halves of dispatch want the same answer.
14
+ // Compute once and reuse for both signals — `shouldDispatch()` reads the persisted config, the
15
+ // bundle defaults, and computes a sample-rate hash. Both halves of dispatch want the same answer.
48
16
  let shouldDispatch = Self.shouldDispatch()
49
17
 
50
- // Snapshot every entry as an `Event` once. Both metrics and logs project
51
- // out of the same snapshot, so building it twice would duplicate the
52
- // `Event.create` work for sessions that have both signals pending.
53
- let allEntries = AppMetrics.storage.getAllEntries()
54
- let eventsByEntryId: [Int: Event] = Dictionary(
55
- uniqueKeysWithValues: allEntries.map { entry in
56
- (
57
- entry.id,
58
- Event.create(
59
- app: entry.app, device: entry.device, sessions: entry.sessions,
60
- environment: entry.environment
61
- )
62
- )
63
- }
64
- )
65
-
66
- await dispatchMetrics(eventsByEntryId: eventsByEntryId, shouldDispatch: shouldDispatch)
67
- await dispatchLogs(eventsByEntryId: eventsByEntryId, shouldDispatch: shouldDispatch)
18
+ await dispatchMetrics(shouldDispatch: shouldDispatch)
19
+ await dispatchLogs(shouldDispatch: shouldDispatch)
68
20
  }
69
21
 
70
- private static func dispatchMetrics(
71
- eventsByEntryId: [Int: Event],
72
- shouldDispatch: Bool
73
- ) async {
74
- let entries = getEntriesToDispatch()
22
+ private static func dispatchMetrics(shouldDispatch: Bool) async {
23
+ repairMetricCursorIfStale()
75
24
 
76
- guard !entries.isEmpty, let endpointUrl = metricsEndpointUrl else {
77
- // Nothing to dispatch
78
- observeLogger.debug("[EAS Observe] No new entries to dispatch")
25
+ let cursor = ObserveUserDefaults.lastDispatchedMetricId
26
+ let pendingMetrics: [MetricRow]
27
+ do {
28
+ pendingMetrics = try AppMetrics.getMetrics(afterId: cursor)
29
+ } catch {
30
+ observeLogger.warn("[EAS Observe] Failed to read pending metrics: \(error.localizedDescription)")
31
+ return
32
+ }
33
+ guard !pendingMetrics.isEmpty, let endpointUrl = metricsEndpointUrl else {
34
+ observeLogger.debug("[EAS Observe] No new metrics to dispatch")
79
35
  return
80
36
  }
37
+ let highestId = pendingMetrics.last?.id ?? cursor
81
38
  if !shouldDispatch {
82
- ObserveUserDefaults.lastDispatchedEntryId = entries.first?.id ?? -1
39
+ ObserveUserDefaults.lastDispatchedMetricId = highestId
40
+ return
41
+ }
42
+ let events: [Event]
43
+ do {
44
+ events = try buildEvents(forMetrics: pendingMetrics)
45
+ } catch {
46
+ observeLogger.warn("[EAS Observe] Failed to assemble metric events: \(error.localizedDescription)")
47
+ return
48
+ }
49
+ if events.isEmpty {
50
+ ObserveUserDefaults.lastDispatchedMetricId = highestId
83
51
  return
84
52
  }
85
53
  do {
86
- let events = entries.compactMap { eventsByEntryId[$0.id] }
87
-
88
- if events.isEmpty {
89
- ObserveUserDefaults.lastDispatchedEntryId = entries.first?.id ?? -1
90
- return
91
- }
92
-
93
54
  let body: any Encodable
94
55
  if useOpenTelemetry {
95
- body = OTRequestBody(resourceMetrics: events.map { $0.toOTEvent(easClientId)})
56
+ body = OTRequestBody(resourceMetrics: events.map { $0.toOTEvent(easClientId) })
96
57
  } else {
97
58
  body = RequestBody(easClientId: easClientId, events: events)
98
59
  }
99
-
100
60
  let success = try await sendRequest(to: endpointUrl, body: body)
101
61
  if success {
102
62
  ObserveUserDefaults.lastDispatchDate = Date.now
103
- ObserveUserDefaults.lastDispatchedEntryId = entries.first?.id ?? -1
63
+ ObserveUserDefaults.lastDispatchedMetricId = highestId
104
64
  }
105
65
  } catch {
106
66
  observeLogger.warn("[EAS Observe] Dispatching the metrics has thrown an error: \(error)")
107
67
  }
108
68
  }
109
69
 
110
- private static func dispatchLogs(
111
- eventsByEntryId: [Int: Event],
112
- shouldDispatch: Bool
113
- ) async {
70
+ private static func dispatchLogs(shouldDispatch: Bool) async {
114
71
  // Logs are only sent in OpenTelemetry mode — there is no legacy logs endpoint.
115
72
  guard useOpenTelemetry else {
116
73
  return
117
74
  }
75
+ repairLogCursorIfStale()
118
76
 
119
- let entries = getLogEntriesToDispatch()
120
-
121
- guard !entries.isEmpty, let endpointUrl = logsEndpointUrl else {
122
- observeLogger.debug("[EAS Observe] No new log entries to dispatch")
77
+ let cursor = ObserveUserDefaults.lastDispatchedLogId
78
+ let pendingLogs: [LogRow]
79
+ do {
80
+ pendingLogs = try AppMetrics.getLogs(afterId: cursor)
81
+ } catch {
82
+ observeLogger.warn("[EAS Observe] Failed to read pending logs: \(error.localizedDescription)")
83
+ return
84
+ }
85
+ guard !pendingLogs.isEmpty, let endpointUrl = logsEndpointUrl else {
86
+ observeLogger.debug("[EAS Observe] No new logs to dispatch")
123
87
  return
124
88
  }
89
+ let highestId = pendingLogs.last?.id ?? cursor
125
90
  if !shouldDispatch {
126
- ObserveUserDefaults.lastDispatchedLogEntryId = entries.first?.id ?? -1
91
+ ObserveUserDefaults.lastDispatchedLogId = highestId
127
92
  return
128
93
  }
94
+ let events: [Event]
129
95
  do {
130
- // Skip the request when there's nothing to send, but still advance the cursor so we
131
- // don't keep re-checking the same entries.
132
- let resourceLogs = entries.compactMap { entry -> OTResourceLogs? in
133
- guard let event = eventsByEntryId[entry.id], !event.logs.isEmpty else {
134
- return nil
135
- }
136
- return event.toOTResourceLogs(easClientId)
137
- }
138
- if resourceLogs.isEmpty {
139
- ObserveUserDefaults.lastDispatchedLogEntryId = entries.first?.id ?? -1
140
- return
96
+ events = try buildEvents(forLogs: pendingLogs)
97
+ } catch {
98
+ observeLogger.warn("[EAS Observe] Failed to assemble log events: \(error.localizedDescription)")
99
+ return
100
+ }
101
+ let resourceLogs = events.compactMap { event -> OTResourceLogs? in
102
+ guard !event.logs.isEmpty else {
103
+ return nil
141
104
  }
142
-
105
+ return event.toOTResourceLogs(easClientId)
106
+ }
107
+ if resourceLogs.isEmpty {
108
+ ObserveUserDefaults.lastDispatchedLogId = highestId
109
+ return
110
+ }
111
+ do {
143
112
  let body = OTLogsRequestBody(resourceLogs: resourceLogs)
144
113
  let success = try await sendRequest(to: endpointUrl, body: body)
145
114
  if success {
146
- ObserveUserDefaults.lastDispatchedLogEntryId = entries.first?.id ?? -1
115
+ ObserveUserDefaults.lastDispatchedLogId = highestId
147
116
  }
148
117
  } catch {
149
118
  observeLogger.warn("[EAS Observe] Dispatching the logs has thrown an error: \(error)")
150
119
  }
151
120
  }
152
121
 
122
+ /**
123
+ Groups `metrics` by `sessionId`, hydrates the matching session rows, and emits one `Event` per
124
+ session in the same shape Android dispatches: each event carries the session's metadata and only
125
+ the metrics that belong to it.
126
+ */
127
+ private static func buildEvents(forMetrics metrics: [MetricRow]) throws -> [Event] {
128
+ let metricsBySession = Dictionary(grouping: metrics, by: \.sessionId)
129
+ let sessionIds = Array(metricsBySession.keys)
130
+ let sessions = try AppMetrics.getSessions(ids: sessionIds)
131
+ return sessions.compactMap { session in
132
+ guard let sessionMetrics = metricsBySession[session.id] else {
133
+ return nil
134
+ }
135
+ return Event.from(session: session, metrics: sessionMetrics, logs: [])
136
+ }
137
+ }
138
+
139
+ private static func buildEvents(forLogs logs: [LogRow]) throws -> [Event] {
140
+ let logsBySession = Dictionary(grouping: logs, by: \.sessionId)
141
+ let sessionIds = Array(logsBySession.keys)
142
+ let sessions = try AppMetrics.getSessions(ids: sessionIds)
143
+ return sessions.compactMap { session in
144
+ guard let sessionLogs = logsBySession[session.id] else {
145
+ return nil
146
+ }
147
+ return Event.from(session: session, metrics: [], logs: sessionLogs)
148
+ }
149
+ }
150
+
153
151
  private static func sendRequest(to endpointUrl: URL, body: any Encodable) async throws -> Bool {
154
152
  var request = URLRequest(url: endpointUrl)
155
153
  request.httpMethod = "POST"
156
154
  request.allHTTPHeaderFields = ["Content-Type": "application/json"]
157
155
  request.httpBody = try body.toJSONData([])
158
156
 
157
+ #if DEBUG
159
158
  observeLogger.debug("[EAS Observe] Sending the request to \(endpointUrl) with body:")
160
- // Use `print` so the JSON can be copied without including the log level emojis.
159
+ // Use `print` so the JSON can be copied without including the log level emojis. Wrapped in
160
+ // `#if DEBUG` so release builds don't pay for a second pretty-printed encode of the payload.
161
161
  print(try body.toJSONString(.prettyPrinted))
162
+ #endif
162
163
 
163
164
  let (responseData, urlResponse) = try await URLSession.shared.data(for: request)
164
165
 
@@ -224,3 +225,4 @@ internal struct ObservabilityManager {
224
225
  return EASClientID.deterministicUniformValue(EASClientID.uuid()) < clamped
225
226
  }
226
227
  }
228
+
@@ -35,8 +35,8 @@ internal final class ObserveUserDefaults: UserDefaults {
35
35
  Enum with keys used within this user defaults database.
36
36
  */
37
37
  private enum Keys: String {
38
- case lastDispatchedEntryId
39
- case lastDispatchedLogEntryId
38
+ case lastDispatchedMetricId
39
+ case lastDispatchedLogId
40
40
  case lastDispatchDate
41
41
  case config
42
42
  case bundleDefaults
@@ -65,29 +65,29 @@ internal final class ObserveUserDefaults: UserDefaults {
65
65
  }
66
66
 
67
67
  /**
68
- Id of the last dispatched entry. It is used to prevent dispatching entries multiple times. The ids reflect the order of creation.
69
- Using the creation date is not the best idea as the device's date can be changed by the user or shift along with the timezone.
68
+ Id of the last metric row dispatched. Each successful dispatch advances this past the largest id
69
+ in the batch so the next dispatch reads only newer rows. Auto-increment ids are monotonic in
70
+ SQLite, so a date-independent cursor avoids drift when the device clock changes.
70
71
  */
71
- static var lastDispatchedEntryId: Int {
72
+ static var lastDispatchedMetricId: Int64 {
72
73
  get {
73
- return defaults.object(forKey: Keys.lastDispatchedEntryId.rawValue) as? Int ?? -1
74
+ return (defaults.object(forKey: Keys.lastDispatchedMetricId.rawValue) as? Int64) ?? -1
74
75
  }
75
76
  set {
76
- defaults.set(newValue, forKey: Keys.lastDispatchedEntryId.rawValue)
77
+ defaults.set(newValue, forKey: Keys.lastDispatchedMetricId.rawValue)
77
78
  }
78
79
  }
79
80
 
80
81
  /**
81
- Id of the last entry whose logs were dispatched. Tracked separately from `lastDispatchedEntryId`
82
- so that a logs request failure does not block metrics dispatch (and vice versa) — both signals
83
- move forward independently.
82
+ Id of the last log row dispatched. Tracked separately from the metric cursor so a logs request
83
+ failure does not block metrics dispatch (and vice versa) — both signals move forward independently.
84
84
  */
85
- static var lastDispatchedLogEntryId: Int {
85
+ static var lastDispatchedLogId: Int64 {
86
86
  get {
87
- return defaults.object(forKey: Keys.lastDispatchedLogEntryId.rawValue) as? Int ?? -1
87
+ return (defaults.object(forKey: Keys.lastDispatchedLogId.rawValue) as? Int64) ?? -1
88
88
  }
89
89
  set {
90
- defaults.set(newValue, forKey: Keys.lastDispatchedLogEntryId.rawValue)
90
+ defaults.set(newValue, forKey: Keys.lastDispatchedLogId.rawValue)
91
91
  }
92
92
  }
93
93