@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.
@@ -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
+ }
@@ -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
@@ -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,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAE9C,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"}
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,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AA4BH,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"}
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;AAaH,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"}
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.2.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.1.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 { triggerNativeCrash } from './native';
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
+ }