expo-observe 57.0.13 → 57.0.15

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 (54) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/android/src/main/java/expo/modules/observe/Constants.kt +1 -0
  3. package/android/src/main/java/expo/modules/observe/DispatchUtils.kt +15 -8
  4. package/android/src/main/java/expo/modules/observe/EventDispatcher.kt +2 -0
  5. package/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt +2 -1
  6. package/android/src/main/java/expo/modules/observe/ObservabilityManager.kt +138 -89
  7. package/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt +18 -4
  8. package/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt +5 -1
  9. package/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt +5 -1
  10. package/expo-module.config.json +1 -1
  11. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15-sources.jar +0 -0
  12. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15-sources.jar.md5 +1 -0
  13. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15-sources.jar.sha1 +1 -0
  14. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15-sources.jar.sha256 +1 -0
  15. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15-sources.jar.sha512 +1 -0
  16. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.aar +0 -0
  17. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.aar.md5 +1 -0
  18. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.aar.sha1 +1 -0
  19. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.aar.sha256 +1 -0
  20. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.aar.sha512 +1 -0
  21. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{57.0.13/expo.modules.observe-57.0.13.module → 57.0.15/expo.modules.observe-57.0.15.module} +23 -23
  22. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.module.md5 +1 -0
  23. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.module.sha1 +1 -0
  24. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.module.sha256 +1 -0
  25. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.module.sha512 +1 -0
  26. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{57.0.13/expo.modules.observe-57.0.13.pom → 57.0.15/expo.modules.observe-57.0.15.pom} +2 -2
  27. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.pom.md5 +1 -0
  28. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.pom.sha1 +1 -0
  29. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.pom.sha256 +1 -0
  30. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.15/expo.modules.observe-57.0.15.pom.sha512 +1 -0
  31. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml +4 -4
  32. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.md5 +1 -1
  33. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha1 +1 -1
  34. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha256 +1 -1
  35. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha512 +1 -1
  36. package/package.json +4 -4
  37. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13-sources.jar +0 -0
  38. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13-sources.jar.md5 +0 -1
  39. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13-sources.jar.sha1 +0 -1
  40. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13-sources.jar.sha256 +0 -1
  41. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13-sources.jar.sha512 +0 -1
  42. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.aar +0 -0
  43. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.aar.md5 +0 -1
  44. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.aar.sha1 +0 -1
  45. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.aar.sha256 +0 -1
  46. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.aar.sha512 +0 -1
  47. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.module.md5 +0 -1
  48. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.module.sha1 +0 -1
  49. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.module.sha256 +0 -1
  50. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.module.sha512 +0 -1
  51. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.pom.md5 +0 -1
  52. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.pom.sha1 +0 -1
  53. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.pom.sha256 +0 -1
  54. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.13/expo.modules.observe-57.0.13.pom.sha512 +0 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,17 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.15 — 2026-08-20
