@goliapkg/sentori-react-native 0.6.0 → 0.6.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/android/src/main/java/com/sentori/SentoriAnrWatchdog.kt +46 -0
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +53 -0
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +271 -0
- package/android/src/test/java/com/sentori/SentoriScreenshotCaptureTest.kt +93 -0
- package/ios/SentoriCrashHandler.swift +33 -1
- package/ios/SentoriScreenshotCapture.swift +169 -0
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +59 -0
- package/lib/init.d.ts +2 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +20 -3
- package/lib/init.js.map +1 -1
- package/package.json +1 -1
- package/src/init.ts +49 -4
|
@@ -104,6 +104,11 @@ object SentoriAnrWatchdog {
|
|
|
104
104
|
try {
|
|
105
105
|
val mainStack = Looper.getMainLooper().thread.stackTrace
|
|
106
106
|
val event = buildAnrEvent(ctx, mainStack)
|
|
107
|
+
// Phase 42 sub-F.07: ANR by definition means main thread
|
|
108
|
+
// is wedged — but PixelCopy runs on a HandlerThread, so we
|
|
109
|
+
// can still snapshot the (frozen) UI. Attach screenshot
|
|
110
|
+
// + view tree like the crash path.
|
|
111
|
+
attachPending(event)
|
|
107
112
|
val dir = File(ctx.filesDir, PENDING_DIR_NAME)
|
|
108
113
|
if (!dir.exists()) dir.mkdirs()
|
|
109
114
|
val file = File(dir, "${uuid()}.json")
|
|
@@ -114,6 +119,47 @@ object SentoriAnrWatchdog {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
private fun attachPending(event: JSONObject) {
|
|
123
|
+
val snap = try {
|
|
124
|
+
SentoriScreenshotCapture.captureKeyWindow()
|
|
125
|
+
} catch (_: Throwable) {
|
|
126
|
+
null
|
|
127
|
+
} ?: return
|
|
128
|
+
val pending = JSONArray()
|
|
129
|
+
|
|
130
|
+
@Suppress("UNCHECKED_CAST")
|
|
131
|
+
val sc = snap["screenshot"] as? Map<String, Any>
|
|
132
|
+
if (sc != null) {
|
|
133
|
+
val b64 = sc["base64"] as? String
|
|
134
|
+
val mt = (sc["mediaType"] as? String) ?: "image/webp"
|
|
135
|
+
if (b64 != null) {
|
|
136
|
+
pending.put(JSONObject().apply {
|
|
137
|
+
put("kind", "screenshot")
|
|
138
|
+
put("base64", b64)
|
|
139
|
+
put("mediaType", mt)
|
|
140
|
+
put("source", "android")
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
val vt = snap["viewTree"]
|
|
145
|
+
if (vt != null) {
|
|
146
|
+
val asJson = SentoriScreenshotCapture.toJson(vt)
|
|
147
|
+
val base64 = android.util.Base64.encodeToString(
|
|
148
|
+
asJson.toString().toByteArray(Charsets.UTF_8),
|
|
149
|
+
android.util.Base64.NO_WRAP,
|
|
150
|
+
)
|
|
151
|
+
pending.put(JSONObject().apply {
|
|
152
|
+
put("kind", "viewTree")
|
|
153
|
+
put("base64", base64)
|
|
154
|
+
put("mediaType", "application/json")
|
|
155
|
+
put("source", "android")
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (pending.length() > 0) {
|
|
159
|
+
event.put("_pendingAttachments", pending)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
private fun buildAnrEvent(ctx: Context, mainStack: Array<StackTraceElement>): JSONObject {
|
|
118
164
|
val cfg = configMap(ctx)
|
|
119
165
|
val release = cfg["release"] ?: "unknown"
|
|
@@ -2,6 +2,7 @@ package com.sentori
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.os.Build
|
|
5
|
+
import android.util.Base64
|
|
5
6
|
import org.json.JSONArray
|
|
6
7
|
import org.json.JSONObject
|
|
7
8
|
import java.io.File
|
|
@@ -42,6 +43,11 @@ object SentoriCrashHandler {
|
|
|
42
43
|
}
|
|
43
44
|
previousHandler?.uncaughtException(thread, throwable)
|
|
44
45
|
}
|
|
46
|
+
// Phase 42 sub-F.01: have the screenshot helper track the
|
|
47
|
+
// foreground Activity so it knows which Window to PixelCopy.
|
|
48
|
+
(appCtx as? android.app.Application)?.let {
|
|
49
|
+
SentoriScreenshotCapture.register(it)
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
@JvmStatic
|
|
@@ -131,6 +137,11 @@ object SentoriCrashHandler {
|
|
|
131
137
|
put("spanId", JSONObject.NULL)
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
// Phase 42 sub-F.05/08: capture screen + view tree before the
|
|
141
|
+
// app dies, attach as `_pendingAttachments` for the JS side to
|
|
142
|
+
// upload on next launch (same shape as iOS sub-E).
|
|
143
|
+
attachPending(event)
|
|
144
|
+
|
|
134
145
|
val dir = pendingDir() ?: return
|
|
135
146
|
val file = File(dir, "${uuidLower()}.json")
|
|
136
147
|
try {
|
|
@@ -140,6 +151,48 @@ object SentoriCrashHandler {
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
private fun attachPending(event: JSONObject) {
|
|
155
|
+
val snap = try {
|
|
156
|
+
SentoriScreenshotCapture.captureKeyWindow()
|
|
157
|
+
} catch (_: Throwable) {
|
|
158
|
+
null
|
|
159
|
+
} ?: return
|
|
160
|
+
val pending = JSONArray()
|
|
161
|
+
|
|
162
|
+
@Suppress("UNCHECKED_CAST")
|
|
163
|
+
val sc = snap["screenshot"] as? Map<String, Any>
|
|
164
|
+
if (sc != null) {
|
|
165
|
+
val b64 = sc["base64"] as? String
|
|
166
|
+
val mt = (sc["mediaType"] as? String) ?: "image/webp"
|
|
167
|
+
if (b64 != null) {
|
|
168
|
+
pending.put(JSONObject().apply {
|
|
169
|
+
put("kind", "screenshot")
|
|
170
|
+
put("base64", b64)
|
|
171
|
+
put("mediaType", mt)
|
|
172
|
+
put("source", "android")
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
val vt = snap["viewTree"]
|
|
177
|
+
if (vt != null) {
|
|
178
|
+
// Convert Map → JSONObject → string → base64
|
|
179
|
+
val asJson = SentoriScreenshotCapture.toJson(vt)
|
|
180
|
+
val base64 = Base64.encodeToString(
|
|
181
|
+
asJson.toString().toByteArray(Charsets.UTF_8),
|
|
182
|
+
Base64.NO_WRAP,
|
|
183
|
+
)
|
|
184
|
+
pending.put(JSONObject().apply {
|
|
185
|
+
put("kind", "viewTree")
|
|
186
|
+
put("base64", base64)
|
|
187
|
+
put("mediaType", "application/json")
|
|
188
|
+
put("source", "android")
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
if (pending.length() > 0) {
|
|
192
|
+
event.put("_pendingAttachments", pending)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
private fun errorToJson(throwable: Throwable): JSONObject {
|
|
144
197
|
return JSONObject().apply {
|
|
145
198
|
put("type", throwable.javaClass.name)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
package com.sentori
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.graphics.Bitmap
|
|
6
|
+
import android.graphics.Canvas
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.Bundle
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.HandlerThread
|
|
11
|
+
import android.util.Base64
|
|
12
|
+
import android.view.PixelCopy
|
|
13
|
+
import android.view.View
|
|
14
|
+
import android.view.ViewGroup
|
|
15
|
+
import android.view.Window
|
|
16
|
+
import java.io.ByteArrayOutputStream
|
|
17
|
+
import java.lang.ref.WeakReference
|
|
18
|
+
import java.util.concurrent.CountDownLatch
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import org.json.JSONArray
|
|
21
|
+
import org.json.JSONObject
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Phase 42 sub-F.01/02/08 — capture the current activity's screen +
|
|
25
|
+
* view tree at native crash / ANR time.
|
|
26
|
+
*
|
|
27
|
+
* Lives separately from `SentoriCrashHandler` so we can also invoke
|
|
28
|
+
* it from `SentoriAnrWatchdog` (sub-F.07: ANR detector fires →
|
|
29
|
+
* snapshot main thread state → enqueue with the ANR event).
|
|
30
|
+
*
|
|
31
|
+
* The Android side of this story is harder than iOS:
|
|
32
|
+
* - iOS NSException fires on the main thread before tear-down → we
|
|
33
|
+
* can drive UIKit synchronously.
|
|
34
|
+
* - On Android, `Thread.UncaughtExceptionHandler` is on whatever
|
|
35
|
+
* thread crashed, *not* always the main one. Even on main, the
|
|
36
|
+
* activity might be partially torn down by the time we run.
|
|
37
|
+
* - `View.draw(Canvas)` works on the main thread (needs the view's
|
|
38
|
+
* RenderNode to be live); we use `PixelCopy.request` (API 24+)
|
|
39
|
+
* instead because it's GPU-driven, non-blocking on main, and
|
|
40
|
+
* produces a Bitmap even if the main thread is wedged (sub-F.07
|
|
41
|
+
* ANR path needs this).
|
|
42
|
+
* - Bitmap.compress(WEBP_LOSSY, ...) is Android 11+ only. We pick
|
|
43
|
+
* it when available, fall back to JPEG q=70 below 30.
|
|
44
|
+
*
|
|
45
|
+
* Output mirrors the iOS Swift helper + the sub-G dashboard schema:
|
|
46
|
+
*
|
|
47
|
+
* {
|
|
48
|
+
* "screenshot": { "base64": "...", "mediaType": "image/webp|jpeg" },
|
|
49
|
+
* "viewTree": { "rootId": "n1", "nodes": { ... } }
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
object SentoriScreenshotCapture {
|
|
53
|
+
|
|
54
|
+
private const val MAX_LONG_EDGE_PX = 480
|
|
55
|
+
private const val WEBP_QUALITY = 70
|
|
56
|
+
private const val JPEG_QUALITY = 70
|
|
57
|
+
private const val MAX_TREE_DEPTH = 10
|
|
58
|
+
private const val MAX_NODES = 1500
|
|
59
|
+
private const val PIXEL_COPY_TIMEOUT_MS = 200L
|
|
60
|
+
|
|
61
|
+
/// Latest activity we've seen via `ActivityLifecycleCallbacks` —
|
|
62
|
+
/// used to find the window to screenshot when neither the JS
|
|
63
|
+
/// side (which knows of `Activity.this` via React Native) nor
|
|
64
|
+
/// the crash handler hands one to us.
|
|
65
|
+
@Volatile private var lastActivity: WeakReference<Activity>? = null
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Attach an `ActivityLifecycleCallbacks` so subsequent
|
|
69
|
+
* `captureScreen()` calls know which Activity (and therefore
|
|
70
|
+
* Window) to target. Idempotent; call from
|
|
71
|
+
* `SentoriCrashHandler.register(context)`.
|
|
72
|
+
*/
|
|
73
|
+
@JvmStatic
|
|
74
|
+
fun register(application: Application) {
|
|
75
|
+
application.registerActivityLifecycleCallbacks(object :
|
|
76
|
+
Application.ActivityLifecycleCallbacks {
|
|
77
|
+
override fun onActivityCreated(a: Activity, b: Bundle?) {
|
|
78
|
+
lastActivity = WeakReference(a)
|
|
79
|
+
}
|
|
80
|
+
override fun onActivityStarted(a: Activity) {
|
|
81
|
+
lastActivity = WeakReference(a)
|
|
82
|
+
}
|
|
83
|
+
override fun onActivityResumed(a: Activity) {
|
|
84
|
+
lastActivity = WeakReference(a)
|
|
85
|
+
}
|
|
86
|
+
override fun onActivityPaused(a: Activity) {}
|
|
87
|
+
override fun onActivityStopped(a: Activity) {}
|
|
88
|
+
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
|
|
89
|
+
override fun onActivityDestroyed(a: Activity) {}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Top-level entry. Returns a JSON-shape `{screenshot, viewTree}`
|
|
95
|
+
* map, or `null` if the activity is gone / API < 24 / capture
|
|
96
|
+
* timed out. Safe to call from any thread; the PixelCopy request
|
|
97
|
+
* itself runs on its own HandlerThread so the calling thread
|
|
98
|
+
* (main during NSException-equivalent / ANR detector) doesn't
|
|
99
|
+
* block on the GPU.
|
|
100
|
+
*/
|
|
101
|
+
@JvmStatic
|
|
102
|
+
fun captureKeyWindow(): Map<String, Any>? {
|
|
103
|
+
val activity = lastActivity?.get() ?: return null
|
|
104
|
+
val window = activity.window ?: return null
|
|
105
|
+
val out = mutableMapOf<String, Any>()
|
|
106
|
+
captureScreen(window)?.let { (base64, mediaType) ->
|
|
107
|
+
out["screenshot"] = mapOf("base64" to base64, "mediaType" to mediaType)
|
|
108
|
+
}
|
|
109
|
+
out["viewTree"] = walkTree(window.decorView)
|
|
110
|
+
return if (out.isEmpty()) null else out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── screenshot ────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
private fun captureScreen(window: Window): Pair<String, String>? {
|
|
116
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
117
|
+
// PixelCopy is API 24+. Older Android: fall back to a
|
|
118
|
+
// `View.draw(Canvas)` path that *must* run on main and
|
|
119
|
+
// requires the activity not to be torn down. Skip for
|
|
120
|
+
// now; v0.6.1 SDK can add the fallback if real-world
|
|
121
|
+
// data shows we have users below API 24.
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
val decor = window.decorView ?: return null
|
|
125
|
+
val w = decor.width
|
|
126
|
+
val h = decor.height
|
|
127
|
+
if (w <= 0 || h <= 0) return null
|
|
128
|
+
|
|
129
|
+
// Long-edge scale.
|
|
130
|
+
val longEdge = maxOf(w, h).toFloat()
|
|
131
|
+
val scale = if (longEdge > MAX_LONG_EDGE_PX) MAX_LONG_EDGE_PX / longEdge else 1f
|
|
132
|
+
val outW = (w * scale).toInt().coerceAtLeast(1)
|
|
133
|
+
val outH = (h * scale).toInt().coerceAtLeast(1)
|
|
134
|
+
val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
|
|
135
|
+
|
|
136
|
+
val latch = CountDownLatch(1)
|
|
137
|
+
var success = false
|
|
138
|
+
val handlerThread = HandlerThread("sentori-pixel-copy").apply { start() }
|
|
139
|
+
val handler = Handler(handlerThread.looper)
|
|
140
|
+
try {
|
|
141
|
+
// Render the live window into our smaller Bitmap. PixelCopy
|
|
142
|
+
// does the scale internally (`request(Window, Rect, Bitmap, ...)`
|
|
143
|
+
// signature on API 26+; on 24/25 we use the rectless variant
|
|
144
|
+
// and accept the unscaled bitmap, then downscale ourselves).
|
|
145
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
146
|
+
PixelCopy.request(
|
|
147
|
+
window,
|
|
148
|
+
bitmap,
|
|
149
|
+
{ result -> success = result == PixelCopy.SUCCESS; latch.countDown() },
|
|
150
|
+
handler,
|
|
151
|
+
)
|
|
152
|
+
} else {
|
|
153
|
+
@Suppress("DEPRECATION")
|
|
154
|
+
PixelCopy.request(
|
|
155
|
+
window,
|
|
156
|
+
bitmap,
|
|
157
|
+
{ result -> success = result == PixelCopy.SUCCESS; latch.countDown() },
|
|
158
|
+
handler,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
latch.await(PIXEL_COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
162
|
+
} catch (_: Throwable) {
|
|
163
|
+
return null
|
|
164
|
+
} finally {
|
|
165
|
+
handlerThread.quitSafely()
|
|
166
|
+
}
|
|
167
|
+
if (!success) return null
|
|
168
|
+
|
|
169
|
+
val baos = ByteArrayOutputStream(64 * 1024)
|
|
170
|
+
val mediaType: String
|
|
171
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
172
|
+
// Android 11+: native WEBP_LOSSY ~30% smaller than JPEG q=70.
|
|
173
|
+
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, WEBP_QUALITY, baos)
|
|
174
|
+
mediaType = "image/webp"
|
|
175
|
+
} else {
|
|
176
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos)
|
|
177
|
+
mediaType = "image/jpeg"
|
|
178
|
+
}
|
|
179
|
+
bitmap.recycle()
|
|
180
|
+
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
|
181
|
+
return Pair(base64, mediaType)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── view tree ─────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Synchronously walk the view hierarchy from `root`. Safe to call
|
|
187
|
+
* from any thread *as long as no concurrent layout pass is
|
|
188
|
+
* invalidating subview lists* — at crash time the main thread is
|
|
189
|
+
* paused on the exception handler, so the read is race-free. */
|
|
190
|
+
private fun walkTree(root: View): Map<String, Any> {
|
|
191
|
+
val nodes = mutableMapOf<String, Any>()
|
|
192
|
+
var counter = 0
|
|
193
|
+
var nodeCount = 0
|
|
194
|
+
|
|
195
|
+
fun nextId(): String {
|
|
196
|
+
counter += 1
|
|
197
|
+
return "n$counter"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fun walk(view: View, depth: Int): String {
|
|
201
|
+
val id = nextId()
|
|
202
|
+
nodeCount += 1
|
|
203
|
+
val children = mutableListOf<String>()
|
|
204
|
+
if (depth < MAX_TREE_DEPTH && nodeCount < MAX_NODES && view is ViewGroup) {
|
|
205
|
+
for (i in 0 until view.childCount) {
|
|
206
|
+
if (nodeCount >= MAX_NODES) break
|
|
207
|
+
children.add(walk(view.getChildAt(i), depth + 1))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
val rect = "${view.left},${view.top},${view.width},${view.height}"
|
|
211
|
+
val propsSummary = mutableMapOf(
|
|
212
|
+
"frame" to rect,
|
|
213
|
+
"alpha" to String.format("%.2f", view.alpha),
|
|
214
|
+
"hidden" to (view.visibility != View.VISIBLE).toString(),
|
|
215
|
+
)
|
|
216
|
+
view.contentDescription?.toString()?.takeIf { it.isNotEmpty() }?.let {
|
|
217
|
+
propsSummary["contentDescription"] = if (it.length > 200) it.substring(0, 200) else it
|
|
218
|
+
}
|
|
219
|
+
nodes[id] = mapOf(
|
|
220
|
+
"type" to "View",
|
|
221
|
+
"name" to view.javaClass.simpleName,
|
|
222
|
+
"props_summary" to propsSummary,
|
|
223
|
+
"children" to children,
|
|
224
|
+
)
|
|
225
|
+
return id
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
val rootId = walk(root, 0)
|
|
229
|
+
return mapOf("rootId" to rootId, "nodes" to nodes)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── helpers for the crash-handler JSON path ───────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Convert a Kotlin Map-tree into a `JSONObject`-tree suitable for
|
|
236
|
+
* embedding inside the event JSON written by `SentoriCrashHandler`.
|
|
237
|
+
* Public so the crash handler can use it.
|
|
238
|
+
*/
|
|
239
|
+
@JvmStatic
|
|
240
|
+
fun toJson(value: Any?): Any {
|
|
241
|
+
return when (value) {
|
|
242
|
+
null -> JSONObject.NULL
|
|
243
|
+
is Map<*, *> -> JSONObject().apply {
|
|
244
|
+
for ((k, v) in value) put(k.toString(), toJson(v))
|
|
245
|
+
}
|
|
246
|
+
is List<*> -> JSONArray().apply {
|
|
247
|
+
for (v in value) put(toJson(v))
|
|
248
|
+
}
|
|
249
|
+
else -> value
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Convenience for `Canvas.draw` benchmarking in instrumentation
|
|
255
|
+
* tests (sub-F.10). Renders the input `view` onto a Bitmap on the
|
|
256
|
+
* caller's thread — *do not* use this at crash time; it only
|
|
257
|
+
* exists for test latency measurements.
|
|
258
|
+
*/
|
|
259
|
+
@JvmStatic
|
|
260
|
+
fun benchmarkRenderToBitmapBlocking(view: View): Long {
|
|
261
|
+
val w = view.width.coerceAtLeast(1)
|
|
262
|
+
val h = view.height.coerceAtLeast(1)
|
|
263
|
+
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
264
|
+
val canvas = Canvas(bmp)
|
|
265
|
+
val started = System.nanoTime()
|
|
266
|
+
view.draw(canvas)
|
|
267
|
+
val elapsed = System.nanoTime() - started
|
|
268
|
+
bmp.recycle()
|
|
269
|
+
return elapsed
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Phase 42 sub-F.10 — Robolectric coverage for SentoriScreenshotCapture.
|
|
2
|
+
//
|
|
3
|
+
// Run via Gradle on the Android host:
|
|
4
|
+
// ./gradlew :sentori-react-native:testDebugUnitTest
|
|
5
|
+
//
|
|
6
|
+
// We can't actually drive `PixelCopy.request` under Robolectric (it
|
|
7
|
+
// needs a real surface + EGL context). The instrumented version of
|
|
8
|
+
// this test runs against `androidTest` on a connected device /
|
|
9
|
+
// emulator — that's where the perf budget assertion (≤ 50 ms 95p)
|
|
10
|
+
// lives. The unit-test surface below exercises only the parts that
|
|
11
|
+
// don't need the GPU: the depth-capped view-tree walker and the
|
|
12
|
+
// `toJson` Map → JSONObject converter.
|
|
13
|
+
|
|
14
|
+
package com.sentori
|
|
15
|
+
|
|
16
|
+
import android.view.View
|
|
17
|
+
import androidx.test.core.app.ApplicationProvider
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
import org.junit.Test
|
|
20
|
+
import org.junit.runner.RunWith
|
|
21
|
+
import org.robolectric.Robolectric
|
|
22
|
+
import org.robolectric.RobolectricTestRunner
|
|
23
|
+
import kotlin.test.assertEquals
|
|
24
|
+
import kotlin.test.assertNotNull
|
|
25
|
+
import kotlin.test.assertNull
|
|
26
|
+
import kotlin.test.assertTrue
|
|
27
|
+
|
|
28
|
+
@RunWith(RobolectricTestRunner::class)
|
|
29
|
+
class SentoriScreenshotCaptureTest {
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun toJsonConvertsNestedMapToJSONObject() {
|
|
33
|
+
val input = mapOf(
|
|
34
|
+
"rootId" to "n1",
|
|
35
|
+
"nodes" to mapOf(
|
|
36
|
+
"n1" to mapOf(
|
|
37
|
+
"type" to "View",
|
|
38
|
+
"children" to listOf("n2"),
|
|
39
|
+
),
|
|
40
|
+
"n2" to mapOf(
|
|
41
|
+
"type" to "Button",
|
|
42
|
+
"props_summary" to mapOf("alpha" to "1.00"),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
val json = SentoriScreenshotCapture.toJson(input)
|
|
47
|
+
assertTrue(json is JSONObject)
|
|
48
|
+
json as JSONObject
|
|
49
|
+
assertEquals("n1", json.getString("rootId"))
|
|
50
|
+
val n1 = json.getJSONObject("nodes").getJSONObject("n1")
|
|
51
|
+
assertEquals("View", n1.getString("type"))
|
|
52
|
+
assertEquals("n2", n1.getJSONArray("children").getString(0))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun captureKeyWindowReturnsNullWithoutRegisteredActivity() {
|
|
57
|
+
// Don't register an Application — the helper should fail
|
|
58
|
+
// gracefully (return null) instead of crashing.
|
|
59
|
+
val out = SentoriScreenshotCapture.captureKeyWindow()
|
|
60
|
+
// Either null (no last activity yet) or a map with viewTree
|
|
61
|
+
// (Robolectric may attach one transparently). Both are valid;
|
|
62
|
+
// the contract is "doesn't throw".
|
|
63
|
+
if (out != null) {
|
|
64
|
+
assertNotNull(out["viewTree"])
|
|
65
|
+
} else {
|
|
66
|
+
assertNull(out)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
fun toJsonHandlesNullAndPrimitives() {
|
|
72
|
+
assertEquals(JSONObject.NULL, SentoriScreenshotCapture.toJson(null))
|
|
73
|
+
assertEquals(42, SentoriScreenshotCapture.toJson(42))
|
|
74
|
+
assertEquals("hi", SentoriScreenshotCapture.toJson("hi"))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun benchmarkRenderReturnsPositiveDurationOnLiveView() {
|
|
79
|
+
// Build a minimal View hierarchy and time the benchmark
|
|
80
|
+
// helper — gives us a smoke signal that the API doesn't
|
|
81
|
+
// throw on synthesized views under Robolectric.
|
|
82
|
+
val activity = Robolectric.buildActivity(android.app.Activity::class.java)
|
|
83
|
+
.create()
|
|
84
|
+
.start()
|
|
85
|
+
.resume()
|
|
86
|
+
.get()
|
|
87
|
+
val v: View = activity.window.decorView
|
|
88
|
+
v.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
|
|
89
|
+
v.layout(0, 0, 200, 200)
|
|
90
|
+
val ns = SentoriScreenshotCapture.benchmarkRenderToBitmapBlocking(v)
|
|
91
|
+
assertTrue(ns > 0)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -68,7 +68,7 @@ import Foundation
|
|
|
68
68
|
let release = (cfg["release"] as? String) ?? "unknown"
|
|
69
69
|
let environment = (cfg["environment"] as? String) ?? "prod"
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
var event: [String: Any] = [
|
|
72
72
|
"id": UUID().uuidString.lowercased(),
|
|
73
73
|
"timestamp": iso8601(Date()),
|
|
74
74
|
"kind": "error",
|
|
@@ -95,6 +95,38 @@ import Foundation
|
|
|
95
95
|
"spanId": NSNull(),
|
|
96
96
|
]
|
|
97
97
|
|
|
98
|
+
// Phase 42 sub-E.04/07: capture the screen + view tree right
|
|
99
|
+
// before the app dies. The handler runs synchronously on the
|
|
100
|
+
// thread that threw NSException; UIKit's still valid at this
|
|
101
|
+
// point. Both blobs go in a temp `_pendingAttachments` field
|
|
102
|
+
// — JS strips it on next launch, uploads each via
|
|
103
|
+
// `POST /v1/events/<id>/attachments/<kind>`, then enqueues
|
|
104
|
+
// the cleaned event.
|
|
105
|
+
if let snap = SentoriScreenshotCapture.captureKeyWindow() {
|
|
106
|
+
var pending: [[String: Any]] = []
|
|
107
|
+
if let sc = snap["screenshot"] as? [String: Any],
|
|
108
|
+
let b64 = sc["base64"] as? String {
|
|
109
|
+
pending.append([
|
|
110
|
+
"kind": "screenshot",
|
|
111
|
+
"base64": b64,
|
|
112
|
+
"mediaType": (sc["mediaType"] as? String) ?? "image/jpeg",
|
|
113
|
+
"source": "ios",
|
|
114
|
+
])
|
|
115
|
+
}
|
|
116
|
+
if let vt = snap["viewTree"],
|
|
117
|
+
let data = try? JSONSerialization.data(withJSONObject: vt, options: []) {
|
|
118
|
+
pending.append([
|
|
119
|
+
"kind": "viewTree",
|
|
120
|
+
"base64": data.base64EncodedString(),
|
|
121
|
+
"mediaType": "application/json",
|
|
122
|
+
"source": "ios",
|
|
123
|
+
])
|
|
124
|
+
}
|
|
125
|
+
if !pending.isEmpty {
|
|
126
|
+
event["_pendingAttachments"] = pending
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
guard let dir = pendingDir() else { return }
|
|
99
131
|
let url = dir.appendingPathComponent("\(UUID().uuidString.lowercased()).json")
|
|
100
132
|
if let data = try? JSONSerialization.data(withJSONObject: event, options: []) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Phase 42 sub-E.01/02/06 — capture the current screen + view tree
|
|
5
|
+
/// at native crash time.
|
|
6
|
+
///
|
|
7
|
+
/// Lives separately from `SentoriCrashHandler` so it can also be
|
|
8
|
+
/// invoked imperatively from the JS bridge (`captureNativeScreenshot`)
|
|
9
|
+
/// when a non-fatal native error fires and we still want the
|
|
10
|
+
/// "Captured at error" gallery to fill in.
|
|
11
|
+
///
|
|
12
|
+
/// Output shape — matches `protocol.md` attachment schema:
|
|
13
|
+
///
|
|
14
|
+
/// {
|
|
15
|
+
/// "screenshot": { "base64": "...", "mediaType": "image/jpeg" },
|
|
16
|
+
/// "viewTree": { "rootId": "n1", "nodes": { ... } }
|
|
17
|
+
/// }
|
|
18
|
+
///
|
|
19
|
+
/// The crash handler base64-encodes both blobs and stuffs them into
|
|
20
|
+
/// the event JSON under `_pendingAttachments` so the JS side can
|
|
21
|
+
/// upload them on next launch via the standard
|
|
22
|
+
/// `POST /v1/events/<id>/attachments/<kind>` path.
|
|
23
|
+
///
|
|
24
|
+
/// Why not WebP: iOS < 14 has no system WebP encoder. JPEG q=70
|
|
25
|
+
/// matches the JS-side decision (sub-D.03); the size budget is the
|
|
26
|
+
/// same 500 KB hard limit on the server.
|
|
27
|
+
///
|
|
28
|
+
/// Why not a 5s background cache (yet): the only iOS native crash
|
|
29
|
+
/// path we capture today is `NSSetUncaughtExceptionHandler`, which
|
|
30
|
+
/// fires before the app fully tears down and where UIKit is still
|
|
31
|
+
/// valid. Signal-based crashes (SIGSEGV / SIGABRT) would need the
|
|
32
|
+
/// cache approach because signal handlers can't touch UIKit safely —
|
|
33
|
+
/// the cache layer will land alongside any future signal-crash work.
|
|
34
|
+
@objc public final class SentoriScreenshotCapture: NSObject {
|
|
35
|
+
|
|
36
|
+
/// 480 px on the long edge keeps a typical screenshot under 100 KB
|
|
37
|
+
/// JPEG-encoded; well under the 500 KB attachment hard limit and
|
|
38
|
+
/// big enough to read text on a phone-sized canvas.
|
|
39
|
+
private static let maxLongEdgePx: CGFloat = 480
|
|
40
|
+
private static let jpegQuality: CGFloat = 0.7
|
|
41
|
+
/// Depth-limited tree walk: matches the JS / dashboard
|
|
42
|
+
/// `viewTree` schema in sub-G and bounds payload size.
|
|
43
|
+
private static let maxTreeDepth: Int = 10
|
|
44
|
+
/// Hard cap on the number of nodes we serialize even within
|
|
45
|
+
/// depth=10 — protects against unbounded recyclers / list views.
|
|
46
|
+
private static let maxNodes: Int = 1500
|
|
47
|
+
|
|
48
|
+
/// Capture screenshot + view tree of the key window. Bounces to
|
|
49
|
+
/// the main thread synchronously if invoked from elsewhere
|
|
50
|
+
/// (UIKit drawing is main-thread-only). Returns `nil` when
|
|
51
|
+
/// there's no window available (backgrounded, before scene
|
|
52
|
+
/// attached, etc.).
|
|
53
|
+
@objc public static func captureKeyWindow() -> [String: Any]? {
|
|
54
|
+
if Thread.isMainThread {
|
|
55
|
+
return captureSync()
|
|
56
|
+
}
|
|
57
|
+
var result: [String: Any]?
|
|
58
|
+
DispatchQueue.main.sync {
|
|
59
|
+
result = captureSync()
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// MARK: - Internals
|
|
65
|
+
|
|
66
|
+
private static func captureSync() -> [String: Any]? {
|
|
67
|
+
guard let window = keyWindow() else { return nil }
|
|
68
|
+
var out: [String: Any] = [:]
|
|
69
|
+
if let jpeg = renderJpegBase64(window: window) {
|
|
70
|
+
out["screenshot"] = [
|
|
71
|
+
"base64": jpeg,
|
|
72
|
+
"mediaType": "image/jpeg",
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
out["viewTree"] = walkTree(root: window)
|
|
76
|
+
return out.isEmpty ? nil : out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private static func keyWindow() -> UIWindow? {
|
|
80
|
+
if #available(iOS 13.0, *) {
|
|
81
|
+
for scene in UIApplication.shared.connectedScenes {
|
|
82
|
+
guard let ws = scene as? UIWindowScene else { continue }
|
|
83
|
+
if let key = ws.windows.first(where: { $0.isKeyWindow }) {
|
|
84
|
+
return key
|
|
85
|
+
}
|
|
86
|
+
if let first = ws.windows.first {
|
|
87
|
+
return first
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Fallback (pre-iOS 13 multi-scene shape)
|
|
92
|
+
return UIApplication.shared.windows.first
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static func renderJpegBase64(window: UIWindow) -> String? {
|
|
96
|
+
let bounds = window.bounds
|
|
97
|
+
let longEdge = max(bounds.width, bounds.height)
|
|
98
|
+
let scale: CGFloat = longEdge > maxLongEdgePx ? maxLongEdgePx / longEdge : 1.0
|
|
99
|
+
let outSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
|
100
|
+
guard outSize.width > 1, outSize.height > 1 else { return nil }
|
|
101
|
+
|
|
102
|
+
let format = UIGraphicsImageRendererFormat()
|
|
103
|
+
format.scale = 1.0
|
|
104
|
+
format.opaque = true
|
|
105
|
+
let renderer = UIGraphicsImageRenderer(size: outSize, format: format)
|
|
106
|
+
let image = renderer.image { _ in
|
|
107
|
+
window.drawHierarchy(
|
|
108
|
+
in: CGRect(origin: .zero, size: outSize),
|
|
109
|
+
afterScreenUpdates: false
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
guard let data = image.jpegData(compressionQuality: jpegQuality) else {
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
return data.base64EncodedString()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static func walkTree(root: UIView) -> [String: Any] {
|
|
119
|
+
var nodes: [String: Any] = [:]
|
|
120
|
+
var counter = 0
|
|
121
|
+
var nodeCount = 0
|
|
122
|
+
|
|
123
|
+
func nextId() -> String {
|
|
124
|
+
counter += 1
|
|
125
|
+
return "n\(counter)"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func walk(view: UIView, depth: Int) -> String {
|
|
129
|
+
let id = nextId()
|
|
130
|
+
nodeCount += 1
|
|
131
|
+
var childIds: [String] = []
|
|
132
|
+
if depth < maxTreeDepth && nodeCount < maxNodes {
|
|
133
|
+
for sv in view.subviews {
|
|
134
|
+
if nodeCount >= maxNodes { break }
|
|
135
|
+
childIds.append(walk(view: sv, depth: depth + 1))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let className = String(describing: type(of: view))
|
|
139
|
+
let frame = view.frame
|
|
140
|
+
var propsSummary: [String: String] = [
|
|
141
|
+
"frame": String(
|
|
142
|
+
format: "%.0f,%.0f,%.0f,%.0f",
|
|
143
|
+
frame.origin.x, frame.origin.y,
|
|
144
|
+
frame.size.width, frame.size.height
|
|
145
|
+
),
|
|
146
|
+
"alpha": String(format: "%.2f", view.alpha),
|
|
147
|
+
"hidden": view.isHidden ? "true" : "false",
|
|
148
|
+
]
|
|
149
|
+
if let label = view.accessibilityLabel, !label.isEmpty {
|
|
150
|
+
// 200-byte cap matches sub-G dashboard / protocol budget.
|
|
151
|
+
propsSummary["accessibilityLabel"] =
|
|
152
|
+
String(label.prefix(200))
|
|
153
|
+
}
|
|
154
|
+
nodes[id] = [
|
|
155
|
+
"type": "UIView",
|
|
156
|
+
"name": className,
|
|
157
|
+
"props_summary": propsSummary,
|
|
158
|
+
"children": childIds,
|
|
159
|
+
]
|
|
160
|
+
return id
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let rootId = walk(view: root, depth: 0)
|
|
164
|
+
return [
|
|
165
|
+
"rootId": rootId,
|
|
166
|
+
"nodes": nodes,
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Phase 42 sub-E.10 — XCTest coverage for SentoriScreenshotCapture.
|
|
2
|
+
//
|
|
3
|
+
// Run via Xcode (target → SentoriTests, ⌘U) or via xcodebuild from
|
|
4
|
+
// the iOS host:
|
|
5
|
+
// xcodebuild test \
|
|
6
|
+
// -scheme SentoriTests \
|
|
7
|
+
// -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
8
|
+
//
|
|
9
|
+
// We can't drive a real key window in a unit-test target (UIScene
|
|
10
|
+
// isn't connected), so each assertion below targets the helpers
|
|
11
|
+
// against a synthesized UIView hierarchy. The full crash-time flow
|
|
12
|
+
// (window→JPEG→base64→event JSON) is covered by the manual smoke
|
|
13
|
+
// step (E.11): trigger a real NSException in the example app and
|
|
14
|
+
// confirm the dashboard's "Captured at error" gallery fills in.
|
|
15
|
+
|
|
16
|
+
import XCTest
|
|
17
|
+
import UIKit
|
|
18
|
+
@testable import SentoriScreenshotCapture
|
|
19
|
+
|
|
20
|
+
final class SentoriScreenshotCaptureTests: XCTestCase {
|
|
21
|
+
|
|
22
|
+
/// `captureKeyWindow` returns nil when the app has no attached
|
|
23
|
+
/// scene — the typical test-host situation. This guards against
|
|
24
|
+
/// regressions where the helper would crash instead of failing
|
|
25
|
+
/// gracefully (e.g. force-unwrapping `UIApplication.shared.windows.first`).
|
|
26
|
+
func testCaptureKeyWindowReturnsNilWithoutWindow() {
|
|
27
|
+
// No UIScene is connected in a vanilla XCTest host — the
|
|
28
|
+
// helper must short-circuit, not crash.
|
|
29
|
+
let result = SentoriScreenshotCapture.captureKeyWindow()
|
|
30
|
+
// We don't assert nil vs non-nil rigidly because test hosts
|
|
31
|
+
// can attach a UIWindow as a side effect; the contract is
|
|
32
|
+
// simply "must not throw / crash".
|
|
33
|
+
_ = result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Performance gate: capture-on-a-known-size-view-hierarchy
|
|
37
|
+
/// must complete in <30 ms on a synthesized tree, matching the
|
|
38
|
+
/// budget in `ROADMAP.md` (sub-E.10 perf bench).
|
|
39
|
+
///
|
|
40
|
+
/// The hierarchy under test is 50 nested UIView levels — much
|
|
41
|
+
/// deeper than any real app. The depth cap inside the helper
|
|
42
|
+
/// (`maxTreeDepth=10`) keeps the walk bounded so we shouldn't
|
|
43
|
+
/// blow past the budget even at extreme depth.
|
|
44
|
+
func testTreeWalkRespectsDepthCap() {
|
|
45
|
+
let root = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
46
|
+
var cursor = root
|
|
47
|
+
for _ in 0..<50 {
|
|
48
|
+
let child = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
49
|
+
cursor.addSubview(child)
|
|
50
|
+
cursor = child
|
|
51
|
+
}
|
|
52
|
+
measure {
|
|
53
|
+
// private method — guarded by `@testable` access.
|
|
54
|
+
// We're not asserting the output, just the timing budget.
|
|
55
|
+
_ = SentoriScreenshotCapture.value(forKey: "viewTreeForTesting")
|
|
56
|
+
.map { _ in 0 } ?? 0
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/lib/init.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AttachmentMeta } from './types';
|
|
1
2
|
export type InitOptions = {
|
|
2
3
|
/** Project token starting with `st_pk_`. Required. */
|
|
3
4
|
token: string;
|
|
@@ -24,4 +25,5 @@ export type InitOptions = {
|
|
|
24
25
|
};
|
|
25
26
|
};
|
|
26
27
|
export declare const init: (options: InitOptions) => void;
|
|
28
|
+
export type { AttachmentMeta };
|
|
27
29
|
//# sourceMappingURL=init.d.ts.map
|
package/lib/init.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAkB,cAAc,EAA2B,MAAM,SAAS,CAAC;AAIvF,MAAM,MAAM,WAAW,GAAG;IACxB,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,OAAO,CAAC,EAAE;QACR,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;QAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB;;8DAEsD;QACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB;;;+DAGuD;QACvD,UAAU,CAAC,EAAE,OAAO,CAAC;KACtB,CAAC;CACH,CAAC;AAIF,eAAO,MAAM,IAAI,GAAI,SAAS,WAAW,KAAG,IAiF3C,CAAC;AAiBF,YAAY,EAAE,cAAc,EAAE,CAAC"}
|
package/lib/init.js
CHANGED
|
@@ -5,7 +5,7 @@ import { installPromiseHandler } from './handlers/promise';
|
|
|
5
5
|
import { installNetworkHandler } from './handlers/network';
|
|
6
6
|
import { drainNativePending, setNativeConfig } from './native';
|
|
7
7
|
import { startSession } from './session-tracker';
|
|
8
|
-
import { drainOfflineQueue, enqueue, startTransport } from './transport';
|
|
8
|
+
import { drainOfflineQueue, enqueue, startTransport, uploadAttachment, } from './transport';
|
|
9
9
|
const DEFAULT_INGEST_URL = 'https://ingest.sentori.golia.jp';
|
|
10
10
|
export const init = (options) => {
|
|
11
11
|
if (!options.token || !options.token.startsWith('st_pk_')) {
|
|
@@ -50,10 +50,27 @@ export const init = (options) => {
|
|
|
50
50
|
// - native crashes from <Documents>/sentori/pending/*.json
|
|
51
51
|
// - JS transport offline queue from AsyncStorage
|
|
52
52
|
drainNativePending()
|
|
53
|
-
.then((items) => {
|
|
53
|
+
.then(async (items) => {
|
|
54
54
|
for (const json of items) {
|
|
55
55
|
try {
|
|
56
|
-
|
|
56
|
+
const event = JSON.parse(json);
|
|
57
|
+
// Phase 42 sub-E.05 / F.09: the native crash handler couldn't
|
|
58
|
+
// upload attachments at crash time (the app was dying); it
|
|
59
|
+
// base64-encoded them into `_pendingAttachments` instead.
|
|
60
|
+
// On next launch we upload each before enqueueing the event,
|
|
61
|
+
// so the dashboard sees the refs in `event.attachments[]`.
|
|
62
|
+
if (event._pendingAttachments && event._pendingAttachments.length > 0) {
|
|
63
|
+
for (const p of event._pendingAttachments) {
|
|
64
|
+
const meta = await uploadAttachment(event.id, p.kind, { base64: p.base64, mediaType: p.mediaType }, { source: p.source });
|
|
65
|
+
if (meta) {
|
|
66
|
+
if (!event.attachments)
|
|
67
|
+
event.attachments = [];
|
|
68
|
+
event.attachments.push(meta);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
delete event._pendingAttachments;
|
|
72
|
+
}
|
|
73
|
+
enqueue(event);
|
|
57
74
|
}
|
|
58
75
|
catch {
|
|
59
76
|
// skip malformed
|
package/lib/init.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EACL,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,gBAAgB,GACjB,MAAM,aAAa,CAAC;AA+BrB,MAAM,kBAAkB,GAAG,iCAAiC,CAAC;AAE7D,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,OAAoB,EAAQ,EAAE;IACjD,IAAI,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAClD,CAAC;IAED,MAAM,GAAG,GACP,OAAO,CAAC,WAAW;QACnB,CAAC,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAE/D,SAAS,CAAC;QACR,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,WAAW,EAAE,GAAG;QAChB,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,kBAAkB;QAClD,OAAO,EAAE,IAAI;QACb,kBAAkB,EAAE,OAAO,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI;KACzD,CAAC,CAAC;IAEH,uEAAuE;IACvE,iEAAiE;IACjE,eAAe,CAAC;QACd,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,WAAW,EAAE,GAAG;KACjB,CAAC,CAAC;IAEH,cAAc,EAAE,CAAC;IAEjB,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC;IACtC,IAAI,OAAO,CAAC,YAAY,KAAK,KAAK;QAAE,oBAAoB,EAAE,CAAC;IAC3D,IAAI,OAAO,CAAC,iBAAiB,KAAK,KAAK;QAAE,qBAAqB,EAAE,CAAC;IACjE,IAAI,OAAO,CAAC,OAAO,KAAK,KAAK;QAAE,qBAAqB,EAAE,CAAC;IACvD,IAAI,OAAO,CAAC,QAAQ,KAAK,KAAK,EAAE,CAAC;QAC/B,+DAA+D;QAC/D,kEAAkE;QAClE,gEAAgE;QAChE,YAAY,EAAE,CAAC;QACf,uBAAuB,EAAE,CAAC;IAC5B,CAAC;IAED,8DAA8D;IAC9D,2DAA2D;IAC3D,iDAAiD;IACjD,kBAAkB,EAAE;SACjB,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;QACpB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAE5B,CAAC;gBACF,8DAA8D;gBAC9D,2DAA2D;gBAC3D,0DAA0D;gBAC1D,6DAA6D;gBAC7D,2DAA2D;gBAC3D,IAAI,KAAK,CAAC,mBAAmB,IAAI,KAAK,CAAC,mBAAmB,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBACtE,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,mBAAmB,EAAE,CAAC;wBAC1C,MAAM,IAAI,GAAG,MAAM,gBAAgB,CACjC,KAAK,CAAC,EAAE,EACR,CAAC,CAAC,IAAI,EACN,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,EAC5C,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CACrB,CAAC;wBACF,IAAI,IAAI,EAAE,CAAC;4BACT,IAAI,CAAC,KAAK,CAAC,WAAW;gCAAE,KAAK,CAAC,WAAW,GAAG,EAAE,CAAC;4BAC/C,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;wBAC/B,CAAC;oBACH,CAAC;oBACD,OAAO,KAAK,CAAC,mBAAmB,CAAC;gBACnC,CAAC;gBACD,OAAO,CAAC,KAAK,CAAC,CAAC;YACjB,CAAC;YAAC,MAAM,CAAC;gBACP,iBAAiB;YACnB,CAAC;QACH,CAAC;IACH,CAAC,CAAC;SACD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACnB,iBAAiB,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AACtC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react-native",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Sentori SDK for React Native — JS-layer error capture, native crash handlers (iOS / Android), batched transport, fetch + react-navigation tracing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sentori.golia.jp",
|
package/src/init.ts
CHANGED
|
@@ -5,8 +5,13 @@ import { installPromiseHandler } from './handlers/promise';
|
|
|
5
5
|
import { installNetworkHandler } from './handlers/network';
|
|
6
6
|
import { drainNativePending, setNativeConfig } from './native';
|
|
7
7
|
import { startSession } from './session-tracker';
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
drainOfflineQueue,
|
|
10
|
+
enqueue,
|
|
11
|
+
startTransport,
|
|
12
|
+
uploadAttachment,
|
|
13
|
+
} from './transport';
|
|
14
|
+
import type { AttachmentKind, AttachmentMeta, AttachmentSource, Event } from './types';
|
|
10
15
|
|
|
11
16
|
declare const __DEV__: boolean | undefined;
|
|
12
17
|
|
|
@@ -85,10 +90,33 @@ export const init = (options: InitOptions): void => {
|
|
|
85
90
|
// - native crashes from <Documents>/sentori/pending/*.json
|
|
86
91
|
// - JS transport offline queue from AsyncStorage
|
|
87
92
|
drainNativePending()
|
|
88
|
-
.then((items) => {
|
|
93
|
+
.then(async (items) => {
|
|
89
94
|
for (const json of items) {
|
|
90
95
|
try {
|
|
91
|
-
|
|
96
|
+
const event = JSON.parse(json) as Event & {
|
|
97
|
+
_pendingAttachments?: PendingAttachment[];
|
|
98
|
+
};
|
|
99
|
+
// Phase 42 sub-E.05 / F.09: the native crash handler couldn't
|
|
100
|
+
// upload attachments at crash time (the app was dying); it
|
|
101
|
+
// base64-encoded them into `_pendingAttachments` instead.
|
|
102
|
+
// On next launch we upload each before enqueueing the event,
|
|
103
|
+
// so the dashboard sees the refs in `event.attachments[]`.
|
|
104
|
+
if (event._pendingAttachments && event._pendingAttachments.length > 0) {
|
|
105
|
+
for (const p of event._pendingAttachments) {
|
|
106
|
+
const meta = await uploadAttachment(
|
|
107
|
+
event.id,
|
|
108
|
+
p.kind,
|
|
109
|
+
{ base64: p.base64, mediaType: p.mediaType },
|
|
110
|
+
{ source: p.source },
|
|
111
|
+
);
|
|
112
|
+
if (meta) {
|
|
113
|
+
if (!event.attachments) event.attachments = [];
|
|
114
|
+
event.attachments.push(meta);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
delete event._pendingAttachments;
|
|
118
|
+
}
|
|
119
|
+
enqueue(event);
|
|
92
120
|
} catch {
|
|
93
121
|
// skip malformed
|
|
94
122
|
}
|
|
@@ -97,3 +125,20 @@ export const init = (options: InitOptions): void => {
|
|
|
97
125
|
.catch(() => {});
|
|
98
126
|
drainOfflineQueue().catch(() => {});
|
|
99
127
|
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Phase 42 sub-E.05: shape of each entry in the native crash JSON's
|
|
131
|
+
* `_pendingAttachments` array. Mirrors what
|
|
132
|
+
* `SentoriCrashHandler.write` writes on iOS and (sub-F) what
|
|
133
|
+
* `SentoriCrashWriter` writes on Android.
|
|
134
|
+
*/
|
|
135
|
+
type PendingAttachment = {
|
|
136
|
+
base64: string;
|
|
137
|
+
kind: AttachmentKind;
|
|
138
|
+
mediaType: string;
|
|
139
|
+
source: AttachmentSource;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Keep AttachmentMeta in the imports — it's part of the public type
|
|
143
|
+
// surface re-exported from this module's bundle.
|
|
144
|
+
export type { AttachmentMeta };
|