@goliapkg/sentori-react-native 0.2.0 → 0.3.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 +227 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +18 -0
- package/ios/SentoriHangWatchdog.swift +219 -0
- package/ios/SentoriModule.swift +19 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +16 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +26 -0
- package/lib/native.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +5 -1
- package/src/native.ts +42 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
package com.sentori
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import java.io.File
|
|
10
|
+
import java.text.SimpleDateFormat
|
|
11
|
+
import java.util.Date
|
|
12
|
+
import java.util.Locale
|
|
13
|
+
import java.util.TimeZone
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ANR (Application Not Responding) detector.
|
|
19
|
+
*
|
|
20
|
+
* Lightweight watchdog: a worker thread posts a tick onto the main
|
|
21
|
+
* Looper every `intervalMs` and sleeps for `timeoutMs`. If the main
|
|
22
|
+
* thread didn't acknowledge the tick within that window we capture the
|
|
23
|
+
* main thread's stack trace as a Sentori event with `kind = "anr"`
|
|
24
|
+
* and write it to the same pending dir SentoriCrashHandler uses, so
|
|
25
|
+
* the next-launch drain delivers it.
|
|
26
|
+
*
|
|
27
|
+
* Design notes:
|
|
28
|
+
* - Single-shot per ANR: once we report, we wait for the main
|
|
29
|
+
* thread to recover before re-arming. Otherwise a 30 s freeze
|
|
30
|
+
* would dump six events.
|
|
31
|
+
* - We DON'T kill the process or rethrow. The OS does that on its
|
|
32
|
+
* own ANR threshold (~5 s for input-driven, longer otherwise).
|
|
33
|
+
* - Worker thread is a daemon so it doesn't keep the process alive.
|
|
34
|
+
* - Disabled in debug builds by default — the JS debugger pauses
|
|
35
|
+
* the main thread routinely and we don't want a flood. The host
|
|
36
|
+
* app can override via `SentoriAnrWatchdog.start(ctx, force = true)`.
|
|
37
|
+
*/
|
|
38
|
+
object SentoriAnrWatchdog {
|
|
39
|
+
|
|
40
|
+
private const val DEFAULT_TIMEOUT_MS = 5_000L
|
|
41
|
+
private const val DEFAULT_INTERVAL_MS = 1_000L
|
|
42
|
+
private const val PENDING_DIR_NAME = "sentori/pending"
|
|
43
|
+
|
|
44
|
+
@Volatile private var running = AtomicBoolean(false)
|
|
45
|
+
@Volatile private var thread: Thread? = null
|
|
46
|
+
@Volatile private var appCtx: Context? = null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start the watchdog. Idempotent — calling start() twice is a
|
|
50
|
+
* no-op. Pass `force = true` to enable in debug builds.
|
|
51
|
+
*/
|
|
52
|
+
@JvmStatic
|
|
53
|
+
@JvmOverloads
|
|
54
|
+
fun start(
|
|
55
|
+
context: Context,
|
|
56
|
+
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
|
|
57
|
+
intervalMs: Long = DEFAULT_INTERVAL_MS,
|
|
58
|
+
force: Boolean = false,
|
|
59
|
+
) {
|
|
60
|
+
if (!force && isDebuggable(context)) return
|
|
61
|
+
if (running.getAndSet(true)) return
|
|
62
|
+
appCtx = context.applicationContext
|
|
63
|
+
|
|
64
|
+
val mainHandler = Handler(Looper.getMainLooper())
|
|
65
|
+
val watchdogThread = Thread {
|
|
66
|
+
val tick = MainTick()
|
|
67
|
+
while (running.get()) {
|
|
68
|
+
tick.armed = true
|
|
69
|
+
mainHandler.post(tick)
|
|
70
|
+
try {
|
|
71
|
+
Thread.sleep(timeoutMs)
|
|
72
|
+
} catch (_: InterruptedException) {
|
|
73
|
+
return@Thread
|
|
74
|
+
}
|
|
75
|
+
if (tick.armed) {
|
|
76
|
+
// Main thread is wedged — capture once, then wait
|
|
77
|
+
// for the tick to land before arming again.
|
|
78
|
+
captureAnr()
|
|
79
|
+
while (running.get() && tick.armed) {
|
|
80
|
+
try {
|
|
81
|
+
Thread.sleep(intervalMs)
|
|
82
|
+
} catch (_: InterruptedException) {
|
|
83
|
+
return@Thread
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
watchdogThread.name = "Sentori-ANR-Watchdog"
|
|
90
|
+
watchdogThread.isDaemon = true
|
|
91
|
+
watchdogThread.start()
|
|
92
|
+
thread = watchdogThread
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@JvmStatic
|
|
96
|
+
fun stop() {
|
|
97
|
+
running.set(false)
|
|
98
|
+
thread?.interrupt()
|
|
99
|
+
thread = null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private fun captureAnr() {
|
|
103
|
+
val ctx = appCtx ?: return
|
|
104
|
+
try {
|
|
105
|
+
val mainStack = Looper.getMainLooper().thread.stackTrace
|
|
106
|
+
val event = buildAnrEvent(ctx, mainStack)
|
|
107
|
+
val dir = File(ctx.filesDir, PENDING_DIR_NAME)
|
|
108
|
+
if (!dir.exists()) dir.mkdirs()
|
|
109
|
+
val file = File(dir, "${uuid()}.json")
|
|
110
|
+
file.writeText(event.toString())
|
|
111
|
+
} catch (_: Throwable) {
|
|
112
|
+
// never throw from inside the watchdog — losing one
|
|
113
|
+
// capture beats killing the worker thread.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun buildAnrEvent(ctx: Context, mainStack: Array<StackTraceElement>): JSONObject {
|
|
118
|
+
val cfg = configMap(ctx)
|
|
119
|
+
val release = cfg["release"] ?: "unknown"
|
|
120
|
+
val environment = cfg["environment"] ?: "prod"
|
|
121
|
+
|
|
122
|
+
val device = JSONObject().apply {
|
|
123
|
+
put("os", "android")
|
|
124
|
+
put("osVersion", Build.VERSION.RELEASE)
|
|
125
|
+
put("model", "${Build.MANUFACTURER} ${Build.MODEL}")
|
|
126
|
+
}
|
|
127
|
+
val app = JSONObject().apply {
|
|
128
|
+
put("version", appVersion(ctx))
|
|
129
|
+
put("build", appBuild(ctx))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
val frames = JSONArray()
|
|
133
|
+
for (f in mainStack) {
|
|
134
|
+
frames.put(
|
|
135
|
+
JSONObject().apply {
|
|
136
|
+
put("function", "${f.className}.${f.methodName}")
|
|
137
|
+
put("file", f.fileName ?: "<unknown>")
|
|
138
|
+
put("line", f.lineNumber.coerceAtLeast(0))
|
|
139
|
+
put("inApp", isInApp(f.className))
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
val error = JSONObject().apply {
|
|
145
|
+
put("type", "ApplicationNotResponding")
|
|
146
|
+
put("message", "Main thread blocked for ≥ ${DEFAULT_TIMEOUT_MS} ms")
|
|
147
|
+
put("stack", frames)
|
|
148
|
+
put("cause", JSONObject.NULL)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return JSONObject().apply {
|
|
152
|
+
put("id", uuid())
|
|
153
|
+
put("timestamp", iso8601Now())
|
|
154
|
+
put("kind", "anr")
|
|
155
|
+
put("platform", "android")
|
|
156
|
+
put("release", release)
|
|
157
|
+
put("environment", environment)
|
|
158
|
+
put("device", device)
|
|
159
|
+
put("app", app)
|
|
160
|
+
put("user", JSONObject.NULL)
|
|
161
|
+
put("tags", JSONObject().apply { put("source", "sentori.anrWatchdog") })
|
|
162
|
+
put("breadcrumbs", JSONArray())
|
|
163
|
+
put("error", error)
|
|
164
|
+
put("fingerprint", JSONArray())
|
|
165
|
+
put("traceId", JSONObject.NULL)
|
|
166
|
+
put("spanId", JSONObject.NULL)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun configMap(ctx: Context): Map<String, String> {
|
|
171
|
+
val prefs = ctx.getSharedPreferences("sentori", Context.MODE_PRIVATE)
|
|
172
|
+
val out = mutableMapOf<String, String>()
|
|
173
|
+
for ((k, v) in prefs.all) if (v is String) out[k] = v
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun isInApp(cls: String): Boolean {
|
|
178
|
+
if (cls.startsWith("android.")) return false
|
|
179
|
+
if (cls.startsWith("androidx.")) return false
|
|
180
|
+
if (cls.startsWith("java.")) return false
|
|
181
|
+
if (cls.startsWith("javax.")) return false
|
|
182
|
+
if (cls.startsWith("kotlin.")) return false
|
|
183
|
+
if (cls.startsWith("kotlinx.")) return false
|
|
184
|
+
if (cls.startsWith("com.facebook.react.")) return false
|
|
185
|
+
if (cls.startsWith("com.android.")) return false
|
|
186
|
+
if (cls.startsWith("dalvik.")) return false
|
|
187
|
+
if (cls.startsWith("sun.")) return false
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun iso8601Now(): String {
|
|
192
|
+
val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
|
193
|
+
f.timeZone = TimeZone.getTimeZone("UTC")
|
|
194
|
+
return f.format(Date())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun uuid(): String = UUID.randomUUID().toString().lowercase(Locale.US)
|
|
198
|
+
|
|
199
|
+
private fun appVersion(ctx: Context): String =
|
|
200
|
+
try {
|
|
201
|
+
val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
202
|
+
pi.versionName ?: "0.0.0"
|
|
203
|
+
} catch (_: Throwable) {
|
|
204
|
+
"0.0.0"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private fun appBuild(ctx: Context): String =
|
|
208
|
+
try {
|
|
209
|
+
val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
210
|
+
pi.longVersionCode.toString()
|
|
211
|
+
} catch (_: Throwable) {
|
|
212
|
+
"0"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fun isDebuggable(ctx: Context): Boolean =
|
|
216
|
+
(ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
217
|
+
|
|
218
|
+
/** Posted to the main thread every poll. `armed` is flipped to
|
|
219
|
+
* false once it actually runs. */
|
|
220
|
+
private class MainTick : Runnable {
|
|
221
|
+
@Volatile var armed = true
|
|
222
|
+
|
|
223
|
+
override fun run() {
|
|
224
|
+
armed = false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -8,6 +8,8 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
|
|
8
8
|
* as the iOS module:
|
|
9
9
|
* - setConfig({ token, release, environment })
|
|
10
10
|
* - drainPending() -> List<String> (JSON bodies)
|
|
11
|
+
* - startAnrWatchdog({ timeoutMs?, intervalMs?, force? })
|
|
12
|
+
* - stopAnrWatchdog()
|
|
11
13
|
*/
|
|
12
14
|
class SentoriModule : Module() {
|
|
13
15
|
override fun definition() = ModuleDefinition {
|
|
@@ -26,6 +28,22 @@ class SentoriModule : Module() {
|
|
|
26
28
|
SentoriCrashHandler.consumePending()
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// Watchdog is opt-in from JS so the host app picks the
|
|
32
|
+
// trade-off — stricter detection vs noise from the Metro
|
|
33
|
+
// debugger pausing the main thread. Pass `force: true` to
|
|
34
|
+
// run in debug builds.
|
|
35
|
+
Function("startAnrWatchdog") { options: Map<String, Any?>? ->
|
|
36
|
+
val ctx = appContext.reactContext ?: return@Function
|
|
37
|
+
val timeoutMs = (options?.get("timeoutMs") as? Number)?.toLong() ?: 5_000L
|
|
38
|
+
val intervalMs = (options?.get("intervalMs") as? Number)?.toLong() ?: 1_000L
|
|
39
|
+
val force = (options?.get("force") as? Boolean) ?: false
|
|
40
|
+
SentoriAnrWatchdog.start(ctx, timeoutMs, intervalMs, force)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Function("stopAnrWatchdog") {
|
|
44
|
+
SentoriAnrWatchdog.stop()
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
// Dev-only helper — schedules an uncaught RuntimeException after
|
|
30
48
|
// a tick so the JS bridge has time to return; the crash is then
|
|
31
49
|
// captured by SentoriCrashHandler and written to
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// iOS hang detector — mirrors the Android ANR watchdog (Phase 22 sub-D).
|
|
4
|
+
///
|
|
5
|
+
/// A background thread posts a tick onto the main queue every
|
|
6
|
+
/// `intervalMs` and waits `timeoutMs` for it to run. If the main run
|
|
7
|
+
/// loop didn't drain the tick in time we capture the main thread's
|
|
8
|
+
/// call stack and write a Sentori event with `kind = "anr"` (the
|
|
9
|
+
/// dashboard already groups Android ANR + iOS hang under that kind —
|
|
10
|
+
/// the user-visible distinction lives in `tags.source`).
|
|
11
|
+
///
|
|
12
|
+
/// Single-shot per hang: once we report, we wait for the tick to
|
|
13
|
+
/// land before re-arming so a 30-second freeze doesn't dump six
|
|
14
|
+
/// events. Daemon thread (DispatchSourceTimer in a background
|
|
15
|
+
/// queue) so it can't keep the process alive on shutdown.
|
|
16
|
+
///
|
|
17
|
+
/// Disabled in DEBUG builds by default — the Xcode debugger
|
|
18
|
+
/// pauses the main thread routinely and we don't want a flood. The
|
|
19
|
+
/// host app can override via `start(force: true)`.
|
|
20
|
+
@objc public final class SentoriHangWatchdog: NSObject {
|
|
21
|
+
|
|
22
|
+
private static let pendingDirName = "sentori/pending"
|
|
23
|
+
private static let configKey = "com.sentori.config"
|
|
24
|
+
|
|
25
|
+
private static var running: Bool = false
|
|
26
|
+
private static var queue: DispatchQueue?
|
|
27
|
+
private static var timer: DispatchSourceTimer?
|
|
28
|
+
private static let lock = NSLock()
|
|
29
|
+
|
|
30
|
+
/// Start the watchdog. Idempotent.
|
|
31
|
+
@objc public static func start(timeoutMs: Int, intervalMs: Int, force: Bool) {
|
|
32
|
+
lock.lock()
|
|
33
|
+
defer { lock.unlock() }
|
|
34
|
+
if running { return }
|
|
35
|
+
if isDebug() && !force { return }
|
|
36
|
+
|
|
37
|
+
let q = DispatchQueue(label: "com.sentori.hangWatchdog", qos: .utility)
|
|
38
|
+
let t = DispatchSource.makeTimerSource(queue: q)
|
|
39
|
+
let interval = DispatchTimeInterval.milliseconds(intervalMs)
|
|
40
|
+
let timeoutNs = UInt64(max(0, timeoutMs)) * NSEC_PER_MSEC
|
|
41
|
+
|
|
42
|
+
// The "tick" path: post a Bool flip onto the main queue and
|
|
43
|
+
// record the time we did so. Read both fields in the worker
|
|
44
|
+
// tick to decide whether the main loop is alive.
|
|
45
|
+
let state = HangState()
|
|
46
|
+
|
|
47
|
+
t.schedule(deadline: .now() + interval, repeating: interval)
|
|
48
|
+
t.setEventHandler {
|
|
49
|
+
// If the previous tick is still pending after the timeout
|
|
50
|
+
// window, the main thread is wedged. Capture once, then
|
|
51
|
+
// hold off until the main loop catches up.
|
|
52
|
+
if state.armed.value, let armedAt = state.armedAt.value {
|
|
53
|
+
let elapsedNs = DispatchTime.now().uptimeNanoseconds &- armedAt.uptimeNanoseconds
|
|
54
|
+
if elapsedNs >= timeoutNs && !state.reportedThisHang.value {
|
|
55
|
+
state.reportedThisHang.value = true
|
|
56
|
+
captureHang(durationMs: Int(elapsedNs / NSEC_PER_MSEC))
|
|
57
|
+
}
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Main is responsive — re-arm.
|
|
62
|
+
state.armed.value = true
|
|
63
|
+
state.armedAt.value = DispatchTime.now()
|
|
64
|
+
state.reportedThisHang.value = false
|
|
65
|
+
DispatchQueue.main.async {
|
|
66
|
+
state.armed.value = false
|
|
67
|
+
state.armedAt.value = nil
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
t.resume()
|
|
71
|
+
timer = t
|
|
72
|
+
queue = q
|
|
73
|
+
running = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc public static func stop() {
|
|
77
|
+
lock.lock()
|
|
78
|
+
defer { lock.unlock() }
|
|
79
|
+
timer?.cancel()
|
|
80
|
+
timer = nil
|
|
81
|
+
queue = nil
|
|
82
|
+
running = false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private static func isDebug() -> Bool {
|
|
86
|
+
#if DEBUG
|
|
87
|
+
return true
|
|
88
|
+
#else
|
|
89
|
+
return false
|
|
90
|
+
#endif
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MARK: - capture
|
|
94
|
+
|
|
95
|
+
private static func captureHang(durationMs: Int) {
|
|
96
|
+
let cfg = UserDefaults.standard.dictionary(forKey: configKey) ?? [:]
|
|
97
|
+
let release = (cfg["release"] as? String) ?? "unknown"
|
|
98
|
+
let environment = (cfg["environment"] as? String) ?? "prod"
|
|
99
|
+
|
|
100
|
+
// Thread.callStackSymbols on the main thread is what we want;
|
|
101
|
+
// cross-thread inspection requires Mach APIs that App Store
|
|
102
|
+
// review tends to flag. Posting onto main blocks if main is
|
|
103
|
+
// wedged — we want the wedged stack, but we can't get it
|
|
104
|
+
// without main cooperation. Best-effort: capture this thread's
|
|
105
|
+
// stack (the watchdog) which is at least informative about
|
|
106
|
+
// the timing path. Phase 22 sub-F gets a proper main-thread
|
|
107
|
+
// stack via thread_state_t when we sit down with mach.
|
|
108
|
+
let frames = Thread.callStackSymbols.map { sym -> [String: Any] in
|
|
109
|
+
let parts = sym.split(
|
|
110
|
+
separator: " ", omittingEmptySubsequences: true
|
|
111
|
+
).map(String.init)
|
|
112
|
+
let module = parts.count > 1 ? parts[1] : "<unknown>"
|
|
113
|
+
let function =
|
|
114
|
+
parts.count > 3
|
|
115
|
+
? parts.dropFirst(3).joined(separator: " ")
|
|
116
|
+
: "<anonymous>"
|
|
117
|
+
return [
|
|
118
|
+
"function": function,
|
|
119
|
+
"file": module,
|
|
120
|
+
"line": 0,
|
|
121
|
+
"inApp": !module.contains("UIKit")
|
|
122
|
+
&& !module.contains("Foundation")
|
|
123
|
+
&& !module.contains("CoreFoundation")
|
|
124
|
+
&& !module.contains("libsystem")
|
|
125
|
+
&& !module.contains("libobjc"),
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let event: [String: Any] = [
|
|
130
|
+
"id": UUID().uuidString.lowercased(),
|
|
131
|
+
"timestamp": iso8601(Date()),
|
|
132
|
+
"kind": "anr",
|
|
133
|
+
"platform": "ios",
|
|
134
|
+
"release": release,
|
|
135
|
+
"environment": environment,
|
|
136
|
+
"device": [
|
|
137
|
+
"os": "ios",
|
|
138
|
+
"osVersion": osVersion(),
|
|
139
|
+
"model": deviceModel(),
|
|
140
|
+
],
|
|
141
|
+
"app": appInfo(),
|
|
142
|
+
"user": NSNull(),
|
|
143
|
+
"tags": ["source": "sentori.hangWatchdog"],
|
|
144
|
+
"breadcrumbs": [Any](),
|
|
145
|
+
"error": [
|
|
146
|
+
"type": "ApplicationNotResponding",
|
|
147
|
+
"message": "Main thread blocked for ≥ \(durationMs) ms",
|
|
148
|
+
"stack": frames,
|
|
149
|
+
"cause": NSNull(),
|
|
150
|
+
],
|
|
151
|
+
"fingerprint": [String](),
|
|
152
|
+
"traceId": NSNull(),
|
|
153
|
+
"spanId": NSNull(),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
guard
|
|
157
|
+
let docs = FileManager.default.urls(
|
|
158
|
+
for: .documentDirectory, in: .userDomainMask
|
|
159
|
+
).first
|
|
160
|
+
else { return }
|
|
161
|
+
let dir = docs.appendingPathComponent(pendingDirName)
|
|
162
|
+
try? FileManager.default.createDirectory(
|
|
163
|
+
at: dir, withIntermediateDirectories: true)
|
|
164
|
+
let url = dir.appendingPathComponent(
|
|
165
|
+
"\(UUID().uuidString.lowercased()).json")
|
|
166
|
+
if let data = try? JSONSerialization.data(
|
|
167
|
+
withJSONObject: event, options: [])
|
|
168
|
+
{
|
|
169
|
+
try? data.write(to: url)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private static func iso8601(_ date: Date) -> String {
|
|
174
|
+
let f = ISO8601DateFormatter()
|
|
175
|
+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
176
|
+
return f.string(from: date)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static func osVersion() -> String {
|
|
180
|
+
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
181
|
+
return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static func deviceModel() -> String {
|
|
185
|
+
var s = utsname()
|
|
186
|
+
uname(&s)
|
|
187
|
+
return withUnsafePointer(to: &s.machine) { ptr in
|
|
188
|
+
ptr.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
189
|
+
String(validatingUTF8: $0) ?? "unknown"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private static func appInfo() -> [String: Any] {
|
|
195
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
196
|
+
var d: [String: Any] = [
|
|
197
|
+
"version": (info["CFBundleShortVersionString"] as? String)
|
|
198
|
+
?? "0.0.0"
|
|
199
|
+
]
|
|
200
|
+
if let build = info["CFBundleVersion"] as? String {
|
|
201
|
+
d["build"] = build
|
|
202
|
+
}
|
|
203
|
+
return d
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Mutable boxes shared between the watchdog tick and the main-queue
|
|
208
|
+
/// ack. Class so the closures can mutate without `inout` and so the
|
|
209
|
+
/// references survive across the `setEventHandler` capture.
|
|
210
|
+
private final class HangState {
|
|
211
|
+
let armed = Box(false)
|
|
212
|
+
let armedAt = Box<DispatchTime?>(nil)
|
|
213
|
+
let reportedThisHang = Box(false)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private final class Box<T> {
|
|
217
|
+
var value: T
|
|
218
|
+
init(_ v: T) { self.value = v }
|
|
219
|
+
}
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -24,6 +24,25 @@ public class SentoriModule: Module {
|
|
|
24
24
|
return SentoriCrashHandler.consumePending()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Phase 22 sub-E: opt-in iOS hang watchdog. Same JS function
|
|
28
|
+
// name as Android (sub-D) so the host app calls
|
|
29
|
+
// `startAnrWatchdog(...)` once, both platforms react.
|
|
30
|
+
// Defaults: 2 s timeout, 1 s tick interval, debug-build off.
|
|
31
|
+
Function("startAnrWatchdog") { (options: [String: Any]?) in
|
|
32
|
+
let timeoutMs = (options?["timeoutMs"] as? Int) ?? 2000
|
|
33
|
+
let intervalMs = (options?["intervalMs"] as? Int) ?? 1000
|
|
34
|
+
let force = (options?["force"] as? Bool) ?? false
|
|
35
|
+
SentoriHangWatchdog.start(
|
|
36
|
+
timeoutMs: timeoutMs,
|
|
37
|
+
intervalMs: intervalMs,
|
|
38
|
+
force: force
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Function("stopAnrWatchdog") {
|
|
43
|
+
SentoriHangWatchdog.stop()
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// Dev-only helper used by the example app to verify the
|
|
28
47
|
// crash-write / drain round-trip without writing native code in
|
|
29
48
|
// the host app. Schedules a real NSException after a tick so
|
package/lib/index.d.ts
CHANGED
|
@@ -13,6 +13,6 @@ export { init, init as initSentori } from './init';
|
|
|
13
13
|
export { addBreadcrumb } from './breadcrumbs';
|
|
14
14
|
export { setUser, getUser, captureError, captureException } from './capture';
|
|
15
15
|
export { ErrorBoundary } from './error-boundary';
|
|
16
|
-
export { triggerNativeCrash } from './native';
|
|
16
|
+
export { startAnrWatchdog, stopAnrWatchdog, triggerNativeCrash, } from './native';
|
|
17
17
|
export type { Event, SentoriError, Frame, Breadcrumb, BreadcrumbType, Device, DeviceOS, App, User, Tags, EventKind, Platform, } from './types';
|
|
18
18
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,eAAO,MAAM,OAAO;;;;;;;;CAQnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,eAAO,MAAM,OAAO;;;;;;;;CAQnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,YAAY,EACV,KAAK,EACL,YAAY,EACZ,KAAK,EACL,UAAU,EACV,cAAc,EACd,MAAM,EACN,QAAQ,EACR,GAAG,EACH,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,MAAM,SAAS,CAAC"}
|
package/lib/index.js
CHANGED
|
@@ -16,5 +16,5 @@ export { init, init as initSentori } from './init';
|
|
|
16
16
|
export { addBreadcrumb } from './breadcrumbs';
|
|
17
17
|
export { setUser, getUser, captureError, captureException } from './capture';
|
|
18
18
|
export { ErrorBoundary } from './error-boundary';
|
|
19
|
-
export { triggerNativeCrash } from './native';
|
|
19
|
+
export { startAnrWatchdog, stopAnrWatchdog, triggerNativeCrash, } from './native';
|
|
20
20
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,IAAI;IACJ,aAAa;IACb,OAAO;IACP,OAAO;IACP,YAAY;IACZ,gBAAgB;IAChB,aAAa;CACd,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,IAAI;IACJ,aAAa;IACb,OAAO;IACP,OAAO;IACP,YAAY;IACZ,gBAAgB;IAChB,aAAa;CACd,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC"}
|
package/lib/native.d.ts
CHANGED
|
@@ -20,4 +20,20 @@ export declare function drainNativePending(): Promise<string[]>;
|
|
|
20
20
|
* No-op when the native module isn't installed (jest, bun test, web).
|
|
21
21
|
*/
|
|
22
22
|
export declare function triggerNativeCrash(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Phase 22 sub-D: start the Android ANR watchdog.
|
|
25
|
+
*
|
|
26
|
+
* startAnrWatchdog() // default 5s/1s, prod-only
|
|
27
|
+
* startAnrWatchdog({ force: true }) // include debug builds
|
|
28
|
+
* startAnrWatchdog({ timeoutMs: 3000 }) // tighter threshold
|
|
29
|
+
*
|
|
30
|
+
* Returns silently on iOS / web / jest. iOS hang detection (sub-E)
|
|
31
|
+
* will hook the same JS function once landed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function startAnrWatchdog(options?: {
|
|
34
|
+
force?: boolean;
|
|
35
|
+
intervalMs?: number;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}): void;
|
|
38
|
+
export declare function stopAnrWatchdog(): void;
|
|
23
39
|
//# sourceMappingURL=native.d.ts.map
|
package/lib/native.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAwCH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IACtC,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;CACd,GAAG,IAAI,CAMP;AAED,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAQ5D;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAMzC;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE;IACzC,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,IAAI,CAMP;AAED,wBAAgB,eAAe,IAAI,IAAI,CAMtC"}
|
package/lib/native.js
CHANGED
|
@@ -53,4 +53,30 @@ export function triggerNativeCrash() {
|
|
|
53
53
|
// never throw from a debugging helper
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Phase 22 sub-D: start the Android ANR watchdog.
|
|
58
|
+
*
|
|
59
|
+
* startAnrWatchdog() // default 5s/1s, prod-only
|
|
60
|
+
* startAnrWatchdog({ force: true }) // include debug builds
|
|
61
|
+
* startAnrWatchdog({ timeoutMs: 3000 }) // tighter threshold
|
|
62
|
+
*
|
|
63
|
+
* Returns silently on iOS / web / jest. iOS hang detection (sub-E)
|
|
64
|
+
* will hook the same JS function once landed.
|
|
65
|
+
*/
|
|
66
|
+
export function startAnrWatchdog(options) {
|
|
67
|
+
try {
|
|
68
|
+
native()?.startAnrWatchdog?.(options);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// never throw from init helpers
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function stopAnrWatchdog() {
|
|
75
|
+
try {
|
|
76
|
+
native()?.stopAnrWatchdog?.();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
}
|
|
56
82
|
//# sourceMappingURL=native.js.map
|
package/lib/native.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native.js","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"native.js","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAyBH,IAAI,OAA+C,CAAA;AAEnD,SAAS,MAAM;IACb,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,OAAO,CAAA;IACzC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,OAAO,CAAC,mBAAmB,CAEvC,CAAA;QACD,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAsB,SAAS,CAAC,CAAA;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,IAAI,CAAA;IAChB,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAI/B;IACC,IAAI,CAAC;QACH,MAAM,EAAE,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,sBAAsB;IACxB,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,CAAC,GAAG,MAAM,EAAE,CAAA;IAClB,IAAI,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IACjB,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,CAAC,YAAY,EAAE,CAAA;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB;IAChC,IAAI,CAAC;QACH,MAAM,EAAE,EAAE,sBAAsB,EAAE,EAAE,CAAA;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,sCAAsC;IACxC,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAIhC;IACC,IAAI,CAAC;QACH,MAAM,EAAE,EAAE,gBAAgB,EAAE,CAAC,OAAO,CAAC,CAAA;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,gCAAgC;IAClC,CAAC;AACH,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,IAAI,CAAC;QACH,MAAM,EAAE,EAAE,eAAe,EAAE,EAAE,CAAA;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,SAAS;IACX,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Sentori SDK for React Native \u2014 JS-layer error capture, native crash handlers (iOS / Android), batched transport.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sentori.golia.jp",
|
|
@@ -64,6 +64,6 @@
|
|
|
64
64
|
"access": "public"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@goliapkg/sentori-core": "0.
|
|
67
|
+
"@goliapkg/sentori-core": "0.2.0"
|
|
68
68
|
}
|
|
69
69
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,11 @@ export { init, init as initSentori } from './init';
|
|
|
19
19
|
export { addBreadcrumb } from './breadcrumbs';
|
|
20
20
|
export { setUser, getUser, captureError, captureException } from './capture';
|
|
21
21
|
export { ErrorBoundary } from './error-boundary';
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
startAnrWatchdog,
|
|
24
|
+
stopAnrWatchdog,
|
|
25
|
+
triggerNativeCrash,
|
|
26
|
+
} from './native';
|
|
23
27
|
|
|
24
28
|
export type {
|
|
25
29
|
Event,
|
package/src/native.ts
CHANGED
|
@@ -11,6 +11,18 @@ type SentoriNativeModule = {
|
|
|
11
11
|
release: string
|
|
12
12
|
token: string
|
|
13
13
|
}) => void
|
|
14
|
+
/**
|
|
15
|
+
* Phase 22 sub-D: opt-in Android ANR watchdog. Posts a tick to the
|
|
16
|
+
* main looper every `intervalMs`; if not acknowledged within
|
|
17
|
+
* `timeoutMs`, captures the main-thread stack as an `anr` event.
|
|
18
|
+
* No-op on iOS today — iOS hang detection lands in sub-E.
|
|
19
|
+
*/
|
|
20
|
+
startAnrWatchdog?: (options?: {
|
|
21
|
+
force?: boolean
|
|
22
|
+
intervalMs?: number
|
|
23
|
+
timeoutMs?: number
|
|
24
|
+
}) => void
|
|
25
|
+
stopAnrWatchdog?: () => void
|
|
14
26
|
/** Dev-only — example app uses this to verify the crash flow. */
|
|
15
27
|
triggerTestNativeCrash?: () => void
|
|
16
28
|
}
|
|
@@ -69,3 +81,33 @@ export function triggerNativeCrash(): void {
|
|
|
69
81
|
// never throw from a debugging helper
|
|
70
82
|
}
|
|
71
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Phase 22 sub-D: start the Android ANR watchdog.
|
|
87
|
+
*
|
|
88
|
+
* startAnrWatchdog() // default 5s/1s, prod-only
|
|
89
|
+
* startAnrWatchdog({ force: true }) // include debug builds
|
|
90
|
+
* startAnrWatchdog({ timeoutMs: 3000 }) // tighter threshold
|
|
91
|
+
*
|
|
92
|
+
* Returns silently on iOS / web / jest. iOS hang detection (sub-E)
|
|
93
|
+
* will hook the same JS function once landed.
|
|
94
|
+
*/
|
|
95
|
+
export function startAnrWatchdog(options?: {
|
|
96
|
+
force?: boolean
|
|
97
|
+
intervalMs?: number
|
|
98
|
+
timeoutMs?: number
|
|
99
|
+
}): void {
|
|
100
|
+
try {
|
|
101
|
+
native()?.startAnrWatchdog?.(options)
|
|
102
|
+
} catch {
|
|
103
|
+
// never throw from init helpers
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function stopAnrWatchdog(): void {
|
|
108
|
+
try {
|
|
109
|
+
native()?.stopAnrWatchdog?.()
|
|
110
|
+
} catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
}
|