expo-observe 57.0.4 → 57.0.6

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 (91) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/android/src/main/java/expo/modules/observe/DispatchUtils.kt +246 -0
  3. package/android/src/main/java/expo/modules/observe/EventDispatcher.kt +59 -31
  4. package/android/src/main/java/expo/modules/observe/ObservabilityManager.kt +113 -7
  5. package/android/src/main/java/expo/modules/observe/ObserveModule.kt +14 -1
  6. package/android/src/main/java/expo/modules/observe/OpenTelemetry.kt +28 -0
  7. package/build/index.d.ts +1 -1
  8. package/build/index.d.ts.map +1 -1
  9. package/build/integrations/expo-router/emitTTI.d.ts +2 -0
  10. package/build/integrations/expo-router/emitTTI.d.ts.map +1 -1
  11. package/build/integrations/expo-router/init.d.ts +3 -1
  12. package/build/integrations/expo-router/init.d.ts.map +1 -1
  13. package/build/integrations/expo-router/useObserveForRouter.d.ts.map +1 -1
  14. package/build/integrations/navigationConfig.d.ts +19 -0
  15. package/build/integrations/navigationConfig.d.ts.map +1 -0
  16. package/build/integrations/react-navigation/emitTTI.d.ts +1 -0
  17. package/build/integrations/react-navigation/emitTTI.d.ts.map +1 -1
  18. package/build/integrations/react-navigation/handleStateChange.d.ts.map +1 -1
  19. package/build/integrations/react-navigation/init.d.ts +3 -1
  20. package/build/integrations/react-navigation/init.d.ts.map +1 -1
  21. package/build/integrations/react-navigation/useObserveForReactNavigation.d.ts.map +1 -1
  22. package/build/module.d.ts.map +1 -1
  23. package/build/module.web.d.ts +3 -2
  24. package/build/module.web.d.ts.map +1 -1
  25. package/build/types.d.ts +34 -4
  26. package/build/types.d.ts.map +1 -1
  27. package/expo-module.config.json +1 -1
  28. package/ios/DispatchUtils.swift +317 -0
  29. package/ios/Observability.swift +100 -57
  30. package/ios/ObserveModule.swift +13 -0
  31. package/ios/OpenTelemetry.swift +28 -0
  32. package/ios/Tests/DispatchUtilsBackoffTests.swift +100 -0
  33. package/ios/Tests/DispatchUtilsNextCursorTests.swift +96 -0
  34. package/ios/Tests/DispatchUtilsRetryGateTests.swift +182 -0
  35. package/ios/Tests/ObservabilityClassifyResponseTests.swift +369 -0
  36. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6-sources.jar +0 -0
  37. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6-sources.jar.md5 +1 -0
  38. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6-sources.jar.sha1 +1 -0
  39. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6-sources.jar.sha256 +1 -0
  40. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6-sources.jar.sha512 +1 -0
  41. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.aar +0 -0
  42. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.aar.md5 +1 -0
  43. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.aar.sha1 +1 -0
  44. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.aar.sha256 +1 -0
  45. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.aar.sha512 +1 -0
  46. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{57.0.4/expo.modules.observe-57.0.4.module → 57.0.6/expo.modules.observe-57.0.6.module} +25 -25
  47. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.module.md5 +1 -0
  48. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.module.sha1 +1 -0
  49. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.module.sha256 +1 -0
  50. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.module.sha512 +1 -0
  51. package/local-maven-repo/expo/modules/observe/expo.modules.observe/{57.0.4/expo.modules.observe-57.0.4.pom → 57.0.6/expo.modules.observe-57.0.6.pom} +4 -4
  52. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.pom.md5 +1 -0
  53. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.pom.sha1 +1 -0
  54. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.pom.sha256 +1 -0
  55. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.6/expo.modules.observe-57.0.6.pom.sha512 +1 -0
  56. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml +4 -4
  57. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.md5 +1 -1
  58. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha1 +1 -1
  59. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha256 +1 -1
  60. package/local-maven-repo/expo/modules/observe/expo.modules.observe/maven-metadata.xml.sha512 +1 -1
  61. package/package.json +5 -5
  62. package/src/index.ts +2 -0
  63. package/src/integrations/expo-router/emitTTI.ts +8 -1
  64. package/src/integrations/expo-router/init.ts +13 -2
  65. package/src/integrations/expo-router/useObserveForRouter.ts +18 -3
  66. package/src/integrations/navigationConfig.ts +75 -0
  67. package/src/integrations/react-navigation/emitTTI.ts +2 -1
  68. package/src/integrations/react-navigation/handleStateChange.ts +10 -5
  69. package/src/integrations/react-navigation/init.ts +8 -1
  70. package/src/integrations/react-navigation/useObserveForReactNavigation.ts +9 -4
  71. package/src/module.ts +8 -3
  72. package/src/module.web.ts +11 -2
  73. package/src/types.ts +34 -4
  74. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4-sources.jar +0 -0
  75. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4-sources.jar.md5 +0 -1
  76. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4-sources.jar.sha1 +0 -1
  77. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4-sources.jar.sha256 +0 -1
  78. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4-sources.jar.sha512 +0 -1
  79. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.aar +0 -0
  80. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.aar.md5 +0 -1
  81. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.aar.sha1 +0 -1
  82. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.aar.sha256 +0 -1
  83. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.aar.sha512 +0 -1
  84. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.module.md5 +0 -1
  85. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.module.sha1 +0 -1
  86. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.module.sha256 +0 -1
  87. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.module.sha512 +0 -1
  88. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.pom.md5 +0 -1
  89. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.pom.sha1 +0 -1
  90. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.pom.sha256 +0 -1
  91. package/local-maven-repo/expo/modules/observe/expo.modules.observe/57.0.4/expo.modules.observe-57.0.4.pom.sha512 +0 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,24 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.6 — 2026-07-15
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 57.0.5 — 2026-07-15
18
+
19
+ ### 🎉 New features
20
+
21
+ - Expose configure event ([#47388](https://github.com/expo/expo/pull/47388) by [@Ubax](https://github.com/Ubax))
22
+ - Add `filteredParams` configuration option to navigation integrations ([#47488](https://github.com/expo/expo/pull/47488) by [@Ubax](https://github.com/Ubax))
23
+
24
+ ### 🐛 Bug fixes
25
+
26
+ - Fix non-serializable route params issue ([#47497](https://github.com/expo/expo/pull/47497) by [@Ubax](https://github.com/Ubax))
27
+ - [Android] Fix `logEvent` not being forwarded to the AppMetrics module through the native module proxy. ([@Ubax](https://github.com/Ubax)) ([#47766](https://github.com/expo/expo/pull/47766) by [@Ubax](https://github.com/Ubax))
28
+ - [iOS] Adjust dispatch code to comply with OTLP retry spec. ([#47159](https://github.com/expo/expo/pull/47159) by [@douglowder](https://github.com/douglowder))
29
+ - [Android] Adjust dispatch code to comply with OTLP retry spec. ([@douglowder](https://github.com/douglowder)) ([#47160](https://github.com/expo/expo/pull/47160) by [@douglowder](https://github.com/douglowder))
30
+
13
31
  ## 57.0.4 — 2026-07-07
14
32
 
15
33
  _This version does not introduce any user-facing changes._
@@ -0,0 +1,246 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ package expo.modules.observe
4
+
5
+ import expo.modules.appmetrics.utils.TimeUtils
6
+ import kotlinx.serialization.json.Json
7
+ import java.text.SimpleDateFormat
8
+ import java.util.Locale
9
+ import java.util.TimeZone
10
+ import kotlin.math.pow
11
+ import kotlin.random.Random
12
+
13
+ /**
14
+ * Outcome of a single dispatch attempt to the OTLP endpoint. Four cases, modeled after the
15
+ * OTLP retry guidance (see https://opentelemetry.io/docs/specs/otlp/#otlphttp-response):
16
+ *
17
+ * - `Success` — server accepted the batch without rejections.
18
+ * - `PartialSuccess` — server accepted the batch but rejected some records (the OTLP
19
+ * `partial_success` body with `rejectedCount > 0`). The rows DID land on the server, so
20
+ * pending-ID removal and counter reset match `Success`; the case stays distinct so the
21
+ * call site can log the rejected count and `errorMessage` clearly rather than describing
22
+ * it as a drop.
23
+ * - `RetryableFailure` — transient failure (429/502/503/504 or transport error); retry
24
+ * the same batch after `retryAfterMs` or a client-computed backoff.
25
+ * - `NonRetryableFailure` — permanent failure (4xx/5xx outside the retryable set, encoding
26
+ * error); drop the batch so it can't wedge the queue.
27
+ *
28
+ * `RetryableFailure.retryAfterMs` carries the parsed `Retry-After` header value in
29
+ * milliseconds (or `null` when the server didn't supply one). `NonRetryableFailure.reason`
30
+ * carries a short diagnostic string for the warn log.
31
+ */
32
+ sealed class DispatchResult {
33
+ object Success : DispatchResult()
34
+ data class PartialSuccess(val partial: OTPartialSuccess) : DispatchResult()
35
+ data class RetryableFailure(val retryAfterMs: Long? = null) : DispatchResult()
36
+ data class NonRetryableFailure(val reason: String) : DispatchResult()
37
+ }
38
+
39
+ /**
40
+ * HTTP response classification + Retry-After parsing for `EventDispatcher`. Lives in its own
41
+ * file mirroring `DispatchUtils.swift` on the iOS side. All functions are pure so they can be
42
+ * unit-tested without instantiating the dispatcher or hitting the network.
43
+ */
44
+ object DispatchUtils {
45
+ /**
46
+ * Pure classifier that maps an HTTP response into one of three retry outcomes. Extracted
47
+ * from the dispatch call site so the OTLP-spec rules can be unit-tested without a real
48
+ * network call.
49
+ *
50
+ * `bodyExcerpt` is invoked lazily, only when the result is `NonRetryable` and the reason
51
+ * string benefits from including a peek at the response body. Lets the caller bound how
52
+ * much data we slurp into the log line.
53
+ */
54
+ fun classifyResponse(
55
+ statusCode: Int,
56
+ retryAfterHeader: String?,
57
+ responseBody: String?,
58
+ bodyExcerpt: () -> String = { "" }
59
+ ): DispatchResult {
60
+ val retryAfter = parseRetryAfter(retryAfterHeader)
61
+
62
+ if (statusCode in 200..299) {
63
+ // The OTLP spec allows `partial_success` to carry a warning-only payload —
64
+ // `rejectedCount == 0` with a non-empty `errorMessage`. Treat that as a successful send
65
+ // (the records DID land) so we don't double-drop. When `rejectedCount > 0`, the records
66
+ // still landed server-side but a subset was rejected; surface that as `PartialSuccess`
67
+ // so the caller can log the count and message clearly. Pending-ID removal and gate
68
+ // behavior for `PartialSuccess` match `Success`.
69
+ val partial = responseBody?.takeIf { it.isNotBlank() }?.let { body ->
70
+ runCatching {
71
+ responseJson.decodeFromString(OTServiceResponse.serializer(), body).partialSuccess
72
+ }.getOrNull()
73
+ }
74
+ if (partial != null && partial.rejectedCount > 0) {
75
+ return DispatchResult.PartialSuccess(partial)
76
+ }
77
+ return DispatchResult.Success
78
+ }
79
+
80
+ return when (statusCode) {
81
+ 429, 502, 503, 504 -> DispatchResult.RetryableFailure(retryAfter)
82
+ else -> {
83
+ val excerpt = bodyExcerpt()
84
+ val suffix = if (excerpt.isEmpty()) "" else ": $excerpt"
85
+ DispatchResult.NonRetryableFailure(reason = "HTTP $statusCode$suffix")
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Whether the dispatch caller should remove the just-sent pending IDs from the queue.
92
+ *
93
+ * - `Success` and `PartialSuccess` remove them — the rows have been accepted by the server
94
+ * (partial success rejects a subset server-side, but the bytes still landed; re-sending
95
+ * them would just re-trip the same rejection).
96
+ * - `NonRetryableFailure` ALSO removes them — the server has refused these rows
97
+ * permanently, so retrying would produce the same answer; removing them drops the batch
98
+ * so it can't wedge subsequent rounds. This is the acceptance-criterion behavior: a
99
+ * 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
+ */
102
+ fun shouldRemovePending(result: DispatchResult): Boolean = when (result) {
103
+ is DispatchResult.Success,
104
+ is DispatchResult.PartialSuccess,
105
+ is DispatchResult.NonRetryableFailure -> true
106
+ is DispatchResult.RetryableFailure -> false
107
+ }
108
+
109
+ /**
110
+ * Parses an HTTP `Retry-After` header into a delay in milliseconds from now. Accepts both
111
+ * formats permitted by RFC 7231: an integer delta-seconds, or an HTTP-date.
112
+ * Returns `null` if the header is absent or unparseable, so the caller can fall through to
113
+ * `computeBackoffDelay` for a client-driven delay.
114
+ *
115
+ * A successfully parsed value is clamped to `[base, cap]` so a misbehaving server can't
116
+ * drive us to either extreme: a value below `base` (including `0`, negatives, or HTTP-dates
117
+ * in the past) floors to `base` so we don't hammer; a value above `cap` (or a date far in
118
+ * the future) ceilings to `cap` so we don't snooze for hours. Non-finite floating-point
119
+ * values (`Infinity`, `NaN`) are treated as garbage and return `null`.
120
+ */
121
+ fun parseRetryAfter(
122
+ header: String?,
123
+ base: Long = backoffBaseMs,
124
+ cap: Long = backoffCapMs
125
+ ): Long? {
126
+ val raw = header?.trim() ?: return null
127
+ if (raw.isEmpty()) return null
128
+
129
+ raw.toLongOrNull()?.let { return clampToBounds(it * 1000L, base, cap) }
130
+ raw.toDoubleOrNull()?.let {
131
+ if (!it.isFinite()) return null
132
+ return clampToBounds((it * 1000L).toLong(), base, cap)
133
+ }
134
+
135
+ // IMF-fixdate only; others fall through to backoff
136
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Date#syntax
137
+ val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
138
+ formatter.timeZone = TimeZone.getTimeZone("GMT")
139
+ val parsed = runCatching { formatter.parse(raw) }.getOrNull()
140
+ if (parsed != null) {
141
+ return clampToBounds(TimeUtils.millisUntil(parsed), base, cap)
142
+ }
143
+ return null
144
+ }
145
+
146
+ /**
147
+ * Clamps a server-supplied retry delay into the client's `[base, cap]` window. Used by
148
+ * `parseRetryAfter` so the gate is bounded regardless of what the server sent.
149
+ */
150
+ private fun clampToBounds(ms: Long, base: Long, cap: Long): Long = ms.coerceIn(base, cap)
151
+
152
+ /**
153
+ * First `limit` bytes of the response body, for inclusion in error log lines. Bounded so a
154
+ * giant HTML error page doesn't flood the log; truncated mid-codepoint is acceptable for a
155
+ * diagnostic excerpt.
156
+ */
157
+ internal fun bodyExcerpt(body: String?, limit: Int = 512): String {
158
+ if (body.isNullOrEmpty()) return ""
159
+ return if (body.length <= limit) body else body.substring(0, limit)
160
+ }
161
+
162
+ private val responseJson = Json { ignoreUnknownKeys = true }
163
+
164
+ // MARK: -- Retry gate + exponential backoff
165
+
166
+ /**
167
+ * Base delay (in ms) for the exponential backoff when the server doesn't supply a
168
+ * `Retry-After`. The cap (`backoffCapMs`) bounds the worst-case wait so we don't end up
169
+ * snoozing for hours after a long string of failures.
170
+ */
171
+ const val backoffBaseMs: Long = 60_000L
172
+ const val backoffCapMs: Long = 900_000L
173
+
174
+ /**
175
+ * Computes a backoff delay (in ms) for the next dispatch attempt when the server didn't
176
+ * supply `Retry-After`. Exponential growth (base × 2^(attempt-1)), capped, with full jitter
177
+ * so a fleet of devices recovering from the same transient backend outage doesn't
178
+ * thunder-herd the recovery.
179
+ *
180
+ * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
181
+ *
182
+ * `attempt` is 1-based; the helper is defensive for `0` / negative inputs and returns 0
183
+ * rather than producing a negative exponent.
184
+ */
185
+ fun computeBackoffDelay(
186
+ attempt: Int,
187
+ base: Long = backoffBaseMs,
188
+ cap: Long = backoffCapMs,
189
+ random: () -> Double = { Random.nextDouble() }
190
+ ): Long {
191
+ if (attempt < 1) return 0L
192
+ val unjittered = minOf((base.toDouble() * 2.0.pow(attempt - 1)).toLong(), cap)
193
+ return (unjittered.toDouble() * random()).toLong()
194
+ }
195
+
196
+ /**
197
+ * Snapshot of the retry-gate state carried across dispatch rounds. `dispatchAfterMs` is the
198
+ * wall-clock deadline (ms since epoch) before which the dispatch entry point should
199
+ * short-circuit (set by a retryable response, naturally expires); `consecutiveRetryableFailures`
200
+ * is the counter that drives `computeBackoffDelay` when the server didn't supply a
201
+ * `Retry-After`.
202
+ */
203
+ data class RetryGateState(
204
+ val dispatchAfterMs: Long?,
205
+ val consecutiveRetryableFailures: Int
206
+ ) {
207
+ companion object {
208
+ val initial = RetryGateState(dispatchAfterMs = null, consecutiveRetryableFailures = 0)
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Pure helper that computes the next `RetryGateState` after a single dispatch result.
214
+ *
215
+ * - `Success` and `PartialSuccess` reset the counter to 0 and leave the gate alone. The
216
+ * gate either already expired (we wouldn't have dispatched otherwise) or was never set
217
+ * — either way, a server response that ACCEPTED the bytes (even if it rejected a subset
218
+ * server-side) doesn't introduce a new pause.
219
+ * - `NonRetryableFailure` also resets the counter. A permanent drop isn't a sign that the
220
+ * server is unhealthy and shouldn't pause subsequent rounds.
221
+ * - `RetryableFailure` increments the counter and sets the gate to `now + delay`, where
222
+ * `delay` is the server-supplied `retryAfterMs` if present, otherwise `backoff(nextCount)`.
223
+ *
224
+ * `backoff` is injected so tests can drive it deterministically without going through
225
+ * `computeBackoffDelay`'s random source.
226
+ */
227
+ fun nextRetryGateState(
228
+ result: DispatchResult,
229
+ currentState: RetryGateState,
230
+ now: Long,
231
+ backoff: (Int) -> Long
232
+ ): RetryGateState = when (result) {
233
+ is DispatchResult.Success,
234
+ is DispatchResult.PartialSuccess,
235
+ is DispatchResult.NonRetryableFailure ->
236
+ currentState.copy(consecutiveRetryableFailures = 0)
237
+ is DispatchResult.RetryableFailure -> {
238
+ val nextCount = currentState.consecutiveRetryableFailures + 1
239
+ val delay = result.retryAfterMs ?: backoff(nextCount)
240
+ RetryGateState(
241
+ dispatchAfterMs = now + delay,
242
+ consecutiveRetryableFailures = nextCount
243
+ )
244
+ }
245
+ }
246
+ }
@@ -9,7 +9,6 @@ import okhttp3.OkHttpClient
9
9
  import okhttp3.Request
10
10
  import okhttp3.RequestBody.Companion.toRequestBody
11
11
  import kotlin.coroutines.resume
12
- import kotlin.coroutines.resumeWithException
13
12
 
14
13
  // TODO: Make this safe for concurrent usage
15
14
  class EventDispatcher(
@@ -28,60 +27,61 @@ class EventDispatcher(
28
27
 
29
28
  private fun logsEndpointUrl(): String = "$baseProjectUrl/v1/logs"
30
29
 
31
- suspend fun dispatch(events: List<Event>): Boolean =
30
+ /**
31
+ * POSTs `events` to the metrics endpoint and classifies the response per the OTLP retry
32
+ * spec. Non-throwing: payload-encoding errors collapse to `NonRetryable` (same bytes will
33
+ * fail again), transport errors collapse to `Retryable(null)` (transient by definition).
34
+ */
35
+ suspend fun dispatch(events: List<Event>): DispatchResult =
32
36
  suspendCancellableCoroutine { continuation ->
33
37
  if (events.isEmpty()) {
34
- continuation.resume(false)
38
+ // Empty input isn't a server response - signal success.
39
+ continuation.resume(DispatchResult.Success)
35
40
  return@suspendCancellableCoroutine Unit
36
41
  }
37
42
  val easId = EASClientID(context).uuid.toString()
38
- try {
39
- val body = OTRequestBody(
43
+ val body = try {
44
+ OTRequestBody(
40
45
  resourceMetrics = events.map { it.toOTEvent(easId) }
41
46
  ).toJson(prettyPrint = true)
42
-
43
- executePost(continuation, metricsEndpointUrl(), body)
44
47
  } catch (e: Exception) {
45
- Log.w(
46
- TAG,
47
- "Dispatching the events has thrown an error: ${e.message}"
48
- )
49
- continuation.resumeWithException(e)
48
+ Log.w(OBSERVE_TAG, "Encoding metrics request body failed: ${e.message}")
49
+ continuation.resume(DispatchResult.NonRetryableFailure("encoding error: ${e.message}"))
50
+ return@suspendCancellableCoroutine Unit
50
51
  }
52
+ executePost(continuation, metricsEndpointUrl(), body)
51
53
  }
52
54
 
53
55
  /**
54
56
  * Dispatches log records to `{baseUrl}/{projectId}/v1/logs`. Always uses the
55
57
  * OTLP wire shape — there is no legacy logs endpoint.
56
58
  */
57
- suspend fun dispatchLogs(events: List<Event>): Boolean =
59
+ suspend fun dispatchLogs(events: List<Event>): DispatchResult =
58
60
  suspendCancellableCoroutine { continuation ->
59
61
  val easId = EASClientID(context).uuid.toString()
60
62
  val resourceLogs = events
61
63
  .filter { it.logs.isNotEmpty() }
62
64
  .map { it.toOTResourceLogs(easId) }
63
65
  if (resourceLogs.isEmpty()) {
64
- continuation.resume(false)
66
+ continuation.resume(DispatchResult.Success)
65
67
  return@suspendCancellableCoroutine Unit
66
68
  }
67
- try {
68
- val body = OTLogsRequestBody(resourceLogs = resourceLogs).toJson(prettyPrint = true)
69
- executePost(continuation, logsEndpointUrl(), body)
69
+ val body = try {
70
+ OTLogsRequestBody(resourceLogs = resourceLogs).toJson(prettyPrint = true)
70
71
  } catch (e: Exception) {
71
- Log.w(
72
- TAG,
73
- "Dispatching the logs has thrown an error: ${e.message}"
74
- )
75
- continuation.resumeWithException(e)
72
+ Log.w(OBSERVE_TAG, "Encoding logs request body failed: ${e.message}")
73
+ continuation.resume(DispatchResult.NonRetryableFailure("encoding error: ${e.message}"))
74
+ return@suspendCancellableCoroutine Unit
76
75
  }
76
+ executePost(continuation, logsEndpointUrl(), body)
77
77
  }
78
78
 
79
79
  private fun executePost(
80
- continuation: kotlinx.coroutines.CancellableContinuation<Boolean>,
80
+ continuation: kotlinx.coroutines.CancellableContinuation<DispatchResult>,
81
81
  endpointUrl: String,
82
82
  body: String
83
83
  ) {
84
- Log.d(TAG, "Sending events to $endpointUrl")
84
+ Log.d(OBSERVE_TAG, "Sending events to $endpointUrl")
85
85
 
86
86
  val request = Request
87
87
  .Builder()
@@ -96,7 +96,7 @@ class EventDispatcher(
96
96
  .post(body.toRequestBody("application/json".toMediaType()))
97
97
  .build()
98
98
 
99
- Log.d(TAG, body)
99
+ Log.d(OBSERVE_TAG, body)
100
100
 
101
101
  val call = httpClient.newCall(request)
102
102
 
@@ -104,13 +104,41 @@ class EventDispatcher(
104
104
  call.cancel()
105
105
  }
106
106
 
107
- val response = call.execute()
108
- Log.d(TAG, "Server responded with: ${response.body?.string()}")
107
+ val response = try {
108
+ call.execute()
109
+ } catch (e: Exception) {
110
+ Log.w(OBSERVE_TAG, "Transport error talking to $endpointUrl: ${e.message}")
111
+ continuation.resume(DispatchResult.RetryableFailure(retryAfterMs = null))
112
+ return
113
+ }
109
114
 
110
- continuation.resume(response.code in 200..299)
111
- }
115
+ val responseBody = response.body?.string()
116
+ val retryAfterHeader = response.header("Retry-After")
117
+ val result = DispatchUtils.classifyResponse(
118
+ statusCode = response.code,
119
+ retryAfterHeader = retryAfterHeader,
120
+ responseBody = responseBody,
121
+ bodyExcerpt = { DispatchUtils.bodyExcerpt(responseBody) }
122
+ )
123
+
124
+ when (result) {
125
+ is DispatchResult.Success ->
126
+ Log.d(OBSERVE_TAG, "Server responded successfully with ${response.code} and data: $responseBody")
127
+ is DispatchResult.PartialSuccess ->
128
+ Log.w(
129
+ OBSERVE_TAG,
130
+ "Server responded with ${response.code} (partial success, rejected " +
131
+ "${result.partial.rejectedCount}: ${result.partial.errorMessage ?: "no error message"}) "
132
+ )
133
+ is DispatchResult.RetryableFailure ->
134
+ Log.w(OBSERVE_TAG, "Server responded with ${response.code} (retryable) and data: $responseBody")
135
+ is DispatchResult.NonRetryableFailure ->
136
+ Log.w(
137
+ OBSERVE_TAG,
138
+ "Server responded with ${response.code} (non-retryable, ${result.reason}) and data: $responseBody"
139
+ )
140
+ }
112
141
 
113
- companion object {
114
- private const val TAG = "EasObserve"
142
+ continuation.resume(result)
115
143
  }
116
144
  }
@@ -1,11 +1,15 @@
1
1
  package expo.modules.observe
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Log
4
5
  import expo.modules.easclient.EASClientID
5
6
  import expo.modules.observe.storage.PendingLogsManager
6
7
  import expo.modules.observe.storage.PendingMetricsManager
7
8
  import expo.modules.appmetrics.storage.SessionManager
9
+ import expo.modules.appmetrics.utils.TimeUtils
8
10
  import expo.modules.interfaces.constants.ConstantsInterface
11
+ import kotlinx.coroutines.sync.Mutex
12
+ import kotlinx.coroutines.sync.withLock
9
13
 
10
14
  class ObservabilityManager(
11
15
  // TODO(@lukmccall): Consider saving context as weak reference to avoid potential memory leaks
@@ -76,7 +80,8 @@ class BaseObservabilityManager(
76
80
  private val isDebugBuild: Boolean = false,
77
81
  private val deterministicUniformValueProvider: () -> Double = {
78
82
  EASClientID.deterministicUniformValue(EASClientID(context).uuid)
79
- }
83
+ },
84
+ private val currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() }
80
85
  ) {
81
86
  private val eventDispatcher = EventDispatcher(
82
87
  context = context,
@@ -84,12 +89,75 @@ class BaseObservabilityManager(
84
89
  baseUrl = baseUrl
85
90
  )
86
91
 
87
- suspend fun dispatchUnsentMetrics() {
92
+ /**
93
+ * In-memory retry-gate state, kept independently per OTLP endpoint. The `/v1/metrics` and
94
+ * `/v1/logs` endpoints fail independently in practice (one schema validation disagreement
95
+ * on the metrics side shouldn't suppress a healthy logs stream), so each signal carries
96
+ * its own consecutive-failure counter and dispatch-after deadline. A single shared field
97
+ * would conflate the two: a recovering signal would reset the other's counter on success,
98
+ * and a server's `Retry-After` on one endpoint would silently overwrite a longer backoff
99
+ * computed for the other.
100
+ *
101
+ * State is reset implicitly when the process restarts — a relaunch usually means enough
102
+ * time passed that the transient cause has cleared anyway, and persisting the gates would
103
+ * mean a disk write per retryable response.
104
+ */
105
+ private var metricsRetryGate: DispatchUtils.RetryGateState = DispatchUtils.RetryGateState.initial
106
+ private var logsRetryGate: DispatchUtils.RetryGateState = DispatchUtils.RetryGateState.initial
107
+
108
+ /**
109
+ * Per-signal mutexes that serialize same-signal dispatch calls. Two `dispatchEvents`
110
+ * invocations from JS can otherwise land on the same `BaseObservabilityManager` instance
111
+ * concurrently and race on the gate's read-modify-write (and double-POST the same pending
112
+ * rows). The metrics and logs paths take separate mutexes so they can still run in
113
+ * parallel — only same-signal calls serialize.
114
+ *
115
+ * Worst case without these would be benign (a dropped gate update or a duplicate dispatch),
116
+ * since the pending-ID stores are already DB-backed and telemetry is best-effort — but the
117
+ * gate is in-memory state with no other synchronization, so close the race explicitly.
118
+ */
119
+ private val metricsDispatchMutex = Mutex()
120
+ private val logsDispatchMutex = Mutex()
121
+
122
+ /**
123
+ * Returns true and logs when an active retry gate suppresses this dispatch round. Called
124
+ * inside each per-signal dispatch method rather than at a shared entry point, so a backoff
125
+ * on one endpoint doesn't suppress traffic on the other.
126
+ */
127
+ private fun retryGateBlocks(state: DispatchUtils.RetryGateState, signal: String): Boolean {
128
+ val until = state.dispatchAfterMs ?: return false
129
+ val now = currentTimeMs()
130
+ if (until <= now) return false
131
+ Log.d(OBSERVE_TAG, "$signal dispatch suppressed by retry gate until $until (now $now)")
132
+ return true
133
+ }
134
+
135
+ /**
136
+ * Computes the next gate state for a given current state and dispatch result. Each per-
137
+ * signal call site assigns the return value back to its own field — the manager doesn't
138
+ * share a single mutable state across signals, so the metrics and logs gates can't drift
139
+ * out of sync from cross-signal updates.
140
+ */
141
+ private fun nextGate(
142
+ current: DispatchUtils.RetryGateState,
143
+ result: DispatchResult
144
+ ): DispatchUtils.RetryGateState = DispatchUtils.nextRetryGateState(
145
+ result = result,
146
+ currentState = current,
147
+ now = currentTimeMs(),
148
+ backoff = { DispatchUtils.computeBackoffDelay(it) }
149
+ )
150
+
151
+ suspend fun dispatchUnsentMetrics(): Unit = metricsDispatchMutex.withLock {
88
152
  val pendingIds = pendingMetricsManager.getAllPendingMetricIds()
89
153
  if (pendingIds.isEmpty()) {
90
154
  return
91
155
  }
92
156
 
157
+ if (retryGateBlocks(metricsRetryGate, "metrics")) {
158
+ return
159
+ }
160
+
93
161
  if (!shouldDispatch()) {
94
162
  pendingMetricsManager.removePendingMetrics(pendingIds)
95
163
  return
@@ -115,22 +183,43 @@ class BaseObservabilityManager(
115
183
  )
116
184
  }
117
185
 
118
- if (eventDispatcher.dispatch(events)) {
119
- val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }
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)) {
120
190
  pendingMetricsManager.removePendingMetrics(dispatchedMetricIds)
121
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
206
+ }
122
207
  }
123
208
 
124
209
  /**
125
210
  * Dispatches log events to `/v1/logs`. Independent from the metrics path —
126
211
  * a logs failure doesn't affect the metrics pending table and vice versa.
127
212
  */
128
- suspend fun dispatchUnsentLogs() {
213
+ suspend fun dispatchUnsentLogs(): Unit = logsDispatchMutex.withLock {
129
214
  val pendingIds = pendingLogsManager.getAllPendingLogIds()
130
215
  if (pendingIds.isEmpty()) {
131
216
  return
132
217
  }
133
218
 
219
+ if (retryGateBlocks(logsRetryGate, "logs")) {
220
+ return
221
+ }
222
+
134
223
  if (!shouldDispatch()) {
135
224
  pendingLogsManager.removePendingLogs(pendingIds)
136
225
  return
@@ -158,10 +247,27 @@ class BaseObservabilityManager(
158
247
  )
159
248
  }
160
249
 
161
- if (eventDispatcher.dispatchLogs(events)) {
162
- val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }
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)) {
163
254
  pendingLogsManager.removePendingLogs(dispatchedLogIds)
164
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
270
+ }
165
271
  }
166
272
 
167
273
  private fun isInSample(): Boolean {
@@ -17,7 +17,8 @@ class Config(
17
17
  @Field val environment: String? = null,
18
18
  @Field val dispatchingEnabled: Boolean? = null,
19
19
  @Field val dispatchInDebug: Boolean? = null,
20
- @Field val sampleRate: Double? = null
20
+ @Field val sampleRate: Double? = null,
21
+ @Field val integrations: Map<String, Any?>? = null
21
22
  ) : Record
22
23
 
23
24
  @OptimizedRecord
@@ -33,10 +34,14 @@ class ObserveModule : Module() {
33
34
  private lateinit var observabilityManager: ObservabilityManager
34
35
  private lateinit var appMetricsModule: AppMetricsModule
35
36
 
37
+ private var lastIntegrations: Map<String, Any?> = emptyMap()
38
+
36
39
  override fun definition() =
37
40
  ModuleDefinition {
38
41
  Name("ExpoObserve")
39
42
 
43
+ Events("configure")
44
+
40
45
  OnCreate {
41
46
  appMetricsModule = checkNotNull(appContext.registry.getModule<AppMetricsModule>()) {
42
47
  "AppMetricsModule is required by ObserveModule. Make sure expo-app-metrics is installed."
@@ -72,6 +77,14 @@ class ObserveModule : Module() {
72
77
  val resolvedEnvironment = config.environment
73
78
  ?: ObservePreferences.getBundleDefaults(context)?.environment
74
79
  resolvedEnvironment?.let { appMetricsModule.setEnvironment(it) }
80
+
81
+ // Broadcast the integrations config so integration libraries (e.g. expo-image) can activate.
82
+ lastIntegrations = config.integrations ?: emptyMap()
83
+ this@ObserveModule.sendEvent("configure", mapOf("integrations" to lastIntegrations))
84
+ }
85
+
86
+ Function("getIntegrations") {
87
+ lastIntegrations
75
88
  }
76
89
 
77
90
  Function("setBundleDefaults") { defaults: BundleDefaults ->
@@ -160,6 +160,34 @@ data class OTLogsRequestBody(
160
160
  }
161
161
  }
162
162
 
163
+ // MARK: -- Response shapes
164
+
165
+ /**
166
+ * `partial_success` body emitted by the collector when records were accepted but some were
167
+ * rejected. Mirrors the wire shape the server actually sends (camelCase keys; the metrics
168
+ * endpoint populates `rejectedDataPoints`, the logs endpoint populates `rejectedLogRecords`).
169
+ * The server only emits this block when at least one record was rejected — a fully successful
170
+ * dispatch returns no body.
171
+ *
172
+ * The OTLP spec also allows a warning-only variant where `rejectedCount == 0` and
173
+ * `errorMessage` carries an advisory note (e.g. deprecation warnings). The classifier handles
174
+ * that case as a success since the records did land.
175
+ */
176
+ @Serializable
177
+ data class OTPartialSuccess(
178
+ val rejectedDataPoints: Int? = null,
179
+ val rejectedLogRecords: Int? = null,
180
+ val errorMessage: String? = null
181
+ ) {
182
+ val rejectedCount: Int
183
+ get() = (rejectedDataPoints ?: 0) + (rejectedLogRecords ?: 0)
184
+ }
185
+
186
+ @Serializable
187
+ data class OTServiceResponse(
188
+ val partialSuccess: OTPartialSuccess? = null
189
+ )
190
+
163
191
  /**
164
192
  * OpenTelemetry Semantic Conventions schema URL referenced by the resource on
165
193
  * every dispatched payload. Bumping this constant signals that our attribute
package/build/index.d.ts CHANGED
@@ -6,6 +6,6 @@ export { ObserveRoot } from './ObserveRoot';
6
6
  export { Observe };
7
7
  /** @deprecated Import the named `Observe` export instead. */
8
8
  export default Observe;
9
- export type { ObserveAttribute, ObserveAttributes, ObserveConfig, ObserveIntegrationsConfig, } from './types';
9
+ export type { ObserveAttribute, ObserveAttributes, ObserveConfig, ObserveIntegrationsConfig, ObserveModule, ObserveModuleEvents, } from './types';
10
10
  export { useObserve } from './useObserve';
11
11
  //# sourceMappingURL=index.d.ts.map