@goliapkg/sentori-react-native 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -0,0 +1,476 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriTransport.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import java.io.File
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+ import java.util.concurrent.Executors
10
+ import java.util.concurrent.ScheduledExecutorService
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+ import org.json.JSONArray
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * The only place the SDK talks to the network.
18
+ *
19
+ * Ported from `sdk/react-native/src/transport.ts` and matching the
20
+ * Swift one: batch on a 5 s timer or a 10-deep queue, whichever comes
21
+ * first; three attempts with a doubling delay; a 429 waits out
22
+ * `retryAfterMs`; anything left after that spills to disk and drains
23
+ * on the next launch.
24
+ *
25
+ * The iron rule is harder here than in JavaScript. A JS verb returns
26
+ * because everything after it is a microtask; the JVM has no such
27
+ * floor, so "fire and forget" is a real queue on a real background
28
+ * thread. [enqueue] does one bounded append under a lock and returns —
29
+ * no encoding, no I/O.
30
+ *
31
+ * `HttpURLConnection` rather than OkHttp: a monitoring SDK that drags
32
+ * a networking library into every host app has already broken the
33
+ * footprint half of its own contract, and OkHttp would also mean this
34
+ * SDK's traffic goes through the same interceptors the host installed
35
+ * for its own — which is a surprising place for a crash report to be
36
+ * logged, retried or blocked.
37
+ */
38
+ object SentoriTransport {
39
+
40
+ private const val FLUSH_INTERVAL_MS = 5_000L
41
+ private const val BATCH_SIZE = 10
42
+ private const val MAX_RETRY = 3
43
+ private const val MAX_PERSISTED = 1000
44
+
45
+ /**
46
+ * Bounded, because an unbounded in-memory queue is a leak with a
47
+ * nicer name. Past it the oldest go: a crash from ten minutes ago
48
+ * matters less than the one happening now.
49
+ */
50
+ private const val MAX_QUEUED = 500
51
+
52
+ private val lock = Any()
53
+
54
+ /**
55
+ * Work that may only run once the server has taken particular
56
+ * events.
57
+ *
58
+ * Attachments are the reason: the server keys one on an event id
59
+ * it must already know, so uploading before that event lands is a
60
+ * guaranteed 404.
61
+ *
62
+ * Keyed on ids rather than "the next delivery", which was the
63
+ * first version and had a race at each end. `flush` hands the send
64
+ * to a worker and returns, so registering afterwards can miss a
65
+ * batch that has already come back — on a fast network the uploads
66
+ * then never happen at all, silently, which is the bug this exists
67
+ * to prevent. Registering beforehand instead lets an unrelated
68
+ * batch already in flight fire the block early, into a 404.
69
+ * Matching on ids has neither end.
70
+ */
71
+ private val afterDelivery = mutableListOf<Pair<Set<String>, () -> Unit>>()
72
+
73
+ /**
74
+ * Run [block] once the server has accepted every event in [ids].
75
+ * If the server refuses them outright the block is discarded — they
76
+ * are not there to attach to. A batch that merely spilled keeps its
77
+ * waiters, since the drain retries it under the same ids.
78
+ */
79
+ internal fun afterDelivery(ids: Set<String>, block: () -> Unit) {
80
+ if (ids.isEmpty()) return
81
+ synchronized(lock) { afterDelivery.add(ids to block) }
82
+ }
83
+
84
+ private fun settle(events: List<Map<String, Any?>>, accepted: Boolean) {
85
+ val ids = events.mapNotNull { it["id"] as? String }.toSet()
86
+ if (ids.isEmpty()) return
87
+ val ready = mutableListOf<() -> Unit>()
88
+ synchronized(lock) {
89
+ val kept = mutableListOf<Pair<Set<String>, () -> Unit>>()
90
+ for ((waiting, block) in afterDelivery) {
91
+ if (waiting.none { id -> id in ids }) {
92
+ kept.add(waiting to block)
93
+ continue
94
+ }
95
+ if (!accepted) continue // refused: drop the waiter
96
+ // Subtract what landed rather than asking whether this
97
+ // one batch carried everything. A waiter on two events
98
+ // whose events go out in two batches is otherwise never
99
+ // due — it is a subset of neither.
100
+ val left = waiting - ids
101
+ if (left.isEmpty()) ready.add(block) else kept.add(left to block)
102
+ }
103
+ afterDelivery.clear()
104
+ afterDelivery.addAll(kept)
105
+ }
106
+ ready.forEach { it() }
107
+ }
108
+ private val queue = ArrayDeque<Map<String, Any?>>()
109
+ private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
110
+ private var started = false
111
+ private var dropped = 0
112
+ private var delivered = 0
113
+ private var pending: ScheduledFuture<*>? = null
114
+
115
+ /** Where the spill lives. Set by [start]; null keeps it in memory only. */
116
+ private var spillDir: File? = null
117
+
118
+ private val worker: ScheduledExecutorService =
119
+ Executors.newSingleThreadScheduledExecutor { r ->
120
+ Thread(r, "sentori-transport").apply { isDaemon = true }
121
+ }
122
+
123
+ /** O(1) on the calling thread: append, maybe schedule. */
124
+ @JvmStatic
125
+ fun enqueue(event: Map<String, Any?>) {
126
+ val due: Boolean
127
+ synchronized(lock) {
128
+ queue.addLast(event)
129
+ while (queue.size > MAX_QUEUED) {
130
+ queue.removeFirst()
131
+ dropped += 1
132
+ }
133
+ due = queue.size >= BATCH_SIZE
134
+ }
135
+ if (due) worker.execute { flush() } else scheduleFlush(FLUSH_INTERVAL_MS)
136
+ }
137
+
138
+ /**
139
+ * Assert outcomes aggregate rather than becoming events, and ride
140
+ * whatever batch goes out next — the liveness ledger without a
141
+ * heartbeat flood.
142
+ */
143
+ @JvmStatic
144
+ fun countAssert(name: String, ok: Boolean, release: String) {
145
+ val idle: Boolean
146
+ synchronized(lock) {
147
+ val key = "$name$release"
148
+ val stat =
149
+ assertStats.getOrPut(key) {
150
+ mutableMapOf(
151
+ "name" to name,
152
+ "release" to release,
153
+ "passDelta" to 0,
154
+ "failDelta" to 0,
155
+ )
156
+ }
157
+ val field = if (ok) "passDelta" else "failDelta"
158
+ stat[field] = (stat[field] as Int) + 1
159
+ idle = queue.isEmpty()
160
+ }
161
+ if (idle) scheduleFlush(FLUSH_INTERVAL_MS * 6)
162
+ }
163
+
164
+ @JvmStatic
165
+ @JvmOverloads
166
+ fun start(spill: File? = null) {
167
+ synchronized(lock) {
168
+ started = true
169
+ spillDir = spill
170
+ }
171
+ worker.execute { drainPersisted() }
172
+ }
173
+
174
+ @JvmStatic
175
+ fun flush() {
176
+ // Before touching the queue, not after: draining first and
177
+ // checking second destroys anything enqueued before init.
178
+ val config = SentoriConfig.current ?: return
179
+
180
+ val events: List<Map<String, Any?>>
181
+ val stats: List<Map<String, Any?>>
182
+ val lost: Int
183
+ synchronized(lock) {
184
+ if (!started || (queue.isEmpty() && assertStats.isEmpty())) return
185
+ events = queue.toList()
186
+ stats = assertStats.values.map { it.toMap() }
187
+ lost = dropped
188
+ queue.clear()
189
+ assertStats.clear()
190
+ dropped = 0
191
+ pending?.cancel(false)
192
+ pending = null
193
+ }
194
+
195
+ val envelope = mutableMapOf<String, Any?>("events" to events)
196
+ if (stats.isNotEmpty()) envelope["assertStats"] = stats
197
+ config.backendHealthUrl?.let { envelope["backendHealthUrl"] = it }
198
+ // Say so rather than let the gap look like quiet: a hole in a
199
+ // timeline otherwise reads as a calm period.
200
+ if (lost > 0) envelope["droppedEvents"] = lost
201
+
202
+ worker.execute {
203
+ when (sendWithRetry(envelope, config)) {
204
+ Outcome.DELIVERED -> {
205
+ synchronized(lock) { delivered += events.size }
206
+ settle(events, accepted = true)
207
+ }
208
+ // Handled, but not accepted. Nothing to retry and
209
+ // nothing to count.
210
+ Outcome.DROPPED -> settle(events, accepted = false)
211
+ // Spilled, not lost: `drainPersisted` puts these back
212
+ // through this path on the next start with the same
213
+ // ids, so anything waiting on them keeps waiting.
214
+ else -> persist(events)
215
+ }
216
+ }
217
+ }
218
+
219
+ // ── the network ───────────────────────────────────────────────
220
+
221
+ private enum class Outcome {
222
+ DELIVERED,
223
+ DROPPED,
224
+ RETRY_AFTER,
225
+ FAILED,
226
+ }
227
+
228
+ private var lastRetryAfterMs = 5_000L
229
+
230
+ /**
231
+ * Returns the terminal outcome rather than a bool. "Stop trying"
232
+ * would conflate a 2xx with a 4xx — not worth retrying is not the
233
+ * same as delivered, and a counter built on the bool counts a
234
+ * rejected batch as a success.
235
+ */
236
+ private fun sendWithRetry(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
237
+ var delay = 1_000L
238
+ repeat(MAX_RETRY) { attempt ->
239
+ when (val outcome = sendOnce(envelope, config)) {
240
+ Outcome.DELIVERED,
241
+ Outcome.DROPPED -> return outcome
242
+ Outcome.RETRY_AFTER -> {
243
+ if (attempt == MAX_RETRY - 1) return outcome
244
+ sleep(lastRetryAfterMs)
245
+ }
246
+ Outcome.FAILED -> {
247
+ if (attempt == MAX_RETRY - 1) return outcome
248
+ sleep(delay)
249
+ delay *= 2
250
+ }
251
+ }
252
+ }
253
+ return Outcome.FAILED
254
+ }
255
+
256
+ /**
257
+ * Test seam: skip the network and return this outcome.
258
+ * `0` delivered, `1` dropped, anything else failed.
259
+ *
260
+ * The iOS suite arrived here after two attempts to provoke a real
261
+ * failure by sending to a closed port: one CI runner dropped the
262
+ * packet and waited out the timeout, another answered in a way
263
+ * that read as a 4xx — and both times a working transport was
264
+ * reported broken. What these tests are about is the spill, the
265
+ * drain and the ordering, not TCP; `android-live-ingest` is what
266
+ * exercises the wire.
267
+ */
268
+ internal var forcedOutcomeForTests: Int? = null
269
+
270
+ private fun sendOnce(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
271
+ forcedOutcomeForTests?.let {
272
+ return when (it) {
273
+ 0 -> Outcome.DELIVERED
274
+ 1 -> Outcome.DROPPED
275
+ else -> Outcome.FAILED
276
+ }
277
+ }
278
+ val body =
279
+ try {
280
+ toJson(envelope).toString()
281
+ } catch (_: Throwable) {
282
+ // Unencodable content is our problem, not the host's,
283
+ // and it will not become encodable on a retry.
284
+ return Outcome.DROPPED
285
+ }
286
+
287
+ var conn: HttpURLConnection? = null
288
+ return try {
289
+ conn = URL("${config.ingestUrl}/v1/events:batch").openConnection() as HttpURLConnection
290
+ conn.requestMethod = "POST"
291
+ conn.setRequestProperty("Content-Type", "application/json")
292
+ conn.setRequestProperty("Authorization", "Bearer ${config.token}")
293
+ conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
294
+ conn.connectTimeout = 15_000
295
+ conn.readTimeout = 15_000
296
+ conn.doOutput = true
297
+ conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
298
+
299
+ when (val code = conn.responseCode) {
300
+ in 200..299 -> Outcome.DELIVERED
301
+ 429 -> {
302
+ lastRetryAfterMs =
303
+ try {
304
+ val text = conn.errorStream?.bufferedReader()?.readText().orEmpty()
305
+ JSONObject(text).optLong("retryAfterMs", 5_000L)
306
+ } catch (_: Throwable) {
307
+ 5_000L
308
+ }
309
+ Outcome.RETRY_AFTER
310
+ }
311
+ in 500..599 -> Outcome.FAILED
312
+ else -> {
313
+ // 4xx other than 429: our request is wrong and
314
+ // will be wrong again. Per-item outcomes are the
315
+ // server's business.
316
+ if (code >= 400) Outcome.DROPPED else Outcome.FAILED
317
+ }
318
+ }
319
+ } catch (_: Throwable) {
320
+ Outcome.FAILED
321
+ } finally {
322
+ conn?.disconnect()
323
+ }
324
+ }
325
+
326
+ private fun sleep(ms: Long) {
327
+ try {
328
+ TimeUnit.MILLISECONDS.sleep(ms)
329
+ } catch (_: InterruptedException) {
330
+ Thread.currentThread().interrupt()
331
+ }
332
+ }
333
+
334
+ // ── json ──────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Hand-rolled rather than a serialization library, for the same
338
+ * reason as `HttpURLConnection`: `org.json` is in the platform, so
339
+ * it costs the host nothing.
340
+ */
341
+ internal fun toJson(value: Any?): Any {
342
+ return when (value) {
343
+ null -> JSONObject.NULL
344
+ is Map<*, *> -> {
345
+ val o = JSONObject()
346
+ for ((k, v) in value) o.put(k.toString(), toJson(v))
347
+ o
348
+ }
349
+ is List<*> -> {
350
+ val a = JSONArray()
351
+ for (v in value) a.put(toJson(v))
352
+ a
353
+ }
354
+ is Number, is Boolean, is String -> value
355
+ else -> value.toString()
356
+ }
357
+ }
358
+
359
+ // ── the offline queue ─────────────────────────────────────────
360
+
361
+ private fun spillFile(): File? {
362
+ val dir = synchronized(lock) { spillDir } ?: return null
363
+ if (!dir.exists()) dir.mkdirs()
364
+ return File(dir, "pending-events.json")
365
+ }
366
+
367
+ private fun persist(events: List<Map<String, Any?>>) {
368
+ val file = spillFile() ?: return
369
+ try {
370
+ val all = readPersisted().toMutableList()
371
+ all.addAll(events)
372
+ // Newest wins: the file has to stop growing on a device
373
+ // that is offline for a week.
374
+ while (all.size > MAX_PERSISTED) all.removeAt(0)
375
+ file.writeText(toJson(all).toString())
376
+ } catch (_: Throwable) {
377
+ // A full disk must not become the host's problem.
378
+ }
379
+ }
380
+
381
+ private fun readPersisted(): List<Map<String, Any?>> {
382
+ val file = spillFile() ?: return emptyList()
383
+ if (!file.exists()) return emptyList()
384
+ return try {
385
+ val arr = JSONArray(file.readText())
386
+ (0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
387
+ } catch (_: Throwable) {
388
+ emptyList()
389
+ }
390
+ }
391
+
392
+ private fun fromJson(o: JSONObject): Map<String, Any?> {
393
+ val out = mutableMapOf<String, Any?>()
394
+ for (k in o.keys()) {
395
+ out[k] =
396
+ when (val v = o.get(k)) {
397
+ is JSONObject -> fromJson(v)
398
+ JSONObject.NULL -> null
399
+ else -> v
400
+ }
401
+ }
402
+ return out
403
+ }
404
+
405
+ /**
406
+ * Read the spill, clear it, put the events back through the normal
407
+ * path — clearing first, so a batch that fails again spills once
408
+ * rather than doubling.
409
+ */
410
+ private fun drainPersisted() {
411
+ val pendingEvents = readPersisted()
412
+ if (pendingEvents.isEmpty()) return
413
+ spillFile()?.delete()
414
+ pendingEvents.forEach { enqueue(it) }
415
+ flush()
416
+ }
417
+
418
+ // ── timer ─────────────────────────────────────────────────────
419
+
420
+ private fun scheduleFlush(afterMs: Long) {
421
+ synchronized(lock) {
422
+ if (pending != null) return
423
+ pending =
424
+ worker.schedule(
425
+ {
426
+ synchronized(lock) { pending = null }
427
+ flush()
428
+ },
429
+ afterMs,
430
+ TimeUnit.MILLISECONDS,
431
+ )
432
+ }
433
+ }
434
+
435
+ // ── test seams ────────────────────────────────────────────────
436
+
437
+ internal fun resetForTests() {
438
+ synchronized(lock) {
439
+ spillFile()?.delete()
440
+ queue.clear()
441
+ assertStats.clear()
442
+ dropped = 0
443
+ delivered = 0
444
+ afterDelivery.clear()
445
+ forcedOutcomeForTests = null
446
+ started = false
447
+ pending?.cancel(false)
448
+ pending = null
449
+ spillDir = null
450
+ }
451
+ }
452
+
453
+ internal fun peekQueue(): List<Map<String, Any?>> = synchronized(lock) { queue.toList() }
454
+
455
+ internal fun peekAssertStats(): List<Map<String, Any?>> =
456
+ synchronized(lock) { assertStats.values.map { it.toMap() } }
457
+
458
+ internal fun peekPersisted(): List<Map<String, Any?>> = readPersisted()
459
+
460
+ /**
461
+ * Events the server actually accepted. A test that checks only for
462
+ * the absence of a spill passes while retries are still in flight
463
+ * — which is how the iOS live suite went green against a server
464
+ * that had stored nothing.
465
+ */
466
+ internal fun peekDelivered(): Int = synchronized(lock) { delivered }
467
+
468
+ /**
469
+ * How many blocks are still waiting on events. Without this a test
470
+ * cannot tell "discarded because the server refused it" from "has
471
+ * not been reached yet", and the version that tried instead
472
+ * flipped the forced outcome mid-send — measuring the race rather
473
+ * than the rule.
474
+ */
475
+ internal fun peekWaiting(): Int = synchronized(lock) { afterDelivery.size }
476
+ }