expo-observe 56.0.7 → 56.0.9

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 (83) 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/init.d.ts.map +1 -1
  4. package/expo-module.config.json +1 -1
  5. package/ios/CursorRepair.swift +55 -0
  6. package/ios/Event.swift +61 -45
  7. package/ios/Observability.swift +96 -94
  8. package/ios/ObserveUserDefaults.swift +13 -13
  9. package/ios/OpenTelemetry.swift +29 -18
  10. package/ios/Tests/CursorRepairTests.swift +94 -0
  11. package/ios/Tests/OTAnyValueTests.swift +37 -5
  12. package/ios/Tests/OpenTelemetryTests.swift +30 -1
  13. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7-sources.jar → 56.0.9/expo.modules.observe-56.0.9-sources.jar} +0 -0
  14. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9-sources.jar.md5 +1 -0
  15. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9-sources.jar.sha1 +1 -0
  16. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9-sources.jar.sha256 +1 -0
  17. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9-sources.jar.sha512 +1 -0
  18. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.aar → 56.0.9/expo.modules.observe-56.0.9.aar} +0 -0
  19. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.aar.md5 +1 -0
  20. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.aar.sha1 +1 -0
  21. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.aar.sha256 +1 -0
  22. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.aar.sha512 +1 -0
  23. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.module → 56.0.9/expo.modules.observe-56.0.9.module} +23 -23
  24. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.module.md5 +1 -0
  25. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.module.sha1 +1 -0
  26. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.module.sha256 +1 -0
  27. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.module.sha512 +1 -0
  28. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{56.0.7/expo.modules.observe-56.0.7.pom → 56.0.9/expo.modules.observe-56.0.9.pom} +2 -2
  29. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.pom.md5 +1 -0
  30. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.pom.sha1 +1 -0
  31. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.pom.sha256 +1 -0
  32. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.9/expo.modules.observe-56.0.9.pom.sha512 +1 -0
  33. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml +4 -4
  34. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.md5 +1 -1
  35. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha1 +1 -1
  36. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha256 +1 -1
  37. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha512 +1 -1
  38. package/package.json +5 -5
  39. package/src/integrations/expo-router/ObserveRouterIntegrationProvider.tsx +5 -4
  40. package/src/integrations/expo-router/init.ts +12 -5
  41. package/tsconfig.json +2 -2
  42. package/build/ObserveProvider.js +0 -7
  43. package/build/ObserveProvider.js.map +0 -1
  44. package/build/ObserveRoot.js +0 -12
  45. package/build/ObserveRoot.js.map +0 -1
  46. package/build/index.js +0 -11
  47. package/build/index.js.map +0 -1
  48. package/build/integrations/expo-router/ObserveRouterIntegrationProvider.js +0 -20
  49. package/build/integrations/expo-router/ObserveRouterIntegrationProvider.js.map +0 -1
  50. package/build/integrations/expo-router/index.js +0 -5
  51. package/build/integrations/expo-router/index.js.map +0 -1
  52. package/build/integrations/expo-router/init.js +0 -83
  53. package/build/integrations/expo-router/init.js.map +0 -1
  54. package/build/integrations/expo-router/router.js +0 -12
  55. package/build/integrations/expo-router/router.js.map +0 -1
  56. package/build/integrations/expo-router/storage.js +0 -10
  57. package/build/integrations/expo-router/storage.js.map +0 -1
  58. package/build/integrations/expo-router/useObserveForRouter.js +0 -91
  59. package/build/integrations/expo-router/useObserveForRouter.js.map +0 -1
  60. package/build/module.js +0 -20
  61. package/build/module.js.map +0 -1
  62. package/build/module.web.js +0 -9
  63. package/build/module.web.js.map +0 -1
  64. package/build/types.js +0 -2
  65. package/build/types.js.map +0 -1
  66. package/build/useObserve.js +0 -9
  67. package/build/useObserve.js.map +0 -1
  68. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.md5 +0 -1
  69. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha1 +0 -1
  70. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha256 +0 -1
  71. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7-sources.jar.sha512 +0 -1
  72. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.md5 +0 -1
  73. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha1 +0 -1
  74. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha256 +0 -1
  75. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.aar.sha512 +0 -1
  76. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.md5 +0 -1
  77. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha1 +0 -1
  78. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha256 +0 -1
  79. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.module.sha512 +0 -1
  80. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.md5 +0 -1
  81. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.sha1 +0 -1
  82. package/local-maven-repo/expo/modules/observe/expo.modules.observe/56.0.7/expo.modules.observe-56.0.7.pom.sha256 +0 -1
  83. 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"}
@@ -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"}
@@ -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.9",
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
 
