@goliapkg/sentori-react-native 6.1.0 → 6.2.1
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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +15 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +359 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +383 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +106 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/core/SentoriPush.swift +340 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts +7 -6
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +8 -0
- package/lib/push.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +1 -1
- package/src/push.ts +14 -6
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
private val queue = ArrayDeque<Map<String, Any?>>()
|
|
54
|
+
private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
|
|
55
|
+
private var started = false
|
|
56
|
+
private var dropped = 0
|
|
57
|
+
private var delivered = 0
|
|
58
|
+
private var pending: ScheduledFuture<*>? = null
|
|
59
|
+
|
|
60
|
+
/** Where the spill lives. Set by [start]; null keeps it in memory only. */
|
|
61
|
+
private var spillDir: File? = null
|
|
62
|
+
|
|
63
|
+
private val worker: ScheduledExecutorService =
|
|
64
|
+
Executors.newSingleThreadScheduledExecutor { r ->
|
|
65
|
+
Thread(r, "sentori-transport").apply { isDaemon = true }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** O(1) on the calling thread: append, maybe schedule. */
|
|
69
|
+
@JvmStatic
|
|
70
|
+
fun enqueue(event: Map<String, Any?>) {
|
|
71
|
+
val due: Boolean
|
|
72
|
+
synchronized(lock) {
|
|
73
|
+
queue.addLast(event)
|
|
74
|
+
while (queue.size > MAX_QUEUED) {
|
|
75
|
+
queue.removeFirst()
|
|
76
|
+
dropped += 1
|
|
77
|
+
}
|
|
78
|
+
due = queue.size >= BATCH_SIZE
|
|
79
|
+
}
|
|
80
|
+
if (due) worker.execute { flush() } else scheduleFlush(FLUSH_INTERVAL_MS)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Assert outcomes aggregate rather than becoming events, and ride
|
|
85
|
+
* whatever batch goes out next — the liveness ledger without a
|
|
86
|
+
* heartbeat flood.
|
|
87
|
+
*/
|
|
88
|
+
@JvmStatic
|
|
89
|
+
fun countAssert(name: String, ok: Boolean, release: String) {
|
|
90
|
+
val idle: Boolean
|
|
91
|
+
synchronized(lock) {
|
|
92
|
+
val key = "$name$release"
|
|
93
|
+
val stat =
|
|
94
|
+
assertStats.getOrPut(key) {
|
|
95
|
+
mutableMapOf(
|
|
96
|
+
"name" to name,
|
|
97
|
+
"release" to release,
|
|
98
|
+
"passDelta" to 0,
|
|
99
|
+
"failDelta" to 0,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
val field = if (ok) "passDelta" else "failDelta"
|
|
103
|
+
stat[field] = (stat[field] as Int) + 1
|
|
104
|
+
idle = queue.isEmpty()
|
|
105
|
+
}
|
|
106
|
+
if (idle) scheduleFlush(FLUSH_INTERVAL_MS * 6)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@JvmStatic
|
|
110
|
+
@JvmOverloads
|
|
111
|
+
fun start(spill: File? = null) {
|
|
112
|
+
synchronized(lock) {
|
|
113
|
+
started = true
|
|
114
|
+
spillDir = spill
|
|
115
|
+
}
|
|
116
|
+
worker.execute { drainPersisted() }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@JvmStatic
|
|
120
|
+
fun flush() {
|
|
121
|
+
// Before touching the queue, not after: draining first and
|
|
122
|
+
// checking second destroys anything enqueued before init.
|
|
123
|
+
val config = SentoriConfig.current ?: return
|
|
124
|
+
|
|
125
|
+
val events: List<Map<String, Any?>>
|
|
126
|
+
val stats: List<Map<String, Any?>>
|
|
127
|
+
val lost: Int
|
|
128
|
+
synchronized(lock) {
|
|
129
|
+
if (!started || (queue.isEmpty() && assertStats.isEmpty())) return
|
|
130
|
+
events = queue.toList()
|
|
131
|
+
stats = assertStats.values.map { it.toMap() }
|
|
132
|
+
lost = dropped
|
|
133
|
+
queue.clear()
|
|
134
|
+
assertStats.clear()
|
|
135
|
+
dropped = 0
|
|
136
|
+
pending?.cancel(false)
|
|
137
|
+
pending = null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
val envelope = mutableMapOf<String, Any?>("events" to events)
|
|
141
|
+
if (stats.isNotEmpty()) envelope["assertStats"] = stats
|
|
142
|
+
config.backendHealthUrl?.let { envelope["backendHealthUrl"] = it }
|
|
143
|
+
// Say so rather than let the gap look like quiet: a hole in a
|
|
144
|
+
// timeline otherwise reads as a calm period.
|
|
145
|
+
if (lost > 0) envelope["droppedEvents"] = lost
|
|
146
|
+
|
|
147
|
+
worker.execute {
|
|
148
|
+
when (sendWithRetry(envelope, config)) {
|
|
149
|
+
Outcome.DELIVERED -> synchronized(lock) { delivered += events.size }
|
|
150
|
+
// Handled, but not accepted. Nothing to retry and
|
|
151
|
+
// nothing to count.
|
|
152
|
+
Outcome.DROPPED -> Unit
|
|
153
|
+
else -> persist(events)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── the network ───────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
private enum class Outcome {
|
|
161
|
+
DELIVERED,
|
|
162
|
+
DROPPED,
|
|
163
|
+
RETRY_AFTER,
|
|
164
|
+
FAILED,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private var lastRetryAfterMs = 5_000L
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Returns the terminal outcome rather than a bool. "Stop trying"
|
|
171
|
+
* would conflate a 2xx with a 4xx — not worth retrying is not the
|
|
172
|
+
* same as delivered, and a counter built on the bool counts a
|
|
173
|
+
* rejected batch as a success.
|
|
174
|
+
*/
|
|
175
|
+
private fun sendWithRetry(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
|
|
176
|
+
var delay = 1_000L
|
|
177
|
+
repeat(MAX_RETRY) { attempt ->
|
|
178
|
+
when (val outcome = sendOnce(envelope, config)) {
|
|
179
|
+
Outcome.DELIVERED,
|
|
180
|
+
Outcome.DROPPED -> return outcome
|
|
181
|
+
Outcome.RETRY_AFTER -> {
|
|
182
|
+
if (attempt == MAX_RETRY - 1) return outcome
|
|
183
|
+
sleep(lastRetryAfterMs)
|
|
184
|
+
}
|
|
185
|
+
Outcome.FAILED -> {
|
|
186
|
+
if (attempt == MAX_RETRY - 1) return outcome
|
|
187
|
+
sleep(delay)
|
|
188
|
+
delay *= 2
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return Outcome.FAILED
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun sendOnce(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
|
|
196
|
+
val body =
|
|
197
|
+
try {
|
|
198
|
+
toJson(envelope).toString()
|
|
199
|
+
} catch (_: Throwable) {
|
|
200
|
+
// Unencodable content is our problem, not the host's,
|
|
201
|
+
// and it will not become encodable on a retry.
|
|
202
|
+
return Outcome.DROPPED
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
var conn: HttpURLConnection? = null
|
|
206
|
+
return try {
|
|
207
|
+
conn = URL("${config.ingestUrl}/v1/events:batch").openConnection() as HttpURLConnection
|
|
208
|
+
conn.requestMethod = "POST"
|
|
209
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
210
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
211
|
+
conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
|
|
212
|
+
conn.connectTimeout = 15_000
|
|
213
|
+
conn.readTimeout = 15_000
|
|
214
|
+
conn.doOutput = true
|
|
215
|
+
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
216
|
+
|
|
217
|
+
when (val code = conn.responseCode) {
|
|
218
|
+
in 200..299 -> Outcome.DELIVERED
|
|
219
|
+
429 -> {
|
|
220
|
+
lastRetryAfterMs =
|
|
221
|
+
try {
|
|
222
|
+
val text = conn.errorStream?.bufferedReader()?.readText().orEmpty()
|
|
223
|
+
JSONObject(text).optLong("retryAfterMs", 5_000L)
|
|
224
|
+
} catch (_: Throwable) {
|
|
225
|
+
5_000L
|
|
226
|
+
}
|
|
227
|
+
Outcome.RETRY_AFTER
|
|
228
|
+
}
|
|
229
|
+
in 500..599 -> Outcome.FAILED
|
|
230
|
+
else -> {
|
|
231
|
+
// 4xx other than 429: our request is wrong and
|
|
232
|
+
// will be wrong again. Per-item outcomes are the
|
|
233
|
+
// server's business.
|
|
234
|
+
if (code >= 400) Outcome.DROPPED else Outcome.FAILED
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (_: Throwable) {
|
|
238
|
+
Outcome.FAILED
|
|
239
|
+
} finally {
|
|
240
|
+
conn?.disconnect()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun sleep(ms: Long) {
|
|
245
|
+
try {
|
|
246
|
+
TimeUnit.MILLISECONDS.sleep(ms)
|
|
247
|
+
} catch (_: InterruptedException) {
|
|
248
|
+
Thread.currentThread().interrupt()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── json ──────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Hand-rolled rather than a serialization library, for the same
|
|
256
|
+
* reason as `HttpURLConnection`: `org.json` is in the platform, so
|
|
257
|
+
* it costs the host nothing.
|
|
258
|
+
*/
|
|
259
|
+
internal fun toJson(value: Any?): Any {
|
|
260
|
+
return when (value) {
|
|
261
|
+
null -> JSONObject.NULL
|
|
262
|
+
is Map<*, *> -> {
|
|
263
|
+
val o = JSONObject()
|
|
264
|
+
for ((k, v) in value) o.put(k.toString(), toJson(v))
|
|
265
|
+
o
|
|
266
|
+
}
|
|
267
|
+
is List<*> -> {
|
|
268
|
+
val a = JSONArray()
|
|
269
|
+
for (v in value) a.put(toJson(v))
|
|
270
|
+
a
|
|
271
|
+
}
|
|
272
|
+
is Number, is Boolean, is String -> value
|
|
273
|
+
else -> value.toString()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── the offline queue ─────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
private fun spillFile(): File? {
|
|
280
|
+
val dir = synchronized(lock) { spillDir } ?: return null
|
|
281
|
+
if (!dir.exists()) dir.mkdirs()
|
|
282
|
+
return File(dir, "pending-events.json")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private fun persist(events: List<Map<String, Any?>>) {
|
|
286
|
+
val file = spillFile() ?: return
|
|
287
|
+
try {
|
|
288
|
+
val all = readPersisted().toMutableList()
|
|
289
|
+
all.addAll(events)
|
|
290
|
+
// Newest wins: the file has to stop growing on a device
|
|
291
|
+
// that is offline for a week.
|
|
292
|
+
while (all.size > MAX_PERSISTED) all.removeAt(0)
|
|
293
|
+
file.writeText(toJson(all).toString())
|
|
294
|
+
} catch (_: Throwable) {
|
|
295
|
+
// A full disk must not become the host's problem.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private fun readPersisted(): List<Map<String, Any?>> {
|
|
300
|
+
val file = spillFile() ?: return emptyList()
|
|
301
|
+
if (!file.exists()) return emptyList()
|
|
302
|
+
return try {
|
|
303
|
+
val arr = JSONArray(file.readText())
|
|
304
|
+
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
|
|
305
|
+
} catch (_: Throwable) {
|
|
306
|
+
emptyList()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun fromJson(o: JSONObject): Map<String, Any?> {
|
|
311
|
+
val out = mutableMapOf<String, Any?>()
|
|
312
|
+
for (k in o.keys()) {
|
|
313
|
+
out[k] =
|
|
314
|
+
when (val v = o.get(k)) {
|
|
315
|
+
is JSONObject -> fromJson(v)
|
|
316
|
+
JSONObject.NULL -> null
|
|
317
|
+
else -> v
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Read the spill, clear it, put the events back through the normal
|
|
325
|
+
* path — clearing first, so a batch that fails again spills once
|
|
326
|
+
* rather than doubling.
|
|
327
|
+
*/
|
|
328
|
+
private fun drainPersisted() {
|
|
329
|
+
val pendingEvents = readPersisted()
|
|
330
|
+
if (pendingEvents.isEmpty()) return
|
|
331
|
+
spillFile()?.delete()
|
|
332
|
+
pendingEvents.forEach { enqueue(it) }
|
|
333
|
+
flush()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── timer ─────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
private fun scheduleFlush(afterMs: Long) {
|
|
339
|
+
synchronized(lock) {
|
|
340
|
+
if (pending != null) return
|
|
341
|
+
pending =
|
|
342
|
+
worker.schedule(
|
|
343
|
+
{
|
|
344
|
+
synchronized(lock) { pending = null }
|
|
345
|
+
flush()
|
|
346
|
+
},
|
|
347
|
+
afterMs,
|
|
348
|
+
TimeUnit.MILLISECONDS,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── test seams ────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
internal fun resetForTests() {
|
|
356
|
+
synchronized(lock) {
|
|
357
|
+
spillFile()?.delete()
|
|
358
|
+
queue.clear()
|
|
359
|
+
assertStats.clear()
|
|
360
|
+
dropped = 0
|
|
361
|
+
delivered = 0
|
|
362
|
+
started = false
|
|
363
|
+
pending?.cancel(false)
|
|
364
|
+
pending = null
|
|
365
|
+
spillDir = null
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
internal fun peekQueue(): List<Map<String, Any?>> = synchronized(lock) { queue.toList() }
|
|
370
|
+
|
|
371
|
+
internal fun peekAssertStats(): List<Map<String, Any?>> =
|
|
372
|
+
synchronized(lock) { assertStats.values.map { it.toMap() } }
|
|
373
|
+
|
|
374
|
+
internal fun peekPersisted(): List<Map<String, Any?>> = readPersisted()
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Events the server actually accepted. A test that checks only for
|
|
378
|
+
* the absence of a spill passes while retries are still in flight
|
|
379
|
+
* — which is how the iOS live suite went green against a server
|
|
380
|
+
* that had stored nothing.
|
|
381
|
+
*/
|
|
382
|
+
internal fun peekDelivered(): Int = synchronized(lock) { delivered }
|
|
383
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
1
4
|
// Phase 16 sub-E: Robolectric coverage for SentoriCrashHandler (回填 Phase 7).
|
|
2
5
|
//
|
|
3
6
|
// Run via Gradle on the Android host:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriIdentityTest.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import java.io.File
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import org.junit.Assert.assertEquals
|
|
9
|
+
import org.junit.Assert.assertNull
|
|
10
|
+
import org.junit.Assert.assertTrue
|
|
11
|
+
import org.junit.Test
|
|
12
|
+
import org.junit.runner.RunWith
|
|
13
|
+
import org.robolectric.RobolectricTestRunner
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The Kotlin hash must equal the TypeScript one for every input, or a
|
|
17
|
+
* device stops matching the events from the same person and the push
|
|
18
|
+
* meant for them reaches nobody — silently, on the one path where
|
|
19
|
+
* nobody is watching.
|
|
20
|
+
*
|
|
21
|
+
* The vectors are generated by `sdk/core/src/identity.ts` itself
|
|
22
|
+
* (`scripts/gen-identity-vectors.mjs`), so this asserts against what
|
|
23
|
+
* the other implementations actually do rather than against what this
|
|
24
|
+
* file's author believed they do. It has already earned its keep:
|
|
25
|
+
* Kotlin's default `trim()` leaves U+00A0 and U+FEFF in place where
|
|
26
|
+
* JavaScript and Swift strip them, and the vector caught it before it
|
|
27
|
+
* shipped.
|
|
28
|
+
*/
|
|
29
|
+
@RunWith(RobolectricTestRunner::class)
|
|
30
|
+
class SentoriIdentityTest {
|
|
31
|
+
|
|
32
|
+
private fun vectors(): List<Triple<String, String, String>> {
|
|
33
|
+
// Walk up out of the module to the fixtures shared with iOS.
|
|
34
|
+
// Robolectric runs with the module directory as CWD; both the
|
|
35
|
+
// native module and the React Native mirror of it sit three
|
|
36
|
+
// levels below `sdk`.
|
|
37
|
+
var dir = File(System.getProperty("user.dir") ?: ".").absoluteFile
|
|
38
|
+
var fixture: File? = null
|
|
39
|
+
repeat(8) {
|
|
40
|
+
val candidate = File(dir, "sdk/native/fixtures/identity-vectors.json")
|
|
41
|
+
if (candidate.exists()) {
|
|
42
|
+
fixture = candidate
|
|
43
|
+
return@repeat
|
|
44
|
+
}
|
|
45
|
+
dir = dir.parentFile ?: dir
|
|
46
|
+
}
|
|
47
|
+
val f = requireNotNull(fixture) {
|
|
48
|
+
"identity-vectors.json not found above ${System.getProperty("user.dir")}"
|
|
49
|
+
}
|
|
50
|
+
val root = JSONObject(f.readText()).getJSONArray("vectors")
|
|
51
|
+
return (0 until root.length()).map { i ->
|
|
52
|
+
val v = root.getJSONObject(i)
|
|
53
|
+
Triple(v.getString("keyType"), v.getString("raw"), v.getString("sha256Hex"))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
fun matchesTheTypeScriptImplementation() {
|
|
59
|
+
val vectors = vectors()
|
|
60
|
+
// A fixture that read as empty would pass every assertion
|
|
61
|
+
// below without running one.
|
|
62
|
+
assertTrue("fixture looks truncated: ${vectors.size}", vectors.size >= 12)
|
|
63
|
+
for ((keyType, raw, expected) in vectors) {
|
|
64
|
+
assertEquals(
|
|
65
|
+
"$keyType / ${raw.take(40)}",
|
|
66
|
+
expected,
|
|
67
|
+
SentoriIdentity.hash(keyType, raw),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test
|
|
73
|
+
fun emptyAndBlankProduceNoKey() {
|
|
74
|
+
assertNull(SentoriIdentity.hash("id", ""))
|
|
75
|
+
assertNull(SentoriIdentity.hash("id", " "))
|
|
76
|
+
assertNull(SentoriIdentity.hash("id", null))
|
|
77
|
+
assertNull(SentoriIdentity.hash("email", " \n "))
|
|
78
|
+
// A phone with no digits normalises away entirely.
|
|
79
|
+
assertNull(SentoriIdentity.hash("phone", "(--) --"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun userKeyPrefersIdAndFallsBackToEmail() {
|
|
84
|
+
assertEquals(
|
|
85
|
+
SentoriIdentity.hash("id", "usr_123"),
|
|
86
|
+
SentoriIdentity.userKey("usr_123", "a@b.com"),
|
|
87
|
+
)
|
|
88
|
+
assertEquals(
|
|
89
|
+
SentoriIdentity.hash("email", "A@B.com"),
|
|
90
|
+
SentoriIdentity.userKey(null, "A@B.com"),
|
|
91
|
+
)
|
|
92
|
+
// A blank id must fall through rather than win and yield null:
|
|
93
|
+
// `user(id = "")` is a wiring mistake, not a decision to become
|
|
94
|
+
// unaddressable.
|
|
95
|
+
assertEquals(
|
|
96
|
+
SentoriIdentity.hash("email", "a@b.com"),
|
|
97
|
+
SentoriIdentity.userKey(" ", "a@b.com"),
|
|
98
|
+
)
|
|
99
|
+
assertNull(SentoriIdentity.userKey(null, null))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test
|
|
103
|
+
fun oauthSubjectsAreLeftExactlyAsGiven() {
|
|
104
|
+
assertEquals(" Ab ", SentoriIdentity.normalise("googleSub", " Ab "))
|
|
105
|
+
assertEquals("X.Y", SentoriIdentity.normalise("appleSub", "X.Y"))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriLiveServerTest.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import java.io.File
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import org.junit.After
|
|
9
|
+
import org.junit.Assert.assertEquals
|
|
10
|
+
import org.junit.Assert.assertTrue
|
|
11
|
+
import org.junit.Assume.assumeTrue
|
|
12
|
+
import org.junit.Before
|
|
13
|
+
import org.junit.Test
|
|
14
|
+
import org.junit.runner.RunWith
|
|
15
|
+
import org.robolectric.RobolectricTestRunner
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Against a real Sentori server, not a mock.
|
|
19
|
+
*
|
|
20
|
+
* Everything else in this suite asserts the shape this SDK *builds*.
|
|
21
|
+
* That is worth nothing if the server rejects it, and a mock would
|
|
22
|
+
* agree with whatever mistake the SDK is making — which is how a wire
|
|
23
|
+
* format quietly diverges. The one thing that cannot agree with a
|
|
24
|
+
* mistake is the server.
|
|
25
|
+
*
|
|
26
|
+
* Configuration comes from a file rather than the environment, the
|
|
27
|
+
* same way the iOS suite reads it, and for the same reason: it was
|
|
28
|
+
* written once by whoever brought a server up.
|
|
29
|
+
*
|
|
30
|
+
* `assumeTrue` skips when there is none. A skip in Gradle's report is
|
|
31
|
+
* visible; `scripts/android-live-ingest.sh` still fails if this test
|
|
32
|
+
* did not actually run, because a suite that quietly tested nothing
|
|
33
|
+
* reports exactly like one that passed.
|
|
34
|
+
*/
|
|
35
|
+
@RunWith(RobolectricTestRunner::class)
|
|
36
|
+
class SentoriLiveServerTest {
|
|
37
|
+
|
|
38
|
+
private var base: String? = null
|
|
39
|
+
private var token: String? = null
|
|
40
|
+
|
|
41
|
+
private fun fixture(): File? {
|
|
42
|
+
var dir = File(System.getProperty("user.dir") ?: ".").absoluteFile
|
|
43
|
+
repeat(8) {
|
|
44
|
+
val f = File(dir, "sdk/native/fixtures/live-server.json")
|
|
45
|
+
if (f.exists()) return f
|
|
46
|
+
dir = dir.parentFile ?: dir
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Before
|
|
52
|
+
fun setUp() {
|
|
53
|
+
val f = fixture()
|
|
54
|
+
assumeTrue("no sdk/native/fixtures/live-server.json — run scripts/android-live-ingest.sh", f != null)
|
|
55
|
+
val j = JSONObject(f!!.readText())
|
|
56
|
+
base = j.getString("base")
|
|
57
|
+
token = j.getString("token")
|
|
58
|
+
SentoriTransport.resetForTests()
|
|
59
|
+
SentoriConfig.resetForTests()
|
|
60
|
+
SentoriScope.clear()
|
|
61
|
+
SentoriSignalRing.clear()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@After
|
|
65
|
+
fun tearDown() {
|
|
66
|
+
SentoriTransport.resetForTests()
|
|
67
|
+
SentoriConfig.resetForTests()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
fun theServerAcceptsWhatThisSdkSends() {
|
|
72
|
+
Sentori.start(
|
|
73
|
+
SentoriConfig(
|
|
74
|
+
token = token!!,
|
|
75
|
+
ingestUrl = base!!,
|
|
76
|
+
release = "kotlin-e2e@1.0.0",
|
|
77
|
+
environment = "test",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
Sentori.user("usr_kotlin_1", null)
|
|
81
|
+
Sentori.context(mapOf("tenant" to "acme"))
|
|
82
|
+
Sentori.pushSignal("nav", mapOf("to" to "/checkout"))
|
|
83
|
+
|
|
84
|
+
val id = Sentori.error(IllegalStateException("boom"), mapOf("cartId" to "c_1"))
|
|
85
|
+
Sentori.warn("checkout.slow", mapOf("ms" to 3200))
|
|
86
|
+
Sentori.assert("total.positive", false)
|
|
87
|
+
Sentori.probe("SEN-482")
|
|
88
|
+
SentoriTransport.flush()
|
|
89
|
+
|
|
90
|
+
// Poll for delivery rather than sleeping and checking that
|
|
91
|
+
// nothing has failed *yet*: both "no spill" and "queue empty"
|
|
92
|
+
// are true while retries are in flight, and both are true when
|
|
93
|
+
// the server answers 4xx.
|
|
94
|
+
val deadline = System.currentTimeMillis() + 30_000
|
|
95
|
+
while (SentoriTransport.peekDelivered() == 0 && System.currentTimeMillis() < deadline) {
|
|
96
|
+
Thread.sleep(250)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
assertTrue(
|
|
100
|
+
"the server never accepted a batch — spilled: " +
|
|
101
|
+
"${SentoriTransport.peekPersisted().size}, queued: ${SentoriTransport.peekQueue().size}",
|
|
102
|
+
SentoriTransport.peekDelivered() > 0,
|
|
103
|
+
)
|
|
104
|
+
assertEquals(36, id.length)
|
|
105
|
+
}
|
|
106
|
+
}
|