@goliapkg/sentori-react-native 0.1.0
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/README.md +5 -0
- package/SentoriReactNative.podspec +21 -0
- package/android/build.gradle +38 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +213 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +39 -0
- package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +60 -0
- package/expo-module.config.json +9 -0
- package/ios/SentoriCrashHandler.swift +160 -0
- package/ios/SentoriModule.swift +43 -0
- package/ios/Tests/SentoriCrashHandlerTests.swift +59 -0
- package/lib/breadcrumbs.d.ts +11 -0
- package/lib/breadcrumbs.d.ts.map +1 -0
- package/lib/breadcrumbs.js +21 -0
- package/lib/breadcrumbs.js.map +1 -0
- package/lib/capture.d.ts +23 -0
- package/lib/capture.d.ts.map +1 -0
- package/lib/capture.js +91 -0
- package/lib/capture.js.map +1 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +10 -0
- package/lib/config.js.map +1 -0
- package/lib/error-boundary.d.ts +17 -0
- package/lib/error-boundary.d.ts.map +1 -0
- package/lib/error-boundary.js +26 -0
- package/lib/error-boundary.js.map +1 -0
- package/lib/handlers/global.d.ts +2 -0
- package/lib/handlers/global.d.ts.map +1 -0
- package/lib/handlers/global.js +29 -0
- package/lib/handlers/global.js.map +1 -0
- package/lib/handlers/network.d.ts +2 -0
- package/lib/handlers/network.d.ts.map +1 -0
- package/lib/handlers/network.js +69 -0
- package/lib/handlers/network.js.map +1 -0
- package/lib/handlers/promise.d.ts +2 -0
- package/lib/handlers/promise.d.ts.map +1 -0
- package/lib/handlers/promise.js +27 -0
- package/lib/handlers/promise.js.map +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +20 -0
- package/lib/index.js.map +1 -0
- package/lib/init.d.ts +18 -0
- package/lib/init.d.ts.map +1 -0
- package/lib/init.js +56 -0
- package/lib/init.js.map +1 -0
- package/lib/native.d.ts +23 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +56 -0
- package/lib/native.js.map +1 -0
- package/lib/stack.d.ts +3 -0
- package/lib/stack.d.ts.map +1 -0
- package/lib/stack.js +69 -0
- package/lib/stack.js.map +1 -0
- package/lib/transport.d.ts +8 -0
- package/lib/transport.d.ts.map +1 -0
- package/lib/transport.js +143 -0
- package/lib/transport.js.map +1 -0
- package/lib/types.d.ts +62 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/uuid.d.ts +11 -0
- package/lib/uuid.d.ts.map +1 -0
- package/lib/uuid.js +46 -0
- package/lib/uuid.js.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/breadcrumbs.test.ts +44 -0
- package/src/__tests__/stack.test.ts +43 -0
- package/src/__tests__/transport.test.ts +112 -0
- package/src/__tests__/uuid.test.ts +41 -0
- package/src/breadcrumbs.ts +33 -0
- package/src/capture.ts +108 -0
- package/src/config.ts +21 -0
- package/src/error-boundary.tsx +38 -0
- package/src/handlers/global.ts +36 -0
- package/src/handlers/network.ts +70 -0
- package/src/handlers/promise.ts +38 -0
- package/src/index.ts +37 -0
- package/src/init.ts +80 -0
- package/src/native.ts +71 -0
- package/src/stack.ts +72 -0
- package/src/transport.ts +164 -0
- package/src/types.ts +63 -0
- package/src/uuid.ts +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'SentoriReactNative'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = 'TBD'
|
|
11
|
+
s.author = { 'Sentori' => 'support@sentori.golia.jp' }
|
|
12
|
+
s.homepage = 'https://sentori.golia.jp'
|
|
13
|
+
s.platforms = { ios: '13.4', tvos: '13.4' }
|
|
14
|
+
s.swift_version = '5.4'
|
|
15
|
+
s.source = { git: '' }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
|
|
20
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift,hpp,cpp}'
|
|
21
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
|
|
4
|
+
group = 'com.sentori'
|
|
5
|
+
version = rootProject.hasProperty('sentoriPackageVersion')
|
|
6
|
+
? rootProject.sentoriPackageVersion
|
|
7
|
+
: '0.0.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace 'com.sentori'
|
|
11
|
+
compileSdk rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : 35
|
|
12
|
+
|
|
13
|
+
defaultConfig {
|
|
14
|
+
minSdk rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : 24
|
|
15
|
+
targetSdk rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : 35
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
sourceSets {
|
|
19
|
+
main {
|
|
20
|
+
manifest.srcFile 'src/main/AndroidManifest.xml'
|
|
21
|
+
java.srcDirs = ['src/main/java']
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
kotlinOptions {
|
|
26
|
+
jvmTarget = '17'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
compileOptions {
|
|
30
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
31
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dependencies {
|
|
36
|
+
implementation project(':expo-modules-core')
|
|
37
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlinVersion ?: '2.0.21'}"
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
package com.sentori
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import org.json.JSONArray
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.io.File
|
|
8
|
+
import java.text.SimpleDateFormat
|
|
9
|
+
import java.util.Date
|
|
10
|
+
import java.util.Locale
|
|
11
|
+
import java.util.TimeZone
|
|
12
|
+
import java.util.UUID
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Static crash handler — captures Java/Kotlin uncaught exceptions on
|
|
16
|
+
* Android and writes one event-shaped JSON file per crash to
|
|
17
|
+
* <filesDir>/sentori/pending/<uuid>.json. JS drains that directory on
|
|
18
|
+
* next launch via Sentori.drainPending().
|
|
19
|
+
*
|
|
20
|
+
* What this does NOT do (Phase 7 v0.1):
|
|
21
|
+
* - native crashes (NDK / SIGSEGV) — Phase 7 explicitly skips signal-
|
|
22
|
+
* based handlers per ROADMAP.
|
|
23
|
+
* - ANR detection — deferred to v0.2.
|
|
24
|
+
*/
|
|
25
|
+
object SentoriCrashHandler {
|
|
26
|
+
|
|
27
|
+
private const val PREFS = "sentori"
|
|
28
|
+
private const val PENDING_DIR_NAME = "sentori/pending"
|
|
29
|
+
|
|
30
|
+
@Volatile private var appCtx: Context? = null
|
|
31
|
+
@Volatile private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
|
32
|
+
|
|
33
|
+
@JvmStatic
|
|
34
|
+
fun register(context: Context) {
|
|
35
|
+
appCtx = context.applicationContext
|
|
36
|
+
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
|
37
|
+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
38
|
+
try {
|
|
39
|
+
write(throwable)
|
|
40
|
+
} catch (_: Throwable) {
|
|
41
|
+
// never throw inside the crash handler
|
|
42
|
+
}
|
|
43
|
+
previousHandler?.uncaughtException(thread, throwable)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@JvmStatic
|
|
48
|
+
fun setConfig(config: Map<String, Any?>) {
|
|
49
|
+
val ctx = appCtx ?: return
|
|
50
|
+
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
51
|
+
val edit = prefs.edit()
|
|
52
|
+
edit.clear()
|
|
53
|
+
for ((k, v) in config) {
|
|
54
|
+
when (v) {
|
|
55
|
+
is String -> edit.putString(k, v)
|
|
56
|
+
is Int -> edit.putInt(k, v)
|
|
57
|
+
is Boolean -> edit.putBoolean(k, v)
|
|
58
|
+
else -> {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
edit.apply()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@JvmStatic
|
|
65
|
+
fun consumePending(): List<String> {
|
|
66
|
+
val dir = pendingDir() ?: return emptyList()
|
|
67
|
+
if (!dir.exists()) return emptyList()
|
|
68
|
+
val out = mutableListOf<String>()
|
|
69
|
+
val files = dir.listFiles { f -> f.extension == "json" } ?: emptyArray()
|
|
70
|
+
for (f in files) {
|
|
71
|
+
try {
|
|
72
|
+
out.add(f.readText())
|
|
73
|
+
} catch (_: Throwable) {
|
|
74
|
+
// skip unreadable file
|
|
75
|
+
}
|
|
76
|
+
f.delete()
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── internals ────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
private fun pendingDir(): File? {
|
|
84
|
+
val ctx = appCtx ?: return null
|
|
85
|
+
val dir = File(ctx.filesDir, PENDING_DIR_NAME)
|
|
86
|
+
if (!dir.exists()) dir.mkdirs()
|
|
87
|
+
return dir
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private fun configMap(): Map<String, String> {
|
|
91
|
+
val ctx = appCtx ?: return emptyMap()
|
|
92
|
+
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
93
|
+
val out = mutableMapOf<String, String>()
|
|
94
|
+
for ((k, v) in prefs.all) if (v is String) out[k] = v
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun write(throwable: Throwable) {
|
|
99
|
+
val cfg = configMap()
|
|
100
|
+
val release = cfg["release"] ?: "unknown"
|
|
101
|
+
val environment = cfg["environment"] ?: "prod"
|
|
102
|
+
|
|
103
|
+
val device = JSONObject().apply {
|
|
104
|
+
put("os", "android")
|
|
105
|
+
put("osVersion", Build.VERSION.RELEASE)
|
|
106
|
+
put("model", "${Build.MANUFACTURER} ${Build.MODEL}")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
val app = JSONObject().apply {
|
|
110
|
+
put("version", appVersion())
|
|
111
|
+
put("build", appBuild())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
val error = errorToJson(throwable)
|
|
115
|
+
|
|
116
|
+
val event = JSONObject().apply {
|
|
117
|
+
put("id", uuidLower())
|
|
118
|
+
put("timestamp", iso8601Now())
|
|
119
|
+
put("kind", "error")
|
|
120
|
+
put("platform", "android")
|
|
121
|
+
put("release", release)
|
|
122
|
+
put("environment", environment)
|
|
123
|
+
put("device", device)
|
|
124
|
+
put("app", app)
|
|
125
|
+
put("user", JSONObject.NULL)
|
|
126
|
+
put("tags", JSONObject())
|
|
127
|
+
put("breadcrumbs", JSONArray())
|
|
128
|
+
put("error", error)
|
|
129
|
+
put("fingerprint", JSONArray())
|
|
130
|
+
put("traceId", JSONObject.NULL)
|
|
131
|
+
put("spanId", JSONObject.NULL)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val dir = pendingDir() ?: return
|
|
135
|
+
val file = File(dir, "${uuidLower()}.json")
|
|
136
|
+
try {
|
|
137
|
+
file.writeText(event.toString())
|
|
138
|
+
} catch (_: Throwable) {
|
|
139
|
+
// best-effort
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun errorToJson(throwable: Throwable): JSONObject {
|
|
144
|
+
return JSONObject().apply {
|
|
145
|
+
put("type", throwable.javaClass.name)
|
|
146
|
+
put("message", throwable.message ?: "")
|
|
147
|
+
put("stack", framesToJson(throwable))
|
|
148
|
+
val cause = throwable.cause
|
|
149
|
+
if (cause != null && cause !== throwable) {
|
|
150
|
+
put("cause", errorToJson(cause))
|
|
151
|
+
} else {
|
|
152
|
+
put("cause", JSONObject.NULL)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private fun framesToJson(throwable: Throwable): JSONArray {
|
|
158
|
+
val arr = JSONArray()
|
|
159
|
+
for (f in throwable.stackTrace) {
|
|
160
|
+
val frame = JSONObject().apply {
|
|
161
|
+
put("function", "${f.className}.${f.methodName}")
|
|
162
|
+
put("file", f.fileName ?: "<unknown>")
|
|
163
|
+
put("line", f.lineNumber.coerceAtLeast(0))
|
|
164
|
+
put("inApp", isInApp(f))
|
|
165
|
+
}
|
|
166
|
+
arr.put(frame)
|
|
167
|
+
}
|
|
168
|
+
return arr
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun isInApp(f: StackTraceElement): Boolean {
|
|
172
|
+
val cls = f.className
|
|
173
|
+
if (cls.startsWith("android.")) return false
|
|
174
|
+
if (cls.startsWith("androidx.")) return false
|
|
175
|
+
if (cls.startsWith("java.")) return false
|
|
176
|
+
if (cls.startsWith("javax.")) return false
|
|
177
|
+
if (cls.startsWith("kotlin.")) return false
|
|
178
|
+
if (cls.startsWith("kotlinx.")) return false
|
|
179
|
+
if (cls.startsWith("com.facebook.react.")) return false
|
|
180
|
+
if (cls.startsWith("com.android.")) return false
|
|
181
|
+
if (cls.startsWith("dalvik.")) return false
|
|
182
|
+
if (cls.startsWith("sun.")) return false
|
|
183
|
+
return true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private fun iso8601Now(): String {
|
|
187
|
+
val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
|
188
|
+
f.timeZone = TimeZone.getTimeZone("UTC")
|
|
189
|
+
return f.format(Date())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun uuidLower(): String = UUID.randomUUID().toString().lowercase(Locale.US)
|
|
193
|
+
|
|
194
|
+
private fun appVersion(): String {
|
|
195
|
+
val ctx = appCtx ?: return "0.0.0"
|
|
196
|
+
return try {
|
|
197
|
+
val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
198
|
+
pi.versionName ?: "0.0.0"
|
|
199
|
+
} catch (_: Throwable) {
|
|
200
|
+
"0.0.0"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun appBuild(): String {
|
|
205
|
+
val ctx = appCtx ?: return "0"
|
|
206
|
+
return try {
|
|
207
|
+
val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
208
|
+
pi.longVersionCode.toString()
|
|
209
|
+
} catch (_: Throwable) {
|
|
210
|
+
"0"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.sentori
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Expo Module exposing the Android crash handler to JS. Same JS contract
|
|
8
|
+
* as the iOS module:
|
|
9
|
+
* - setConfig({ token, release, environment })
|
|
10
|
+
* - drainPending() -> List<String> (JSON bodies)
|
|
11
|
+
*/
|
|
12
|
+
class SentoriModule : Module() {
|
|
13
|
+
override fun definition() = ModuleDefinition {
|
|
14
|
+
Name("Sentori")
|
|
15
|
+
|
|
16
|
+
OnCreate {
|
|
17
|
+
val ctx = appContext.reactContext ?: return@OnCreate
|
|
18
|
+
SentoriCrashHandler.register(ctx)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Function("setConfig") { config: Map<String, Any?> ->
|
|
22
|
+
SentoriCrashHandler.setConfig(config)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
AsyncFunction("drainPending") {
|
|
26
|
+
SentoriCrashHandler.consumePending()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Dev-only helper — schedules an uncaught RuntimeException after
|
|
30
|
+
// a tick so the JS bridge has time to return; the crash is then
|
|
31
|
+
// captured by SentoriCrashHandler and written to
|
|
32
|
+
// <filesDir>/sentori/pending/.
|
|
33
|
+
Function("triggerTestNativeCrash") {
|
|
34
|
+
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
35
|
+
throw RuntimeException("Sentori test native crash")
|
|
36
|
+
}, 50)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Phase 16 sub-E: Robolectric coverage for SentoriCrashHandler (回填 Phase 7).
|
|
2
|
+
//
|
|
3
|
+
// Run via Gradle on the Android host:
|
|
4
|
+
// ./gradlew :sentori-react-native:testDebugUnitTest
|
|
5
|
+
//
|
|
6
|
+
// We can't easily catch a real uncaught Throwable in-process (the test
|
|
7
|
+
// runner would itself crash), so we drive the persistence helper
|
|
8
|
+
// directly and assert it writes a protocol-shaped JSON file.
|
|
9
|
+
|
|
10
|
+
package com.sentori
|
|
11
|
+
|
|
12
|
+
import androidx.test.core.app.ApplicationProvider
|
|
13
|
+
import org.junit.After
|
|
14
|
+
import org.junit.Before
|
|
15
|
+
import org.junit.Test
|
|
16
|
+
import org.junit.runner.RunWith
|
|
17
|
+
import org.robolectric.RobolectricTestRunner
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
import java.io.File
|
|
20
|
+
import kotlin.test.assertEquals
|
|
21
|
+
import kotlin.test.assertNotNull
|
|
22
|
+
import kotlin.test.assertTrue
|
|
23
|
+
|
|
24
|
+
@RunWith(RobolectricTestRunner::class)
|
|
25
|
+
class SentoriCrashHandlerTest {
|
|
26
|
+
private lateinit var pendingDir: File
|
|
27
|
+
|
|
28
|
+
@Before
|
|
29
|
+
fun setUp() {
|
|
30
|
+
val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
31
|
+
pendingDir = File(ctx.filesDir, "sentori/pending")
|
|
32
|
+
pendingDir.deleteRecursively()
|
|
33
|
+
pendingDir.mkdirs()
|
|
34
|
+
SentoriCrashHandler.installForTesting(ctx)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@After
|
|
38
|
+
fun tearDown() {
|
|
39
|
+
pendingDir.deleteRecursively()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun writePendingProducesValidEventJson() {
|
|
44
|
+
val ex = RuntimeException("boom from robolectric")
|
|
45
|
+
SentoriCrashHandler.persistForTesting(ex, "android-test-thread")
|
|
46
|
+
|
|
47
|
+
val files = pendingDir.listFiles { f -> f.name.endsWith(".json") }
|
|
48
|
+
assertNotNull(files, "no listing in $pendingDir")
|
|
49
|
+
assertEquals(1, files.size, "expected exactly one pending file")
|
|
50
|
+
|
|
51
|
+
val payload = JSONObject(files[0].readText())
|
|
52
|
+
assertEquals("error", payload.getString("kind"))
|
|
53
|
+
assertEquals("android", payload.getString("platform"))
|
|
54
|
+
assertNotNull(payload.getString("id"))
|
|
55
|
+
assertNotNull(payload.getString("timestamp"))
|
|
56
|
+
val error = payload.getJSONObject("error")
|
|
57
|
+
assertEquals("RuntimeException", error.getString("type"))
|
|
58
|
+
assertTrue(error.getString("message").contains("boom"))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Static crash handler — captures NSException and writes one JSON file
|
|
4
|
+
/// per crash to <Documents>/sentori/pending/<uuid>.json. JS drains that
|
|
5
|
+
/// directory on next launch via `Sentori.drainPending()`.
|
|
6
|
+
///
|
|
7
|
+
/// What this does NOT do (Phase 7 v0.1):
|
|
8
|
+
/// - signal-based native crashes (SIGSEGV / SIGABRT etc.) — see ROADMAP
|
|
9
|
+
/// "explicitly out" list. Only Objective-C exceptions are caught here.
|
|
10
|
+
@objc public final class SentoriCrashHandler: NSObject {
|
|
11
|
+
|
|
12
|
+
private static let configKey = "com.sentori.config"
|
|
13
|
+
private static let pendingDirName = "sentori/pending"
|
|
14
|
+
|
|
15
|
+
/// Install the global uncaught-exception handler. C function pointer,
|
|
16
|
+
/// so we cannot capture local context (no chaining to a previously
|
|
17
|
+
/// installed handler in v0.1 — RedBox in dev still receives via
|
|
18
|
+
/// JS-side handlers; in release this just replaces the default).
|
|
19
|
+
@objc public static func register() {
|
|
20
|
+
NSSetUncaughtExceptionHandler(SentoriCrashHandler.exceptionHandler)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private static let exceptionHandler: @convention(c) (NSException) -> Void = { exception in
|
|
24
|
+
SentoriCrashHandler.write(exception: exception)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// JS side calls this on `sentori.init(...)` so the crash handler
|
|
28
|
+
/// has release / environment when an exception fires later.
|
|
29
|
+
@objc public static func setConfig(_ config: [String: Any]) {
|
|
30
|
+
UserDefaults.standard.set(config, forKey: configKey)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Read all pending-crash files, return their contents (UTF-8 JSON
|
|
34
|
+
/// strings), and remove them from disk. Best-effort: any I/O error
|
|
35
|
+
/// drops that one file silently.
|
|
36
|
+
@objc public static func consumePending() -> [String] {
|
|
37
|
+
guard let dir = pendingDir() else { return [] }
|
|
38
|
+
let urls = (try? FileManager.default.contentsOfDirectory(
|
|
39
|
+
at: dir, includingPropertiesForKeys: nil)) ?? []
|
|
40
|
+
var out: [String] = []
|
|
41
|
+
for url in urls where url.pathExtension == "json" {
|
|
42
|
+
if let data = try? Data(contentsOf: url),
|
|
43
|
+
let str = String(data: data, encoding: .utf8) {
|
|
44
|
+
out.append(str)
|
|
45
|
+
}
|
|
46
|
+
try? FileManager.default.removeItem(at: url)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// MARK: - Internals
|
|
52
|
+
|
|
53
|
+
private static func pendingDir() -> URL? {
|
|
54
|
+
guard let docs = FileManager.default.urls(
|
|
55
|
+
for: .documentDirectory, in: .userDomainMask).first else { return nil }
|
|
56
|
+
let dir = docs.appendingPathComponent(pendingDirName)
|
|
57
|
+
try? FileManager.default.createDirectory(
|
|
58
|
+
at: dir, withIntermediateDirectories: true)
|
|
59
|
+
return dir
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static func config() -> [String: Any] {
|
|
63
|
+
return UserDefaults.standard.dictionary(forKey: configKey) ?? [:]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static func write(exception: NSException) {
|
|
67
|
+
let cfg = config()
|
|
68
|
+
let release = (cfg["release"] as? String) ?? "unknown"
|
|
69
|
+
let environment = (cfg["environment"] as? String) ?? "prod"
|
|
70
|
+
|
|
71
|
+
let event: [String: Any] = [
|
|
72
|
+
"id": UUID().uuidString.lowercased(),
|
|
73
|
+
"timestamp": iso8601(Date()),
|
|
74
|
+
"kind": "error",
|
|
75
|
+
"platform": "ios",
|
|
76
|
+
"release": release,
|
|
77
|
+
"environment": environment,
|
|
78
|
+
"device": [
|
|
79
|
+
"os": "ios",
|
|
80
|
+
"osVersion": osVersion(),
|
|
81
|
+
"model": deviceModel(),
|
|
82
|
+
],
|
|
83
|
+
"app": appInfo(),
|
|
84
|
+
"user": NSNull(),
|
|
85
|
+
"tags": [String: String](),
|
|
86
|
+
"breadcrumbs": [Any](),
|
|
87
|
+
"error": [
|
|
88
|
+
"type": exception.name.rawValue,
|
|
89
|
+
"message": exception.reason ?? "",
|
|
90
|
+
"stack": frames(from: exception.callStackSymbols),
|
|
91
|
+
"cause": NSNull(),
|
|
92
|
+
],
|
|
93
|
+
"fingerprint": [String](),
|
|
94
|
+
"traceId": NSNull(),
|
|
95
|
+
"spanId": NSNull(),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
guard let dir = pendingDir() else { return }
|
|
99
|
+
let url = dir.appendingPathComponent("\(UUID().uuidString.lowercased()).json")
|
|
100
|
+
if let data = try? JSONSerialization.data(withJSONObject: event, options: []) {
|
|
101
|
+
try? data.write(to: url)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private static func iso8601(_ date: Date) -> String {
|
|
106
|
+
let f = ISO8601DateFormatter()
|
|
107
|
+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
108
|
+
return f.string(from: date)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private static func osVersion() -> String {
|
|
112
|
+
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
113
|
+
return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static func deviceModel() -> String {
|
|
117
|
+
var s = utsname()
|
|
118
|
+
uname(&s)
|
|
119
|
+
return withUnsafePointer(to: &s.machine) { ptr in
|
|
120
|
+
ptr.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
121
|
+
String(validatingUTF8: $0) ?? "unknown"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static func appInfo() -> [String: Any] {
|
|
127
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
128
|
+
var d: [String: Any] = [
|
|
129
|
+
"version": (info["CFBundleShortVersionString"] as? String) ?? "0.0.0",
|
|
130
|
+
]
|
|
131
|
+
if let build = info["CFBundleVersion"] as? String {
|
|
132
|
+
d["build"] = build
|
|
133
|
+
}
|
|
134
|
+
return d
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Best-effort frame parse from `[NSException callStackSymbols]`.
|
|
138
|
+
/// Each line looks roughly like:
|
|
139
|
+
/// "1 AppName 0x0001a0b0 -[ClassName method:] + 100"
|
|
140
|
+
/// We don't have file/line info in raw symbol output; sourcemap-style
|
|
141
|
+
/// symbolication for native happens server-side (Phase 8+).
|
|
142
|
+
private static func frames(from symbols: [String]) -> [[String: Any]] {
|
|
143
|
+
return symbols.map { sym -> [String: Any] in
|
|
144
|
+
let parts = sym.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
|
145
|
+
let module = parts.count > 1 ? parts[1] : "<unknown>"
|
|
146
|
+
let function = parts.count > 3 ? parts.dropFirst(3).joined(separator: " ") : "<anonymous>"
|
|
147
|
+
let inApp = !module.contains("UIKit")
|
|
148
|
+
&& !module.contains("Foundation")
|
|
149
|
+
&& !module.contains("CoreFoundation")
|
|
150
|
+
&& !module.contains("libsystem")
|
|
151
|
+
&& !module.contains("libobjc")
|
|
152
|
+
return [
|
|
153
|
+
"function": function,
|
|
154
|
+
"file": module,
|
|
155
|
+
"line": 0,
|
|
156
|
+
"inApp": inApp,
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
/// Expo Module exposing the iOS crash handler to JS.
|
|
4
|
+
///
|
|
5
|
+
/// JS contract (mirrored in src/native.ts):
|
|
6
|
+
/// - setConfig({ token, release, environment }): stash for the crash
|
|
7
|
+
/// writer. Token is currently unused at native side; release and
|
|
8
|
+
/// environment are baked into the saved event JSON.
|
|
9
|
+
/// - drainPending() → string[]: read & delete all pending crash files
|
|
10
|
+
/// from <Documents>/sentori/pending and return their JSON bodies.
|
|
11
|
+
public class SentoriModule: Module {
|
|
12
|
+
public func definition() -> ModuleDefinition {
|
|
13
|
+
Name("Sentori")
|
|
14
|
+
|
|
15
|
+
OnCreate {
|
|
16
|
+
SentoriCrashHandler.register()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Function("setConfig") { (config: [String: Any]) in
|
|
20
|
+
SentoriCrashHandler.setConfig(config)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
AsyncFunction("drainPending") { () -> [String] in
|
|
24
|
+
return SentoriCrashHandler.consumePending()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Dev-only helper used by the example app to verify the
|
|
28
|
+
// crash-write / drain round-trip without writing native code in
|
|
29
|
+
// the host app. Schedules a real NSException after a tick so
|
|
30
|
+
// the JS bridge has time to return; the resulting crash hits
|
|
31
|
+
// SentoriCrashHandler and writes a JSON file under
|
|
32
|
+
// <Documents>/sentori/pending/.
|
|
33
|
+
Function("triggerTestNativeCrash") {
|
|
34
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
35
|
+
NSException(
|
|
36
|
+
name: NSExceptionName("SentoriTestException"),
|
|
37
|
+
reason: "Sentori test native crash",
|
|
38
|
+
userInfo: nil
|
|
39
|
+
).raise()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Phase 16 sub-E: XCTest coverage for SentoriCrashHandler (回填 Phase 7).
|
|
2
|
+
//
|
|
3
|
+
// Run via Xcode (target → SentoriTests, ⌘U) or via xcodebuild from the
|
|
4
|
+
// iOS host:
|
|
5
|
+
// xcodebuild test \
|
|
6
|
+
// -scheme SentoriTests \
|
|
7
|
+
// -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
8
|
+
//
|
|
9
|
+
// The handler writes one JSON file per crash to
|
|
10
|
+
// <Documents>/sentori/pending/<uuid>.json
|
|
11
|
+
// We can't easily re-raise NSException (it tears down the test
|
|
12
|
+
// process), so we exercise the persistence helper directly instead.
|
|
13
|
+
|
|
14
|
+
import XCTest
|
|
15
|
+
@testable import SentoriCrashHandler
|
|
16
|
+
|
|
17
|
+
final class SentoriCrashHandlerTests: XCTestCase {
|
|
18
|
+
private var pendingDir: URL!
|
|
19
|
+
|
|
20
|
+
override func setUpWithError() throws {
|
|
21
|
+
let docs = try FileManager.default.url(
|
|
22
|
+
for: .documentDirectory,
|
|
23
|
+
in: .userDomainMask,
|
|
24
|
+
appropriateFor: nil,
|
|
25
|
+
create: true
|
|
26
|
+
)
|
|
27
|
+
pendingDir = docs.appendingPathComponent("sentori/pending", isDirectory: true)
|
|
28
|
+
try? FileManager.default.removeItem(at: pendingDir)
|
|
29
|
+
try FileManager.default.createDirectory(at: pendingDir, withIntermediateDirectories: true)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func testWritePendingProducesValidEventJson() throws {
|
|
33
|
+
// Synthesize a payload like the @convention(c) handler would.
|
|
34
|
+
let exception = NSException(
|
|
35
|
+
name: NSExceptionName("XCTestSyntheticException"),
|
|
36
|
+
reason: "boom",
|
|
37
|
+
userInfo: nil
|
|
38
|
+
)
|
|
39
|
+
SentoriCrashHandler.persistForTesting(exception: exception)
|
|
40
|
+
|
|
41
|
+
let files = try FileManager.default.contentsOfDirectory(atPath: pendingDir.path)
|
|
42
|
+
.filter { $0.hasSuffix(".json") }
|
|
43
|
+
XCTAssertEqual(files.count, 1, "expected exactly one pending file, got \(files.count)")
|
|
44
|
+
|
|
45
|
+
let url = pendingDir.appendingPathComponent(files[0])
|
|
46
|
+
let data = try Data(contentsOf: url)
|
|
47
|
+
let payload = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
|
|
48
|
+
XCTAssertNotNil(payload, "pending file is not valid JSON")
|
|
49
|
+
|
|
50
|
+
// Spot-check the protocol shape (Phase 1 schema).
|
|
51
|
+
XCTAssertEqual(payload?["kind"] as? String, "error")
|
|
52
|
+
XCTAssertEqual(payload?["platform"] as? String, "ios")
|
|
53
|
+
XCTAssertNotNil(payload?["id"] as? String)
|
|
54
|
+
XCTAssertNotNil(payload?["timestamp"] as? String)
|
|
55
|
+
let error = payload?["error"] as? [String: Any]
|
|
56
|
+
XCTAssertEqual(error?["type"] as? String, "XCTestSyntheticException")
|
|
57
|
+
XCTAssertEqual(error?["message"] as? String, "boom")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Breadcrumb, BreadcrumbType } from './types';
|
|
2
|
+
export type AddBreadcrumbInput = {
|
|
3
|
+
type: BreadcrumbType;
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
timestamp?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const addBreadcrumb: (input: AddBreadcrumbInput) => void;
|
|
8
|
+
export declare const getBreadcrumbs: () => Breadcrumb[];
|
|
9
|
+
export declare const clearBreadcrumbs: () => void;
|
|
10
|
+
export declare const __resetForTests: () => void;
|
|
11
|
+
//# sourceMappingURL=breadcrumbs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"breadcrumbs.d.ts","sourceRoot":"","sources":["../src/breadcrumbs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAM1D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,OAAO,kBAAkB,KAAG,IAUzD,CAAC;AAEF,eAAO,MAAM,cAAc,QAAO,UAAU,EAAkB,CAAC;AAE/D,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAC;AAEF,eAAO,MAAM,eAAe,QAAO,IAElC,CAAC"}
|