@goliapkg/sentori-react-native 6.2.0 → 6.3.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/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
|
@@ -10,15 +10,22 @@ Pod::Spec.new do |s|
|
|
|
10
10
|
s.license = 'TBD'
|
|
11
11
|
s.author = { 'Sentori' => 'support@sentori.golia.jp' }
|
|
12
12
|
s.homepage = 'https://sentori.golia.jp'
|
|
13
|
-
|
|
13
|
+
# 13.4 was a claim nothing compiled. `SentoriPushNotifications`
|
|
14
|
+
# uses `UNNotificationPresentationOptions.banner` and `.list`, both
|
|
15
|
+
# iOS 14 — building the sources against the declared floor for the
|
|
16
|
+
# first time (as a Swift package, 2026-08-11) failed on exactly
|
|
17
|
+
# that. The floor now matches what the code needs.
|
|
18
|
+
s.platforms = { ios: '14.0', tvos: '14.0' }
|
|
14
19
|
s.swift_version = '5.4'
|
|
15
20
|
s.source = { git: '' }
|
|
16
21
|
s.static_framework = true
|
|
17
22
|
|
|
18
23
|
s.dependency 'ExpoModulesCore'
|
|
19
24
|
|
|
25
|
+
# `ios/SentoriModule.swift` is the bridge; `ios/core/` is a mirror of
|
|
26
|
+
# `sdk/native/ios/Sources/Sentori`, written by
|
|
27
|
+
# `scripts/sync-native-core.mjs` and gated byte-for-byte. Edit the
|
|
28
|
+
# package, never the mirror — a pod cannot reach outside its own
|
|
29
|
+
# directory, which is the only reason a copy exists at all.
|
|
20
30
|
s.source_files = 'ios/**/*.{h,m,mm,swift,hpp,cpp}'
|
|
21
|
-
# XCTest only links into test targets; including ios/Tests/** in the
|
|
22
|
-
# main pod target makes app builds fail with `no such module 'XCTest'`.
|
|
23
|
-
s.exclude_files = 'ios/Tests/**'
|
|
24
31
|
end
|
package/android/build.gradle
CHANGED
|
@@ -18,7 +18,21 @@ android {
|
|
|
18
18
|
sourceSets {
|
|
19
19
|
main {
|
|
20
20
|
manifest.srcFile 'src/main/AndroidManifest.xml'
|
|
21
|
-
java.
|
|
21
|
+
// `java` holds the bridge — `SentoriModule.kt`, the one
|
|
22
|
+
// file here that knows Expo exists. `java-core` is a
|
|
23
|
+
// generated mirror of `sdk/native/android`, written by
|
|
24
|
+
// `scripts/sync-native-core.mjs` and gated byte-for-byte.
|
|
25
|
+
// Two roots rather than one directory because the sync
|
|
26
|
+
// deletes its target before writing, and the bridge must
|
|
27
|
+
// not live somewhere that gets wiped.
|
|
28
|
+
java.srcDirs = ['src/main/java', 'src/main/java-core']
|
|
29
|
+
}
|
|
30
|
+
test {
|
|
31
|
+
// The Robolectric suites cover the core, so they moved
|
|
32
|
+
// with it. Mirrored back so `android-unit` keeps running
|
|
33
|
+
// them — dropping them here would have left the job green
|
|
34
|
+
// while testing strictly less.
|
|
35
|
+
java.srcDirs = ['src/test/java', 'src/test/java-core']
|
|
22
36
|
}
|
|
23
37
|
}
|
|
24
38
|
|
|
@@ -63,6 +77,12 @@ dependencies {
|
|
|
63
77
|
// unresolved reference. Nothing noticed because nothing ran them —
|
|
64
78
|
// the CI job that was supposed to had been failing for months on a
|
|
65
79
|
// missing Gradle wrapper.
|
|
80
|
+
// The mirrored tests move with the code they cover, and one of
|
|
81
|
+
// them drives a real `RemoteMessage` through the messaging
|
|
82
|
+
// service. `compileOnly` above does not reach the test source
|
|
83
|
+
// set, so without this the mirror compiles and its tests do not —
|
|
84
|
+
// which is how this module went red while `sdk/native` was green.
|
|
85
|
+
testImplementation 'com.google.firebase:firebase-messaging:24.0.3'
|
|
66
86
|
testImplementation 'junit:junit:4.13.2'
|
|
67
87
|
testImplementation 'org.robolectric:robolectric:4.13'
|
|
68
88
|
testImplementation 'androidx.test:core:1.6.1'
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/Sentori.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import java.text.SimpleDateFormat
|
|
8
|
+
import java.util.Date
|
|
9
|
+
import java.util.Locale
|
|
10
|
+
import java.util.TimeZone
|
|
11
|
+
import kotlin.random.Random
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The surface a host app writes against.
|
|
15
|
+
*
|
|
16
|
+
* Sentori.error(e) what went wrong?
|
|
17
|
+
* Sentori.warn("checkout.slow") where did the user struggle?
|
|
18
|
+
* Sentori.trace("cart.opened") what happened here?
|
|
19
|
+
* Sentori.assert("total", ok) should this hold?
|
|
20
|
+
* Sentori.probe("SEN-482") is that bug back?
|
|
21
|
+
*
|
|
22
|
+
* Every one is synchronous, returns the event id it minted, and never
|
|
23
|
+
* throws — including before [start], where each is a no-op that still
|
|
24
|
+
* hands back an id. An app that mis-wires its token gets a silent SDK,
|
|
25
|
+
* not an exception on a path it did not know it had.
|
|
26
|
+
*
|
|
27
|
+
* Same semantics as the Swift and React Native surfaces, because the
|
|
28
|
+
* dashboard reading them cannot tell which one sent an event and
|
|
29
|
+
* should not have to.
|
|
30
|
+
*/
|
|
31
|
+
object Sentori {
|
|
32
|
+
|
|
33
|
+
// ── lifecycle ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configure and start. [context] is used for one thing: the
|
|
37
|
+
* directory events spill into when the network is gone. Pass the
|
|
38
|
+
* application context, not an Activity.
|
|
39
|
+
*
|
|
40
|
+
* Nothing here reaches the network; the first request happens when
|
|
41
|
+
* there is something to send.
|
|
42
|
+
*/
|
|
43
|
+
@JvmStatic
|
|
44
|
+
@JvmOverloads
|
|
45
|
+
fun start(config: SentoriConfig, context: Context? = null) {
|
|
46
|
+
SentoriConfig.set(config)
|
|
47
|
+
SentoriDevice.bind(context)
|
|
48
|
+
SentoriTransport.start(context?.let { java.io.File(it.filesDir, "sentori") })
|
|
49
|
+
|
|
50
|
+
// Crash capture needs a Context for its storage, so an app
|
|
51
|
+
// that starts without one gets everything except this.
|
|
52
|
+
if (context != null) {
|
|
53
|
+
// The handler runs inside an uncaught-exception handler
|
|
54
|
+
// where almost nothing is safe, so it reads its release
|
|
55
|
+
// and environment from SharedPreferences rather than from
|
|
56
|
+
// a live object. Without this call every crash ships as
|
|
57
|
+
// `unknown` / `prod` — matching no release, so
|
|
58
|
+
// symbolicating against nothing.
|
|
59
|
+
SentoriCrashHandler.setConfig(
|
|
60
|
+
mapOf("release" to config.release, "environment" to config.environment)
|
|
61
|
+
)
|
|
62
|
+
SentoriCrashHandler.register(context)
|
|
63
|
+
|
|
64
|
+
// The crash that killed the last launch. Until now the
|
|
65
|
+
// handler wrote files into a directory nothing emptied —
|
|
66
|
+
// a crash reporter that captured crashes and never sent
|
|
67
|
+
// one.
|
|
68
|
+
SentoriPendingCrash.ship()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Identify the person using the app. Only a hash of [id] (or
|
|
74
|
+
* [email] when there is no id) travels; the raw values stay here.
|
|
75
|
+
*
|
|
76
|
+
* Order does not matter with push: a device that has registered
|
|
77
|
+
* updates itself when this changes, so signing in after
|
|
78
|
+
* registering makes the device reachable straight away. It said to
|
|
79
|
+
* call this first, which was advice for a defect rather than a
|
|
80
|
+
* design — most apps learn who the user is well after launch.
|
|
81
|
+
*
|
|
82
|
+
* [traits] are attributes a push campaign can select on: plan,
|
|
83
|
+
* cohort, org. They travel raw, unlike [id] and [email], so put
|
|
84
|
+
* nothing there that identifies the person.
|
|
85
|
+
*/
|
|
86
|
+
@JvmStatic
|
|
87
|
+
@JvmOverloads
|
|
88
|
+
fun user(id: String?, email: String?, traits: Map<String, Any?>? = null) =
|
|
89
|
+
SentoriScope.setUser(id, email, traits)
|
|
90
|
+
|
|
91
|
+
/** Merge keys into the ambient context that rides every event. */
|
|
92
|
+
@JvmStatic
|
|
93
|
+
fun context(patch: Map<String, Any?>) = SentoriScope.patchContext(patch)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record a signal for the last-sixty-seconds ring that ships with
|
|
97
|
+
* an error. Any [kind] is accepted; the dashboard reads `http` as
|
|
98
|
+
* `{ method, url, status, ms }` and `trace` as a quiet breadcrumb.
|
|
99
|
+
*
|
|
100
|
+
* This SDK deliberately does not install an OkHttp interceptor:
|
|
101
|
+
* watching the host's traffic is the host's decision, not ours to
|
|
102
|
+
* make silently.
|
|
103
|
+
*/
|
|
104
|
+
@JvmStatic
|
|
105
|
+
@JvmOverloads
|
|
106
|
+
fun pushSignal(kind: String, data: Map<String, Any?>? = null) =
|
|
107
|
+
SentoriSignalRing.push(kind, data)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `Sentori.push.register(...)`, matching `sentori.push.register()`
|
|
111
|
+
* in the React Native SDK and `Sentori.push` in Swift.
|
|
112
|
+
*/
|
|
113
|
+
@JvmStatic val push: SentoriPush get() = SentoriPush
|
|
114
|
+
|
|
115
|
+
// ── the verbs ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/** Something went wrong. */
|
|
118
|
+
@JvmStatic
|
|
119
|
+
@JvmOverloads
|
|
120
|
+
fun error(err: Throwable, data: Map<String, Any?>? = null): String =
|
|
121
|
+
emit("error", null, describe(err), data, withSignals = true)
|
|
122
|
+
|
|
123
|
+
/** Something went wrong, described rather than thrown. */
|
|
124
|
+
@JvmStatic
|
|
125
|
+
@JvmOverloads
|
|
126
|
+
fun error(message: String, type: String = "Error", data: Map<String, Any?>? = null): String =
|
|
127
|
+
emit("error", null, mapOf("type" to type, "message" to message), data, withSignals = true)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The user struggled here. Not a crash — a place the product hurt,
|
|
131
|
+
* named by you.
|
|
132
|
+
*/
|
|
133
|
+
@JvmStatic
|
|
134
|
+
@JvmOverloads
|
|
135
|
+
fun warn(name: String, data: Map<String, Any?>? = null): String =
|
|
136
|
+
emit("warn", name, null, data, withSignals = true)
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* This happened. Always lands in the signal ring; [quiet] keeps it
|
|
140
|
+
* out of the event stream, which is how a high-frequency
|
|
141
|
+
* breadcrumb stays affordable.
|
|
142
|
+
*/
|
|
143
|
+
@JvmStatic
|
|
144
|
+
@JvmOverloads
|
|
145
|
+
fun trace(name: String, data: Map<String, Any?>? = null, quiet: Boolean = false): String {
|
|
146
|
+
val signal = mutableMapOf<String, Any?>("name" to name)
|
|
147
|
+
data?.let { signal.putAll(it) }
|
|
148
|
+
SentoriSignalRing.push("trace", signal)
|
|
149
|
+
if (quiet) return newEventId()
|
|
150
|
+
return emit("trace", name, null, data, withSignals = false)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* This should hold. A passing assert never becomes an event — it
|
|
155
|
+
* increments a counter that rides the next batch, so a liveness
|
|
156
|
+
* check costs no request. Only failures are events.
|
|
157
|
+
*
|
|
158
|
+
* Unlike the language's own `assert`, this never stops the
|
|
159
|
+
* program. That difference is the point: a monitoring SDK that can
|
|
160
|
+
* halt the app it monitors has picked the wrong side of the
|
|
161
|
+
* bargain.
|
|
162
|
+
*/
|
|
163
|
+
@JvmStatic
|
|
164
|
+
@JvmOverloads
|
|
165
|
+
fun assert(name: String, ok: Boolean, data: Map<String, Any?>? = null): String {
|
|
166
|
+
SentoriConfig.current?.let { SentoriTransport.countAssert(name, ok, it.release) }
|
|
167
|
+
if (ok) return newEventId()
|
|
168
|
+
return emit("assert", name, null, data, withSignals = true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Is that bug back? A tripwire: reaching this call is the signal.
|
|
173
|
+
* It changes no control flow and returns no verdict.
|
|
174
|
+
*/
|
|
175
|
+
@JvmStatic
|
|
176
|
+
@JvmOverloads
|
|
177
|
+
fun probe(ref: String, data: Map<String, Any?>? = null): String =
|
|
178
|
+
emit("probe", ref, null, data, withSignals = false)
|
|
179
|
+
|
|
180
|
+
// ── assembly ──────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
private fun emit(
|
|
183
|
+
kind: String,
|
|
184
|
+
name: String?,
|
|
185
|
+
error: Map<String, Any?>?,
|
|
186
|
+
data: Map<String, Any?>?,
|
|
187
|
+
withSignals: Boolean,
|
|
188
|
+
): String {
|
|
189
|
+
val id = newEventId()
|
|
190
|
+
// Before init every verb is a no-op that still returns an id,
|
|
191
|
+
// so a call site does not change shape depending on whether
|
|
192
|
+
// the SDK came up.
|
|
193
|
+
val config = SentoriConfig.current ?: return id
|
|
194
|
+
|
|
195
|
+
val payload = mutableMapOf<String, Any?>()
|
|
196
|
+
error?.let { payload["error"] = it }
|
|
197
|
+
data?.let { payload["data"] = it }
|
|
198
|
+
SentoriScope.context?.let { payload["context"] = it }
|
|
199
|
+
if (withSignals) {
|
|
200
|
+
val signals = SentoriSignalRing.snapshot()
|
|
201
|
+
if (signals.isNotEmpty()) payload["signals"] = signals
|
|
202
|
+
}
|
|
203
|
+
payload["device"] = SentoriDevice.snapshot()
|
|
204
|
+
|
|
205
|
+
val event =
|
|
206
|
+
mutableMapOf<String, Any?>(
|
|
207
|
+
"id" to id,
|
|
208
|
+
"kind" to kind,
|
|
209
|
+
"occurredAt" to iso8601(Date()),
|
|
210
|
+
"platform" to "android",
|
|
211
|
+
"release" to config.release,
|
|
212
|
+
"environment" to config.environment,
|
|
213
|
+
"payload" to payload,
|
|
214
|
+
)
|
|
215
|
+
name?.let { event["name"] = it }
|
|
216
|
+
SentoriScope.userKey?.let { event["userKey"] = it }
|
|
217
|
+
|
|
218
|
+
SentoriTransport.enqueue(event)
|
|
219
|
+
return id
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun describe(err: Throwable): Map<String, Any?> {
|
|
223
|
+
val frames =
|
|
224
|
+
err.stackTrace.take(50).map { f ->
|
|
225
|
+
mapOf(
|
|
226
|
+
"function" to "${f.className}.${f.methodName}",
|
|
227
|
+
"file" to (f.fileName ?: ""),
|
|
228
|
+
"line" to f.lineNumber,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
val out =
|
|
232
|
+
mutableMapOf<String, Any?>(
|
|
233
|
+
"type" to (err.javaClass.name),
|
|
234
|
+
"message" to (err.message ?: ""),
|
|
235
|
+
"stack" to frames,
|
|
236
|
+
)
|
|
237
|
+
err.cause?.let { if (it !== err) out["cause"] = describe(it) }
|
|
238
|
+
return out
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private val isoFormat =
|
|
242
|
+
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
|
243
|
+
timeZone = TimeZone.getTimeZone("UTC")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
internal fun iso8601(date: Date): String = synchronized(isoFormat) { isoFormat.format(date) }
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* UUIDv7: 48 bits of milliseconds then random, so ids sort by
|
|
250
|
+
* creation time. The server keys events on them and the dashboard
|
|
251
|
+
* orders by them, so a v4 would scatter a session across the
|
|
252
|
+
* index.
|
|
253
|
+
*/
|
|
254
|
+
internal fun newEventId(): String {
|
|
255
|
+
val bytes = ByteArray(16)
|
|
256
|
+
Random.nextBytes(bytes)
|
|
257
|
+
val ms = System.currentTimeMillis()
|
|
258
|
+
bytes[0] = ((ms shr 40) and 0xff).toByte()
|
|
259
|
+
bytes[1] = ((ms shr 32) and 0xff).toByte()
|
|
260
|
+
bytes[2] = ((ms shr 24) and 0xff).toByte()
|
|
261
|
+
bytes[3] = ((ms shr 16) and 0xff).toByte()
|
|
262
|
+
bytes[4] = ((ms shr 8) and 0xff).toByte()
|
|
263
|
+
bytes[5] = (ms and 0xff).toByte()
|
|
264
|
+
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x70).toByte()
|
|
265
|
+
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
|
|
266
|
+
|
|
267
|
+
val hex = StringBuilder(32)
|
|
268
|
+
for (b in bytes) hex.append(String.format("%02x", b.toInt() and 0xff))
|
|
269
|
+
val h = hex.toString()
|
|
270
|
+
return "${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-" +
|
|
271
|
+
"${h.substring(16, 20)}-${h.substring(20, 32)}"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriAttachment.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import java.net.HttpURLConnection
|
|
7
|
+
import java.net.URL
|
|
8
|
+
import java.net.URLEncoder
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Blobs that belong to an event: the screenshot taken as the app died,
|
|
12
|
+
* the view tree behind it.
|
|
13
|
+
*
|
|
14
|
+
* They upload *after* the event, never before. The server keys an
|
|
15
|
+
* attachment on an event id it must already know, so an upload that
|
|
16
|
+
* races the batch 404s — and it always wins that race, because the
|
|
17
|
+
* batch waits for a flush and the upload does not.
|
|
18
|
+
*
|
|
19
|
+
* The multipart body is built by hand. React Native learned why the
|
|
20
|
+
* expensive way: its `FormData` file part wants a `uri`, the `data:`
|
|
21
|
+
* form throws a bare network error on iOS, and every JS attachment
|
|
22
|
+
* died silently for a release. Doing it literally is both shorter and
|
|
23
|
+
* checkable.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `SentoriAttachment.swift` part for part.
|
|
26
|
+
*/
|
|
27
|
+
internal object SentoriAttachment {
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The kinds the server's CHECK constraint accepts, from
|
|
31
|
+
* `handlers/sdk/events_attachments.rs`. Anything else is a 400,
|
|
32
|
+
* so it is dropped here with the round trip saved.
|
|
33
|
+
*
|
|
34
|
+
* Worth keeping honest: the first version of this list had three
|
|
35
|
+
* entries, and the crash handler's view tree — written on both
|
|
36
|
+
* platforms, kind `viewTree` — was not one of them. It would have
|
|
37
|
+
* been dropped on the way out by the very code meant to deliver
|
|
38
|
+
* it, silently, with the event arriving intact and the evidence
|
|
39
|
+
* missing.
|
|
40
|
+
*/
|
|
41
|
+
val KNOWN =
|
|
42
|
+
setOf(
|
|
43
|
+
"logTail",
|
|
44
|
+
"replay",
|
|
45
|
+
"screens",
|
|
46
|
+
"screenshot",
|
|
47
|
+
"sessionTrail",
|
|
48
|
+
"stateSnapshot",
|
|
49
|
+
"viewTree",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/** `android` | `ios` | `js`, likewise CHECK-constrained. */
|
|
53
|
+
val KNOWN_SOURCES = setOf("android", "ios", "js")
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Test seam: capture instead of sending.
|
|
57
|
+
*
|
|
58
|
+
* What is worth asserting is *what* would be uploaded and *when* —
|
|
59
|
+
* the kind, the event it is keyed on, the body, and that none of
|
|
60
|
+
* it moves before the batch lands. None of that is about TCP; the
|
|
61
|
+
* wire has its own gate in `android-live-ingest`.
|
|
62
|
+
*/
|
|
63
|
+
internal var recorderForTests: ((String, String, String) -> Unit)? = null
|
|
64
|
+
|
|
65
|
+
private val worker = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
|
66
|
+
Thread(r, "sentori-attachment").apply { isDaemon = true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Upload one blob against an event the server already has.
|
|
71
|
+
*
|
|
72
|
+
* Fire and forget: nothing here reaches the caller, and a failure
|
|
73
|
+
* costs the attachment rather than the crash report it belongs to.
|
|
74
|
+
*/
|
|
75
|
+
fun upload(
|
|
76
|
+
eventId: String,
|
|
77
|
+
kind: String,
|
|
78
|
+
base64: String,
|
|
79
|
+
mediaType: String,
|
|
80
|
+
source: String = "android",
|
|
81
|
+
completion: ((Boolean) -> Unit)? = null,
|
|
82
|
+
) {
|
|
83
|
+
val config = SentoriConfig.current
|
|
84
|
+
if (config == null || kind !in KNOWN || source !in KNOWN_SOURCES) {
|
|
85
|
+
completion?.invoke(false)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
val boundary = "----sentori-$eventId"
|
|
90
|
+
val body = multipartBody(boundary, kind, mediaType, base64, source)
|
|
91
|
+
|
|
92
|
+
recorderForTests?.let {
|
|
93
|
+
it(eventId, kind, body)
|
|
94
|
+
completion?.invoke(true)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
worker.execute {
|
|
99
|
+
var conn: HttpURLConnection? = null
|
|
100
|
+
var ok = false
|
|
101
|
+
try {
|
|
102
|
+
val url =
|
|
103
|
+
"${config.ingestUrl}/v1/events/" +
|
|
104
|
+
URLEncoder.encode(eventId, "UTF-8") +
|
|
105
|
+
"/attachments/" +
|
|
106
|
+
URLEncoder.encode(kind, "UTF-8")
|
|
107
|
+
conn = URL(url).openConnection() as HttpURLConnection
|
|
108
|
+
conn.requestMethod = "POST"
|
|
109
|
+
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
|
110
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
111
|
+
conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
|
|
112
|
+
conn.connectTimeout = 15_000
|
|
113
|
+
conn.readTimeout = 30_000
|
|
114
|
+
conn.doOutput = true
|
|
115
|
+
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
116
|
+
ok = conn.responseCode in 200..299
|
|
117
|
+
} catch (_: Throwable) {
|
|
118
|
+
// An attachment that will not go is an attachment lost,
|
|
119
|
+
// and never the host app's problem.
|
|
120
|
+
} finally {
|
|
121
|
+
conn?.disconnect()
|
|
122
|
+
completion?.invoke(ok)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The multipart body, visible so a test can read what would go on
|
|
129
|
+
* the wire. Building it correctly is the whole job here, and the
|
|
130
|
+
* failure mode is a 2xx that stored the wrong bytes.
|
|
131
|
+
*/
|
|
132
|
+
internal fun multipartBody(
|
|
133
|
+
boundary: String,
|
|
134
|
+
kind: String,
|
|
135
|
+
mediaType: String,
|
|
136
|
+
base64: String,
|
|
137
|
+
source: String,
|
|
138
|
+
): String =
|
|
139
|
+
buildString {
|
|
140
|
+
append("--$boundary\r\n")
|
|
141
|
+
append("Content-Disposition: form-data; name=\"file\"; filename=\"$kind.bin\"\r\n")
|
|
142
|
+
append("Content-Type: $mediaType\r\n")
|
|
143
|
+
// The server decodes when told to. Without this header it
|
|
144
|
+
// stores the base64 text as the image, which renders as
|
|
145
|
+
// nothing and looks like a capture problem.
|
|
146
|
+
append("Content-Transfer-Encoding: base64\r\n")
|
|
147
|
+
append("\r\n$base64\r\n")
|
|
148
|
+
append("--$boundary\r\n")
|
|
149
|
+
append("Content-Disposition: form-data; name=\"source\"\r\n")
|
|
150
|
+
append("\r\n$source\r\n")
|
|
151
|
+
append("--$boundary--\r\n")
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriConfig.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Set once by [Sentori.start]; everything else reads it and treats
|
|
10
|
+
* absence as "not initialised, so every verb is a no-op".
|
|
11
|
+
*
|
|
12
|
+
* That default is the failure-isolation half of the client zero-cost
|
|
13
|
+
* rule: an app that mis-wires its token gets a silent SDK, never an
|
|
14
|
+
* exception on a path it did not know it had.
|
|
15
|
+
*/
|
|
16
|
+
class SentoriConfig
|
|
17
|
+
@JvmOverloads
|
|
18
|
+
constructor(
|
|
19
|
+
val token: String,
|
|
20
|
+
ingestUrl: String,
|
|
21
|
+
val release: String,
|
|
22
|
+
val environment: String,
|
|
23
|
+
/**
|
|
24
|
+
* The integrator's own health endpoint, carried on batches and
|
|
25
|
+
* probed server-side. The app never pings it — a monitoring SDK
|
|
26
|
+
* that adds traffic to the thing it monitors has picked the wrong
|
|
27
|
+
* side of the bargain.
|
|
28
|
+
*/
|
|
29
|
+
val backendHealthUrl: String? = null,
|
|
30
|
+
) {
|
|
31
|
+
/**
|
|
32
|
+
* A trailing slash here becomes `//v1/events:batch`, which some
|
|
33
|
+
* proxies answer and some 404.
|
|
34
|
+
*/
|
|
35
|
+
val ingestUrl: String = ingestUrl.trimEnd('/')
|
|
36
|
+
|
|
37
|
+
companion object {
|
|
38
|
+
private val ref = AtomicReference<SentoriConfig?>(null)
|
|
39
|
+
|
|
40
|
+
@JvmStatic val current: SentoriConfig? get() = ref.get()
|
|
41
|
+
|
|
42
|
+
@JvmStatic val isInitialised: Boolean get() = ref.get() != null
|
|
43
|
+
|
|
44
|
+
internal fun set(config: SentoriConfig?) = ref.set(config)
|
|
45
|
+
|
|
46
|
+
internal fun resetForTests() = ref.set(null)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reported in the `Sentori-Sdk` header so a server-side problem can be
|
|
52
|
+
* attributed to a client version.
|
|
53
|
+
*
|
|
54
|
+
* Mirrors the constant in `sdk/react-native/src/transport.ts` and the
|
|
55
|
+
* Swift one, and is written by `scripts/sync-sdk-version.mjs` — a
|
|
56
|
+
* version string nothing writes goes stale, and only ever in the
|
|
57
|
+
* direction of a lie.
|
|
58
|
+
*/
|
|
59
|
+
object SentoriVersion {
|
|
60
|
+
const val CURRENT = "1.7.1"
|
|
61
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriCrashHandler.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
1
4
|
package com.sentori
|
|
2
5
|
|
|
3
6
|
import android.content.Context
|
|
@@ -193,6 +196,25 @@ object SentoriCrashHandler {
|
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Test-only seam that writes a crash file verbatim.
|
|
201
|
+
*
|
|
202
|
+
* `persistForTesting(throwable)` goes through `write`, which
|
|
203
|
+
* composes the file from a live throwable and a live window —
|
|
204
|
+
* neither of which a Robolectric test can arrange. Delivery tests
|
|
205
|
+
* need to state the file they are about to deliver, particularly
|
|
206
|
+
* the pre-death attachments, which only exist when there was a
|
|
207
|
+
* window to capture.
|
|
208
|
+
*/
|
|
209
|
+
internal fun persistRawForTesting(event: JSONObject) {
|
|
210
|
+
val dir = pendingDir() ?: return
|
|
211
|
+
try {
|
|
212
|
+
File(dir, "${uuidLower()}.json").writeText(event.toString())
|
|
213
|
+
} catch (_: Throwable) {
|
|
214
|
+
// best-effort
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
private fun attachPending(event: JSONObject) {
|
|
197
219
|
val snap = try {
|
|
198
220
|
SentoriScreenshotCapture.captureKeyWindow()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriDevice.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.os.Build
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the device is, as `payload.device` on every event.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is read once and cached: this runs on the calling
|
|
13
|
+
* thread inside every verb, and a verb that asks the window manager
|
|
14
|
+
* for a fresh value each time is not the O(1) the contract promises.
|
|
15
|
+
*/
|
|
16
|
+
object SentoriDevice {
|
|
17
|
+
|
|
18
|
+
private val lock = Any()
|
|
19
|
+
private var screen: Map<String, Any?>? = null
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Optional. Without a [Context] the snapshot omits the screen and
|
|
23
|
+
* carries everything else — an event with a thinner device beats
|
|
24
|
+
* no event, and a host that starts the SDK before it has a context
|
|
25
|
+
* is making a reasonable choice.
|
|
26
|
+
*/
|
|
27
|
+
@JvmStatic
|
|
28
|
+
fun bind(context: Context?) {
|
|
29
|
+
val ctx = context ?: return
|
|
30
|
+
synchronized(lock) {
|
|
31
|
+
val m = ctx.resources.displayMetrics
|
|
32
|
+
screen =
|
|
33
|
+
mapOf(
|
|
34
|
+
"width" to (m.widthPixels / m.density),
|
|
35
|
+
"height" to (m.heightPixels / m.density),
|
|
36
|
+
"scale" to m.density,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@JvmStatic
|
|
42
|
+
fun snapshot(): Map<String, Any?> {
|
|
43
|
+
val out =
|
|
44
|
+
mutableMapOf<String, Any?>(
|
|
45
|
+
"os" to "android",
|
|
46
|
+
"osVersion" to Build.VERSION.RELEASE,
|
|
47
|
+
// The hardware identifier, not a marketing name.
|
|
48
|
+
// Mapping it would need a table that goes stale with
|
|
49
|
+
// every launch; the dashboard does that lookup
|
|
50
|
+
// server-side where it can be updated without a client
|
|
51
|
+
// release.
|
|
52
|
+
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
|
53
|
+
)
|
|
54
|
+
synchronized(lock) { screen }?.let { out["screen"] = it }
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
internal fun resetForTests() {
|
|
59
|
+
synchronized(lock) { screen = null }
|
|
60
|
+
}
|
|
61
|
+
}
|