@@ -11,10 +11,6 @@ struct OTStringValue: Codable, Sendable {
11
11
  Tagged union mirroring the OTLP `AnyValue` shape — encodes as an object with
12
12
  exactly one of `stringValue` / `intValue` / `doubleValue` / `boolValue` /
13
13
  `arrayValue` / `kvlistValue`, depending on the variant.
14
-
15
- OTLP encodes 64-bit integers as JSON strings to avoid precision loss; we follow
16
- that convention so collectors that rely on the protobuf-JSON mapping accept the
17
- payload.
18
14
  */
19
15
  enum OTAnyValue: Codable, Sendable {
20
16
  case string(String)
@@ -34,7 +30,10 @@ enum OTAnyValue: Codable, Sendable {
34
30
  case .string(let value):
35
31
  try container.encode(value, forKey: .stringValue)
36
32
  case .int(let value):
37
- try container.encode(String(value), forKey: .intValue)
33
+ // OTLP/JSON spec encodes int64 as a stringified number to avoid loss in JS-style number
34
+ // handling, but the EAS observability backend (ClickHouse) rejects strings here and requires
35
+ // a JSON number. Emit as a number to match the server contract.
36
+ try container.encode(value, forKey: .intValue)
38
37
  case .double(let value):
39
38
  try container.encode(value, forKey: .doubleValue)
40
39
  case .bool(let value):
@@ -50,8 +49,8 @@ enum OTAnyValue: Codable, Sendable {
50
49
  let container = try decoder.container(keyedBy: CodingKeys.self)
51
50
  if let value = try container.decodeIfPresent(String.self, forKey: .stringValue) {
52
51
  self = .string(value)
53
- } else if let value = try container.decodeIfPresent(String.self, forKey: .intValue), let parsed = Int64(value) {
54
- self = .int(parsed)
52
+ } else if let value = try container.decodeIfPresent(Int64.self, forKey: .intValue) {
53
+ self = .int(value)
55
54
  } else if let value = try container.decodeIfPresent(Double.self, forKey: .doubleValue) {
56
55
  self = .double(value)
57
56
  } else if let value = try container.decodeIfPresent(Bool.self, forKey: .boolValue) {
@@ -180,19 +179,27 @@ private let semConvSchemaUrl = "https://opentelemetry.io/schemas/1.27.0"
180
179
 
181
180
  // This must be kept in sync with the INTERNAL_TO_OTEL map in universe
182
181
  // https://github.com/expo/universe/blob/main/server/www/src/middleware/easObserveRoutes.ts#L209
182
+ // Keyed by "<category>/<name>" — mirrors the (category, name) pair used by the
183
+ // Android port so the same metric name under a different category never silently
184
+ // collides.
183
185
  let metricNameMap = [
184
- "timeToInteractive": "expo.app_startup.tti",
185
- "timeToFirstRender": "expo.app_startup.ttr",
186
- "coldLaunchTime": "expo.app_startup.cold_launch_time",
187
- "warmLaunchTime": "expo.app_startup.warm_launch_time",
188
- "bundleLoadTime": "expo.app_startup.bundle_load_time",
186
+ // App startup
187
+ "appStartup/timeToInteractive": "expo.app_startup.tti",
188
+ "appStartup/timeToFirstRender": "expo.app_startup.ttr",
189
+ "appStartup/coldLaunchTime": "expo.app_startup.cold_launch_time",
190
+ "appStartup/warmLaunchTime": "expo.app_startup.warm_launch_time",
191
+ "appStartup/bundleLoadTime": "expo.app_startup.bundle_load_time",
189
192
 
190
- // Legacy metrics - will be removed in a future release
191
- "loadTime": "expo.app_startup.load_time",
192
- "launchTime": "expo.app_startup.launch_time",
193
+ // Legacy app startup metrics - will be removed in a future release
194
+ "appStartup/loadTime": "expo.app_startup.load_time",
195
+ "appStartup/launchTime": "expo.app_startup.launch_time",
193
196
 
194
197
  // Updates
195
- "updateDownloadTime": "expo.updates.download_time"
198
+ "updates/updateDownloadTime": "expo.updates.download_time",
199
+
200
+ // Navigation
201
+ "navigation/cold_ttr": "expo.navigation.cold_ttr",
202
+ "navigation/warm_ttr": "expo.navigation.warm_ttr"
196
203
  ]
197
204
 
198
205
  nonisolated(unsafe) let formatter = ISO8601DateFormatter()
@@ -224,9 +231,10 @@ extension Event.Metric {
224
231
  attributes.append(OTAttribute(key: "expo.custom_params", rawValue: customParamsString))
225
232
  }
226
233
 
234
+ let lookupKey = "\(self.category ?? "unknown")/\(self.name)"
227
235
  return OTMetric(
228
236
  unit: "s",
229
- name: metricNameMap[self.name] ?? "expo.app_startup.\(self.name)",
237
+ name: metricNameMap[lookupKey] ?? "expo.unknown.\(self.name)",
230
238
  gauge: OTGauge(dataPoints: [
231
239
  OTDataPoint(
232
240
  timeUnixNano: nsFromISODateString(),
@@ -294,7 +302,10 @@ func otAttributesFromUserDict(_ dict: [String: Any]) -> (attributes: [OTAttribut
294
302
  and would otherwise be matched as `Int` first.
295
303
  */
296
304
  func otAnyValue(from value: Any) -> OTAnyValue? {
297
- if let bool = value as? Bool {
305
+ // `as? Bool` succeeds for any NSNumber holding 0 or 1 — including `Int(0)` / `Int(1)` from JS,
306
+ // which would otherwise quietly emit as `boolValue` instead of `intValue`. Distinguish real
307
+ // booleans by checking against `CFBoolean`'s type id, which only the actual boxed Bool matches.
308
+ if CFGetTypeID(value as CFTypeRef) == CFBooleanGetTypeID(), let bool = value as? Bool {
298
309
  return .bool(bool)
299
310
  }
300
311
  if let int = value as? Int64 {