@goliapkg/sentori-react-native 0.3.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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-react-native",
3
- "version": "0.3.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",