14
+
15
+ ### 💡 Others
16
+
17
+ - [Android] Retry a dispatch that gets HTTP 413 ([#49016](https://github.com/expo/expo/pull/49016) by [@Ubax](https://github.com/Ubax))
18
+ - [Android] Dispatch pending metrics and logs in bounded, oldest-first chunks without replacing active background work. ([#49012](https://github.com/expo/expo/pull/49012) by [@Ubax](https://github.com/Ubax))
19
+
20
+ ## 57.0.14 — 2026-08-17
21
+
22
+ _This version does not introduce any user-facing changes._
23
+
13
24
  ## 57.0.13 — 2026-08-14
14
25
 
15
26
  _This version does not introduce any user-facing changes._
@@ -3,4 +3,5 @@ package expo.modules.observe
3
3
  internal const val OBSERVE_TAG = "EasObserve"
4
4
  internal const val OBSERVE_DEFAULT_BASE_URL = "https://o.expo.dev/"
5
5
 
6
+ internal const val DISPATCH_CHUNK_SIZE = 200
6
7
  internal const val SQLITE_MAX_BIND_VARIABLES = 900
@@ -11,7 +11,7 @@ import kotlin.math.pow
11
11
  import kotlin.random.Random
12
12
 
13
13
  /**
14
- * Outcome of a single dispatch attempt to the OTLP endpoint. Four cases, modeled after the
14
+ * Outcome of a single dispatch attempt to the OTLP endpoint. Five cases, modeled after the
15
15
  * OTLP retry guidance (see https://opentelemetry.io/docs/specs/otlp/#otlphttp-response):
16
16
  *
17
17
  * - `Success` — server accepted the batch without rejections.
@@ -22,6 +22,7 @@ import kotlin.random.Random
22
22
  * it as a drop.
23
23
  * - `RetryableFailure` — transient failure (429/502/503/504 or transport error); retry
24
24
  * the same batch after `retryAfterMs` or a client-computed backoff.
25
+ * - `PayloadTooLarge` — HTTP 413; retry immediately with fewer records.
25
26
  * - `NonRetryableFailure` — permanent failure (4xx/5xx outside the retryable set, encoding
26
27
  * error); drop the batch so it can't wedge the queue.
27
28
  *
@@ -33,6 +34,7 @@ sealed class DispatchResult {
33
34
  object Success : DispatchResult()
34
35
  data class PartialSuccess(val partial: OTPartialSuccess) : DispatchResult()
35
36
  data class RetryableFailure(val retryAfterMs: Long? = null) : DispatchResult()
37
+ object PayloadTooLarge : DispatchResult()
36
38
  data class NonRetryableFailure(val reason: String) : DispatchResult()
37
39
  }
38
40
 
@@ -43,7 +45,7 @@ sealed class DispatchResult {
43
45
  */
44
46
  object DispatchUtils {
45
47
  /**
46
- * Pure classifier that maps an HTTP response into one of three retry outcomes. Extracted
48
+ * Pure classifier that maps an HTTP response into a dispatch outcome. Extracted
47
49
  * from the dispatch call site so the OTLP-spec rules can be unit-tested without a real
48
50
  * network call.
49
51
  *
@@ -57,8 +59,6 @@ object DispatchUtils {
57
59
  responseBody: String?,
58
60
  bodyExcerpt: () -> String = { "" }
59
61
  ): DispatchResult {
60
- val retryAfter = parseRetryAfter(retryAfterHeader)
61
-
62
62
  if (statusCode in 200..299) {
63
63
  // The OTLP spec allows `partial_success` to carry a warning-only payload —
64
64
  // `rejectedCount == 0` with a non-empty `errorMessage`. Treat that as a successful send
@@ -78,7 +78,8 @@ object DispatchUtils {
78
78
  }
79
79
 
80
80
  return when (statusCode) {
81
- 429, 502, 503, 504 -> DispatchResult.RetryableFailure(retryAfter)
81
+ 413 -> DispatchResult.PayloadTooLarge
82
+ 429, 502, 503, 504 -> DispatchResult.RetryableFailure(parseRetryAfter(retryAfterHeader))
82
83
  else -> {
83
84
  val excerpt = bodyExcerpt()
84
85
  val suffix = if (excerpt.isEmpty()) "" else ": $excerpt"
@@ -97,12 +98,15 @@ object DispatchUtils {
97
98
  * permanently, so retrying would produce the same answer; removing them drops the batch
98
99
  * so it can't wedge subsequent rounds. This is the acceptance-criterion behavior: a
99
100
  * 400/403 must not be re-sent on the next cycle.
100
- * - `RetryableFailure` keeps them so the next dispatch round picks the same rows up again.
101
+ * - `PayloadTooLarge` removes the pending ID because multi-record batches are retried with
102
+ * smaller chunks before this check, so reaching it means a single record exceeded the limit.
103
+ * - `RetryableFailure` keeps them so they can be retried.
101
104
  */
102
105
  fun shouldRemovePending(result: DispatchResult): Boolean = when (result) {
103
106
  is DispatchResult.Success,
104
107
  is DispatchResult.PartialSuccess,
105
- is DispatchResult.NonRetryableFailure -> true
108
+ is DispatchResult.NonRetryableFailure,
109
+ is DispatchResult.PayloadTooLarge -> true
106
110
  is DispatchResult.RetryableFailure -> false
107
111
  }
108
112
 
@@ -218,6 +222,8 @@ object DispatchUtils {
218
222
  * server-side) doesn't introduce a new pause.
219
223
  * - `NonRetryableFailure` also resets the counter. A permanent drop isn't a sign that the
220
224
  * server is unhealthy and shouldn't pause subsequent rounds.
225
+ * - `PayloadTooLarge` also resets the counter because a reachable server returned a definitive
226
+ * response, and leaves the gate alone because chunk-size retries are immediate.
221
227
  * - `RetryableFailure` increments the counter and sets the gate to `now + delay`, where
222
228
  * `delay` is the server-supplied `retryAfterMs` if present, otherwise `backoff(nextCount)`.
223
229
  *
@@ -232,7 +238,8 @@ object DispatchUtils {
232
238
  ): RetryGateState = when (result) {
233
239
  is DispatchResult.Success,
234
240
  is DispatchResult.PartialSuccess,
235
- is DispatchResult.NonRetryableFailure ->
241
+ is DispatchResult.NonRetryableFailure,
242
+ is DispatchResult.PayloadTooLarge ->
236
243
  currentState.copy(consecutiveRetryableFailures = 0)
237
244
  is DispatchResult.RetryableFailure -> {
238
245
  val nextCount = currentState.consecutiveRetryableFailures + 1
@@ -132,6 +132,8 @@ class EventDispatcher(
132
132
  )
133
133
  is DispatchResult.RetryableFailure ->
134
134
  Log.w(OBSERVE_TAG, "Server responded with ${response.code} (retryable) and data: $responseBody")
135
+ is DispatchResult.PayloadTooLarge ->
136
+ Log.w(OBSERVE_TAG, "Server responded with ${response.code} (payload too large) and data: $responseBody")
135
137
  is DispatchResult.NonRetryableFailure ->
136
138
  Log.w(
137
139
  OBSERVE_TAG,
@@ -119,7 +119,8 @@ class ObservabilityBackgroundWorker(
119
119
  .getInstance(context)
120
120
  .enqueueUniqueWork(
121
121
  WORK_NAME,
122
- ExistingWorkPolicy.REPLACE,
122
+ // Keep an in-flight dispatch; cancelling it can duplicate a request the server received.
123
+ ExistingWorkPolicy.KEEP,
123
124
  periodicWork
124
125
  )
125
126
  }
@@ -8,6 +8,8 @@ import expo.modules.observe.storage.PendingMetricsManager
8
8
  import expo.modules.appmetrics.storage.SessionManager
9
9
  import expo.modules.appmetrics.utils.TimeUtils
10
10
  import expo.modules.interfaces.constants.ConstantsInterface
11
+ import kotlinx.coroutines.currentCoroutineContext
12
+ import kotlinx.coroutines.isActive
11
13
  import kotlinx.coroutines.sync.Mutex
12
14
  import kotlinx.coroutines.sync.withLock
13
15
 
@@ -81,7 +83,8 @@ class BaseObservabilityManager(
81
83
  private val deterministicUniformValueProvider: () -> Double = {
82
84
  EASClientID.deterministicUniformValue(EASClientID(context).uuid)
83
85
  },
84
- private val currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() }
86
+ private val currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() },
87
+ private val dispatchChunkSize: Int = DISPATCH_CHUNK_SIZE
85
88
  ) {
86
89
  private val eventDispatcher = EventDispatcher(
87
90
  context = context,
@@ -149,8 +152,7 @@ class BaseObservabilityManager(
149
152
  )
150
153
 
151
154
  suspend fun dispatchUnsentMetrics(): Unit = metricsDispatchMutex.withLock {
152
- val pendingIds = pendingMetricsManager.getAllPendingMetricIds()
153
- if (pendingIds.isEmpty()) {
155
+ if (!pendingMetricsManager.hasPendingMetrics()) {
154
156
  return
155
157
  }
156
158
 
@@ -159,50 +161,74 @@ class BaseObservabilityManager(
159
161
  }
160
162
 
161
163
  if (!shouldDispatch()) {
162
- pendingMetricsManager.removePendingMetrics(pendingIds)
164
+ pendingMetricsManager.removeAllPendingMetrics()
163
165
  return
164
166
  }
165
167
 
166
- val sessionsWithPendingMetrics = sessionManager.getSessionsWithMetrics(pendingIds)
167
-
168
- // Clean up orphaned pending IDs (metrics deleted from MetricsDatabase but still in pending table)
169
- val resolvedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }.toSet()
170
- val orphanedIds = pendingIds.filter { it !in resolvedMetricIds }
171
- if (orphanedIds.isNotEmpty()) {
172
- pendingMetricsManager.removePendingMetrics(orphanedIds)
173
- }
174
-
175
- if (sessionsWithPendingMetrics.isEmpty()) {
176
- return
177
- }
178
-
179
- val events = sessionsWithPendingMetrics.map { sessionWithMetrics ->
180
- Event(
181
- metadata = Metadata.fromSessionMetadata(sessionWithMetrics.session),
182
- metrics = sessionWithMetrics.metrics.map { EASMetric.fromMetric(it) }
183
- )
184
- }
185
-
186
- val result = eventDispatcher.dispatch(events)
187
- metricsRetryGate = nextGate(metricsRetryGate, result)
188
- val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }
189
- if (DispatchUtils.shouldRemovePending(result)) {
190
- pendingMetricsManager.removePendingMetrics(dispatchedMetricIds)
191
- }
192
- when (result) {
193
- is DispatchResult.PartialSuccess ->
194
- Log.w(
195
- OBSERVE_TAG,
196
- "Partial success on batch of ${dispatchedMetricIds.size} metric event(s): " +
197
- "server rejected ${result.partial.rejectedCount} " +
198
- "(${result.partial.errorMessage ?: "no error message"})"
199
- )
200
- is DispatchResult.NonRetryableFailure ->
201
- Log.w(
202
- OBSERVE_TAG,
203
- "Dropping batch of ${dispatchedMetricIds.size} metric event(s): ${result.reason}"
204
- )
205
- is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
168
+ var chunkSize = dispatchChunkSize
169
+ while (currentCoroutineContext().isActive) {
170
+ val pendingIds = pendingMetricsManager.getPendingMetricIds(chunkSize)
171
+ // Use the default for the next batch unless a 413 below overrides it. Re-discovering
172
+ // the limit each batch is fine: the server accepts payloads over 1 MB, so the default
173
+ // chunk stays far below the limit and a 413 is exceptional.
174
+ chunkSize = dispatchChunkSize
175
+ if (pendingIds.isEmpty()) {
176
+ break
177
+ }
178
+
179
+ val sessionsWithPendingMetrics = sessionManager.getSessionsWithMetrics(pendingIds)
180
+
181
+ // Clean up orphaned pending IDs (metrics deleted from MetricsDatabase but still in pending table)
182
+ val resolvedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }.toSet()
183
+ val orphanedIds = pendingIds.filter { it !in resolvedMetricIds }
184
+ if (orphanedIds.isNotEmpty()) {
185
+ pendingMetricsManager.removePendingMetrics(orphanedIds)
186
+ }
187
+
188
+ if (sessionsWithPendingMetrics.isNotEmpty()) {
189
+ val events = sessionsWithPendingMetrics.map { sessionWithMetrics ->
190
+ Event(
191
+ metadata = Metadata.fromSessionMetadata(sessionWithMetrics.session),
192
+ metrics = sessionWithMetrics.metrics.map { EASMetric.fromMetric(it) }
193
+ )
194
+ }
195
+
196
+ val result = eventDispatcher.dispatch(events)
197
+ metricsRetryGate = nextGate(metricsRetryGate, result)
198
+ val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }
199
+ when (result) {
200
+ is DispatchResult.PartialSuccess ->
201
+ Log.w(
202
+ OBSERVE_TAG,
203
+ "Partial success on batch of ${dispatchedMetricIds.size} metric event(s): " +
204
+ "server rejected ${result.partial.rejectedCount} " +
205
+ "(${result.partial.errorMessage ?: "no error message"})"
206
+ )
207
+ is DispatchResult.NonRetryableFailure ->
208
+ Log.w(
209
+ OBSERVE_TAG,
210
+ "Dropping batch of ${dispatchedMetricIds.size} metric event(s): ${result.reason}"
211
+ )
212
+ is DispatchResult.PayloadTooLarge ->
213
+ if (dispatchedMetricIds.size == 1) {
214
+ Log.w(OBSERVE_TAG, "Dropping metric event that exceeds the server's payload limit")
215
+ }
216
+ is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
217
+ }
218
+ if (result is DispatchResult.PayloadTooLarge && dispatchedMetricIds.size > 1) {
219
+ chunkSize = dispatchedMetricIds.size / 2
220
+ // Keep the pending metrics, but retry immediately with a smaller chunk.
221
+ continue
222
+ }
223
+ if (!DispatchUtils.shouldRemovePending(result)) {
224
+ break
225
+ }
226
+ pendingMetricsManager.removePendingMetrics(dispatchedMetricIds)
227
+ // A systematic rejection or an oversized record: leave the rest for the next run.
228
+ if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) {
229
+ break
230
+ }
231
+ }
206
232
  }
207
233
  }
208
234
 
@@ -211,8 +237,7 @@ class BaseObservabilityManager(
211
237
  * a logs failure doesn't affect the metrics pending table and vice versa.
212
238
  */
213
239
  suspend fun dispatchUnsentLogs(): Unit = logsDispatchMutex.withLock {
214
- val pendingIds = pendingLogsManager.getAllPendingLogIds()
215
- if (pendingIds.isEmpty()) {
240
+ if (!pendingLogsManager.hasPendingLogs()) {
216
241
  return
217
242
  }
218
243
 
@@ -221,52 +246,76 @@ class BaseObservabilityManager(
221
246
  }
222
247
 
223
248
  if (!shouldDispatch()) {
224
- pendingLogsManager.removePendingLogs(pendingIds)
249
+ pendingLogsManager.removeAllPendingLogs()
225
250
  return
226
251
  }
227
252
 
228
- val sessionsWithPendingLogs = sessionManager.getSessionsWithLogs(pendingIds)
229
-
230
- // Clean up orphaned pending IDs (logs deleted from the `logs` table but
231
- // still tracked in `pending_logs`).
232
- val resolvedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }.toSet()
233
- val orphanedIds = pendingIds.filter { it !in resolvedLogIds }
234
- if (orphanedIds.isNotEmpty()) {
235
- pendingLogsManager.removePendingLogs(orphanedIds)
236
- }
237
-
238
- if (sessionsWithPendingLogs.isEmpty()) {
239
- return
240
- }
241
-
242
- val events = sessionsWithPendingLogs.map { sessionWithLogs ->
243
- Event(
244
- metadata = Metadata.fromSessionMetadata(sessionWithLogs.session),
245
- metrics = emptyList(),
246
- logs = sessionWithLogs.logs.map { LogEvent.fromLogRecord(it) }
247
- )
248
- }
249
-
250
- val result = eventDispatcher.dispatchLogs(events)
251
- logsRetryGate = nextGate(logsRetryGate, result)
252
- val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }
253
- if (DispatchUtils.shouldRemovePending(result)) {
254
- pendingLogsManager.removePendingLogs(dispatchedLogIds)
255
- }
256
- when (result) {
257
- is DispatchResult.PartialSuccess ->
258
- Log.w(
259
- OBSERVE_TAG,
260
- "Partial success on batch of ${dispatchedLogIds.size} log event(s): " +
261
- "server rejected ${result.partial.rejectedCount} " +
262
- "(${result.partial.errorMessage ?: "no error message"})"
263
- )
264
- is DispatchResult.NonRetryableFailure ->
265
- Log.w(
266
- OBSERVE_TAG,
267
- "Dropping batch of ${dispatchedLogIds.size} log event(s): ${result.reason}"
268
- )
269
- is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
253
+ var chunkSize = dispatchChunkSize
254
+ while (currentCoroutineContext().isActive) {
255
+ val pendingIds = pendingLogsManager.getPendingLogIds(chunkSize)
256
+ // Use the default for the next batch unless a 413 below overrides it. Re-discovering
257
+ // the limit each batch is fine: the server accepts payloads over 1 MB, so the default
258
+ // chunk stays far below the limit and a 413 is exceptional.
259
+ chunkSize = dispatchChunkSize
260
+ if (pendingIds.isEmpty()) {
261
+ break
262
+ }
263
+
264
+ val sessionsWithPendingLogs = sessionManager.getSessionsWithLogs(pendingIds)
265
+
266
+ // Clean up orphaned pending IDs (logs deleted from the `logs` table but
267
+ // still tracked in `pending_logs`).
268
+ val resolvedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }.toSet()
269
+ val orphanedIds = pendingIds.filter { it !in resolvedLogIds }
270
+ if (orphanedIds.isNotEmpty()) {
271
+ pendingLogsManager.removePendingLogs(orphanedIds)
272
+ }
273
+
274
+ if (sessionsWithPendingLogs.isNotEmpty()) {
275
+ val events = sessionsWithPendingLogs.map { sessionWithLogs ->
276
+ Event(
277
+ metadata = Metadata.fromSessionMetadata(sessionWithLogs.session),
278
+ metrics = emptyList(),
279
+ logs = sessionWithLogs.logs.map { LogEvent.fromLogRecord(it) }
280
+ )
281
+ }
282
+
283
+ val result = eventDispatcher.dispatchLogs(events)
284
+ logsRetryGate = nextGate(logsRetryGate, result)
285
+ val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }
286
+ when (result) {
287
+ is DispatchResult.PartialSuccess ->
288
+ Log.w(
289
+ OBSERVE_TAG,
290
+ "Partial success on batch of ${dispatchedLogIds.size} log event(s): " +
291
+ "server rejected ${result.partial.rejectedCount} " +
292
+ "(${result.partial.errorMessage ?: "no error message"})"
293
+ )
294
+ is DispatchResult.NonRetryableFailure ->
295
+ Log.w(
296
+ OBSERVE_TAG,
297
+ "Dropping batch of ${dispatchedLogIds.size} log event(s): ${result.reason}"
298
+ )
299
+ is DispatchResult.PayloadTooLarge ->
300
+ if (dispatchedLogIds.size == 1) {
301
+ Log.w(OBSERVE_TAG, "Dropping log event that exceeds the server's payload limit")
302
+ }
303
+ is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
304
+ }
305
+ if (result is DispatchResult.PayloadTooLarge && dispatchedLogIds.size > 1) {
306
+ chunkSize = dispatchedLogIds.size / 2
307
+ // Keep the pending logs, but retry immediately with a smaller chunk.
308
+ continue
309
+ }
310
+ if (!DispatchUtils.shouldRemovePending(result)) {
311
+ break
312
+ }
313
+ pendingLogsManager.removePendingLogs(dispatchedLogIds)
314
+ // A systematic rejection or an oversized record: leave the rest for the next run.
315
+ if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) {
316
+ break
317
+ }
318
+ }
270
319
  }
271
320
  }
272
321
 
@@ -11,6 +11,8 @@ import androidx.room.Query
11
11
  import androidx.room.Room
12
12
  import androidx.room.RoomDatabase
13
13
 
14
+ // TODO: Draining large backlogs from both pending tables reruns `ORDER BY addedAt`
15
+ // for every chunk. Consider `Index("addedAt")` and a version bump if this matters in practice.
14
16
  @Entity(tableName = "pending_metrics")
15
17
  data class PendingMetric(
16
18
  @PrimaryKey val metricId: String,
@@ -30,8 +32,14 @@ interface PendingMetricDao {
30
32
  @Insert(onConflict = OnConflictStrategy.IGNORE)
31
33
  suspend fun insertAll(metrics: List<PendingMetric>)
32
34
 
33
- @Query("SELECT metricId FROM pending_metrics")
34
- suspend fun getAllMetricIds(): List<String>
35
+ @Query("SELECT metricId FROM pending_metrics ORDER BY addedAt ASC LIMIT :limit")
36
+ suspend fun getMetricIds(limit: Int): List<String>
37
+
38
+ @Query("SELECT EXISTS(SELECT 1 FROM pending_metrics)")
39
+ suspend fun hasMetricIds(): Boolean
40
+
41
+ @Query("DELETE FROM pending_metrics")
42
+ suspend fun deleteAll()
35
43
 
36
44
  @Query("DELETE FROM pending_metrics WHERE metricId IN (:metricIds)")
37
45
  suspend fun deleteByIds(metricIds: List<String>)
@@ -45,8 +53,14 @@ interface PendingLogDao {
45
53
  @Insert(onConflict = OnConflictStrategy.IGNORE)
46
54
  suspend fun insertAll(logs: List<PendingLog>)
47
55
 
48
- @Query("SELECT logId FROM pending_logs")
49
- suspend fun getAllLogIds(): List<String>
56
+ @Query("SELECT logId FROM pending_logs ORDER BY addedAt ASC LIMIT :limit")
57
+ suspend fun getLogIds(limit: Int): List<String>
58
+
59
+ @Query("SELECT EXISTS(SELECT 1 FROM pending_logs)")
60
+ suspend fun hasLogIds(): Boolean
61
+
62
+ @Query("DELETE FROM pending_logs")
63
+ suspend fun deleteAll()
50
64
 
51
65
  @Query("DELETE FROM pending_logs WHERE logId IN (:logIds)")
52
66
  suspend fun deleteByIds(logIds: List<String>)
@@ -17,7 +17,11 @@ class PendingLogsManager(
17
17
  database.pendingLogDao().insertAll(pendingLogs)
18
18
  }
19
19
 
20
- suspend fun getAllPendingLogIds(): List<String> = database.pendingLogDao().getAllLogIds()
20
+ suspend fun getPendingLogIds(limit: Int): List<String> = database.pendingLogDao().getLogIds(limit)
21
+
22
+ suspend fun hasPendingLogs(): Boolean = database.pendingLogDao().hasLogIds()
23
+
24
+ suspend fun removeAllPendingLogs() = database.pendingLogDao().deleteAll()
21
25
 
22
26
  suspend fun removePendingLogs(logIds: List<String>) {
23
27
  database.withTransaction {
@@ -17,7 +17,11 @@ class PendingMetricsManager(
17
17
  database.pendingMetricDao().insertAll(pendingMetrics)
18
18
  }
19
19
 
20
- suspend fun getAllPendingMetricIds(): List<String> = database.pendingMetricDao().getAllMetricIds()
20
+ suspend fun getPendingMetricIds(limit: Int): List<String> = database.pendingMetricDao().getMetricIds(limit)
21
+
22
+ suspend fun hasPendingMetrics(): Boolean = database.pendingMetricDao().hasMetricIds()
23
+
24
+ suspend fun removeAllPendingMetrics() = database.pendingMetricDao().deleteAll()
21
25
 
22
26
  suspend fun removePendingMetrics(metricIds: List<String>) {
23
27
  database.withTransaction {
@@ -9,7 +9,7 @@
9
9
  "publication": {
10
10
  "groupId": "expo.modules.observe",
11
11
  "artifactId": "expo.modules.observe",
12
- "version": "57.0.13",
12
+ "version": "57.0.15",
13
13
  "repository": "local-maven-repo"
14
14
  }
15
15
  }
@@ -0,0 +1 @@
1
+ 9859b378e81b9f278044dffb84c6f6c0594e6ffc6c876b7c9471800a39ad7010
@@ -0,0 +1 @@
1
+ 4ccf4718d0b3b09def0435d9bc224010235bea875a63a8c8c609bf3c2c96023cc387fcfcdb6e426357dd797eeba064d53d70513877d3abfd750f47398b7e4ff6
@@ -0,0 +1 @@
1
+ 6598738fa761502e1da0e93cb5d098616755bf810557ec6e5f91030ac2d03115
@@ -0,0 +1 @@
1
+ d64aa553cc7b7116a3bfbef0b88b25b47244cece088283f0cbce543e921de5677974be9dc4ce00d9c5c7c05bcfb05151f5285b78413e24aab9a974c38ce0e7fe
@@ -3,7 +3,7 @@
3
3
  "component": {
4
4
  "group": "expo.modules.observe",
5
5
  "module": "expo.modules.observe",
6
- "version": "57.0.13",
6
+ "version": "57.0.15",
7
7
  "attributes": {
8
8
  "org.gradle.status": "release"
9
9
  }
@@ -24,13 +24,13 @@
24
24
  },
25
25
  "files": [
26
26
  {
27
- "name": "expo.modules.observe-57.0.13.aar",
28
- "url": "expo.modules.observe-57.0.13.aar",
29
- "size": 275832,
30
- "sha512": "efb8dcee42d6457617e21778edfb37079978025d8f0031a4d11361dba511c93bc4189ad163614d93b21a1f98bb9732b8260f65f6432eb7aab57ad0bdd95eed3e",
31
- "sha256": "9ca9a7d9d78365a0c95c492db0a0b02c88ddee546cf7b2758920662ef3bf1b4d",
32
- "sha1": "01c1015942ab1dca9b939bdf85dca606789f0470",
33
- "md5": "f59414d95986cdaebaa1684bd740f5b7"
27
+ "name": "expo.modules.observe-57.0.15.aar",
28
+ "url": "expo.modules.observe-57.0.15.aar",
29
+ "size": 279117,
30
+ "sha512": "d64aa553cc7b7116a3bfbef0b88b25b47244cece088283f0cbce543e921de5677974be9dc4ce00d9c5c7c05bcfb05151f5285b78413e24aab9a974c38ce0e7fe",
31
+ "sha256": "6598738fa761502e1da0e93cb5d098616755bf810557ec6e5f91030ac2d03115",
32
+ "sha1": "a429ffd382c02b9fdc6de948013b4080478c86dd",
33
+ "md5": "7e9d14ef4b143fca2a5b5f4ce8ab1b04"
34
34
  }
35
35
  ]
36
36
  },
@@ -61,7 +61,7 @@
61
61
  "group": "expo.modules.appmetrics",
62
62
  "module": "expo.modules.appmetrics",
63
63
  "version": {
64
- "requires": "57.0.11"
64
+ "requires": "57.0.13"
65
65
  }
66
66
  },
67
67
  {
@@ -134,13 +134,13 @@
134
134
  ],
135
135
  "files": [
136
136
  {
137
- "name": "expo.modules.observe-57.0.13.aar",
138
- "url": "expo.modules.observe-57.0.13.aar",
139
- "size": 275832,
140
- "sha512": "efb8dcee42d6457617e21778edfb37079978025d8f0031a4d11361dba511c93bc4189ad163614d93b21a1f98bb9732b8260f65f6432eb7aab57ad0bdd95eed3e",
141
- "sha256": "9ca9a7d9d78365a0c95c492db0a0b02c88ddee546cf7b2758920662ef3bf1b4d",
142
- "sha1": "01c1015942ab1dca9b939bdf85dca606789f0470",
143
- "md5": "f59414d95986cdaebaa1684bd740f5b7"
137
+ "name": "expo.modules.observe-57.0.15.aar",
138
+ "url": "expo.modules.observe-57.0.15.aar",
139
+ "size": 279117,
140
+ "sha512": "d64aa553cc7b7116a3bfbef0b88b25b47244cece088283f0cbce543e921de5677974be9dc4ce00d9c5c7c05bcfb05151f5285b78413e24aab9a974c38ce0e7fe",
141
+ "sha256": "6598738fa761502e1da0e93cb5d098616755bf810557ec6e5f91030ac2d03115",
142
+ "sha1": "a429ffd382c02b9fdc6de948013b4080478c86dd",
143
+ "md5": "7e9d14ef4b143fca2a5b5f4ce8ab1b04"
144
144
  }
145
145
  ]
146
146
  },
@@ -154,13 +154,13 @@
154
154
  },
155
155
  "files": [
156
156
  {
157
- "name": "expo.modules.observe-57.0.13-sources.jar",
158
- "url": "expo.modules.observe-57.0.13-sources.jar",
159
- "size": 25679,
160
- "sha512": "6b1cd0f830b9527829365dd05a0c7eb6e5403e96e08f071fe63d1703ebf58e36ba7cc950e9b7e4b2041b80a12a7264a9519daff7bebf2a5d1e65cad0d3cf764a",
161
- "sha256": "b5f3c5315d5f69659945189b8481f06136430e3383c08235a7faf07e9d562501",
162
- "sha1": "c2699f439715d0ed6161a5fd7b11447f7ee16806",
163
- "md5": "2d172433507ad60d9a463b80c5236dde"
157
+ "name": "expo.modules.observe-57.0.15-sources.jar",
158
+ "url": "expo.modules.observe-57.0.15-sources.jar",
159
+ "size": 26681,
160
+ "sha512": "4ccf4718d0b3b09def0435d9bc224010235bea875a63a8c8c609bf3c2c96023cc387fcfcdb6e426357dd797eeba064d53d70513877d3abfd750f47398b7e4ff6",
161
+ "sha256": "9859b378e81b9f278044dffb84c6f6c0594e6ffc6c876b7c9471800a39ad7010",
162
+ "sha1": "a226a6afacb650059d328efa24da7d2166b2b306",
163
+ "md5": "5430ae1cc22085977500f5a9be5d9770"
164
164
  }
165
165
  ]
166
166
  }
@@ -0,0 +1 @@
1
+ 8afb9eb230946a7be139a495fd0dea559f26850926ed0c95d5b5a589d9bb0e77
@@ -0,0 +1 @@
1
+ a35cfdd58aeda7b1c603212f09812f52058703309aeca000f97bb16160e3c52a7df4df36b730473e1d60a825af689b7994890a0573c645168e98787fc5ea496d
@@ -9,7 +9,7 @@
9
9
  <modelVersion>4.0.0</modelVersion>
10
10
  <groupId>expo.modules.observe</groupId>
11
11
  <artifactId>expo.modules.observe</artifactId>
12
- <version>57.0.13</version>
12
+ <version>57.0.15</version>
13
13
  <packaging>aar</packaging>
14
14
  <name>expo.modules.observe</name>
15
15
  <url>https://github.com/expo/expo</url>
@@ -52,7 +52,7 @@
52
52
  <dependency>
53
53
  <groupId>expo.modules.appmetrics</groupId>
54
54
  <artifactId>expo.modules.appmetrics</artifactId>
55
- <version>57.0.11</version>
55
+ <version>57.0.13</version>
56
56
  <scope>runtime</scope>
57
57
  </dependency>
58
58
  <dependency>
@@ -0,0 +1 @@
1
+ f7b0181f654c7427af6df84bb6954f14ef7f7f2abc949d92a82ab3ccdd970760
@@ -0,0 +1 @@
1
+ f191372e14db1e6b075d0ce5f05cca883166a74ea132143293aea3c2550e74669ab6718363e094121849e14653608ed031c2053fb1916ab4f1669f90ace954ba
@@ -3,11 +3,11 @@
3
3
  <groupId>expo.modules.observe</groupId>
4
4
  <artifactId>expo.modules.observe</artifactId>
5
5
  <versioning>
6
- <latest>57.0.13</latest>
7
- <release>57.0.13</release>
6
+ <latest>57.0.15</latest>
7
+ <release>57.0.15</release>
8
8
  <versions>
9
- <version>57.0.13</version>
9
+ <version>57.0.15</version>
10
10
  </versions>
11
- <lastUpdated>20260814141312</lastUpdated>
11
+ <lastUpdated>20260820103851</lastUpdated>
12
12
  </versioning>
13
13
  </metadata>
@@ -1 +1 @@
1
- e5b8467c6e7ee358b35da72ab18f7414
1
+ 2c0c0f09d33976b41ea0b5fd83a0ce84
@@ -1 +1 @@
1
- 734876848454b21bb77be6cab99516191187916c
1
+ 5edcb91f92b6d691970902c2445c72949c9ecc45
@@ -1 +1 @@
1
- 7e5b488a45e21030dc4428f59f83ffa4c5dec38fde57993a2476b6dd04db1c46
1
+ d34ffe597003279018ac71c5254be213491be648ccef19925a22960846de2e9e
@@ -1 +1 @@
1
- ce71ed6e3d267512ae50e71169f1c67e16d2d5d75dc864c85bb27a5575a3d2d3c30e13304d63d3cb58cf9539297b0972d9935b8699df1ddd3381b04b5bddff22
1
+ c8095abdac192e8e4cc90dd777f9a162fc28034cb4723c75ceb7626a3af21c056adb529869b428bf0911a0b044e5616bcbbe296480baf6046ca695136096aec1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-observe",
3
3
  "title": "Expo Observe",
4
- "version": "57.0.13",
4
+ "version": "57.0.15",
5
5
  "description": "Expo module that dispatches collected app metrics to EAS Observe",
6
6
  "main": "src/index.ts",
7
7
  "types": "build/index.d.ts",
@@ -35,14 +35,14 @@
35
35
  "author": "650 Industries, Inc.",
36
36
  "license": "MIT",
37
37
  "dependencies": {
38
- "expo-app-metrics": "~57.0.11",
38
+ "expo-app-metrics": "~57.0.13",
39
39
  "expo-eas-client": "~57.0.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@react-navigation/native": "^7.1.33",
43
43
  "@testing-library/react-native": "^13.3.0",
44
44
  "@types/react": "~19.2.0",
45
- "expo": "57.0.13",
45
+ "expo": "57.0.15",
46
46
  "expo-module-scripts": "56.0.3"
47
47
  },
48
48
  "peerDependencies": {
@@ -60,7 +60,7 @@
60
60
  "optional": true
61
61
  }
62
62
  },
63
- "gitHead": "125b8d4472432db3dc4ae047730432d7bd5bd30c",
63
+ "gitHead": "499024a441d67293aa60afcbe02051999a54f1bb",
64
64
  "scripts": {
65
65
  "build": "expo-module build",
66
66
  "clean": "expo-module clean",
@@ -1 +0,0 @@
1
- b5f3c5315d5f69659945189b8481f06136430e3383c08235a7faf07e9d562501
@@ -1 +0,0 @@
1
- 6b1cd0f830b9527829365dd05a0c7eb6e5403e96e08f071fe63d1703ebf58e36ba7cc950e9b7e4b2041b80a12a7264a9519daff7bebf2a5d1e65cad0d3cf764a
@@ -1 +0,0 @@
1
- 9ca9a7d9d78365a0c95c492db0a0b02c88ddee546cf7b2758920662ef3bf1b4d
@@ -1 +0,0 @@
1
- efb8dcee42d6457617e21778edfb37079978025d8f0031a4d11361dba511c93bc4189ad163614d93b21a1f98bb9732b8260f65f6432eb7aab57ad0bdd95eed3e
@@ -1 +0,0 @@
1
- 720ac07ecd122cf219df7f41e06e9c30f3ccaacb7dc721b359979c3d7dd2092d
@@ -1 +0,0 @@
1
- 4435097b976e7ffae1ccb63bca65ab8f0cd97ffe24ab076debe86f552d949f726586075ead478395aea43e66bb0fcb89adb92cc60a3f10ec236c480ba22cc88d
@@ -1 +0,0 @@
1
- 986dc06fa1a306980e3c3fab2e618b52657a70b5fbfcd988d308ad8b2000180c
@@ -1 +0,0 @@
1
- 06fd6d39e633d3907c63cc96f747dd751c186888b22db7f166a6ab9fd794983256a26a66f098c98b615496624b2e25d35663bb4a7d7063995808fb9df0d9c45d