Send Android client errors to Mac log service
ober
2e8b0af15c1130b59c54ce0bab669ef3726be78f
--- a/templates/original-tactics.ss +++ b/templates/original-tactics.ss @@ -5808,7 +5808,7 @@ " }" "" " private fun clientLogEndpoint(): String =" - " if (::rulesClient.isInitialized) rulesClient.clientLogEndpoint() else DEFAULT_ENDPOINT.removeSuffix(\"/api/service\") + \"/api/client-log\"" + " CLIENT_LOG_ENDPOINT" "" " private fun clientLogPayload(event: String, fields: JSONObject): JSONObject =" " JSONObject()" @@ -6112,6 +6112,7 @@ " private val UI_DIM = Color.rgb(102, 120, 148)" " private val UI_ACCENT = Color.rgb(88, 156, 226)" " private const val DEFAULT_ENDPOINT = \"http://127.0.0.1:8788/api/service\"" + " private const val CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\"" " private const val PREF_ENDPOINT = \"endpoint\"" " private const val PREF_STATE_SCHEMA_VERSION = \"state_schema_version\"" " private const val STATE_SCHEMA_VERSION = 2" @@ -6197,7 +6198,7 @@ " if (embeddedAvailable) EMBEDDED_HEALTH_ENDPOINT else fallbackEndpoint.removeSuffix(\"/api/service\") + \"/health\"" "" " fun clientLogEndpoint(): String =" - " if (embeddedAvailable) EMBEDDED_CLIENT_LOG_ENDPOINT else fallbackEndpoint.removeSuffix(\"/api/service\") + \"/api/client-log\"" + " EXTERNAL_CLIENT_LOG_ENDPOINT" "" " fun modeLabel(): String =" " if (embeddedAvailable) \"embedded\" else \"http-fallback\"" @@ -6210,7 +6211,7 @@ " .put(\"engine\", \"jerboa\")" " .put(\"transport\", \"embedded\")" " .put(\"endpoint\", EMBEDDED_SERVICE_ENDPOINT)" - " .put(\"client-log-endpoint\", EMBEDDED_CLIENT_LOG_ENDPOINT)" + " .put(\"client-log-endpoint\", EXTERNAL_CLIENT_LOG_ENDPOINT)" " .toString()" " }" " return httpGet(url)" @@ -6272,6 +6273,7 @@ " private const val EMBEDDED_SERVICE_ENDPOINT = \"embedded://sfb-rules/api/service\"" " private const val EMBEDDED_HEALTH_ENDPOINT = \"embedded://sfb-rules/health\"" " private const val EMBEDDED_CLIENT_LOG_ENDPOINT = \"embedded://sfb-rules/api/client-log\"" + " private const val EXTERNAL_CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\"" " }" "}" "" --- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -267,6 +267,7 @@ "import android.os.Bundle" "import android.os.Environment" "import android.os.ParcelFileDescriptor" + "import android.os.SystemClock" "import android.provider.DocumentsContract" "import android.provider.MediaStore" "import android.provider.OpenableColumns" @@ -286,10 +287,15 @@ "import android.widget.TextView" "import android.widget.Toast" "import androidx.documentfile.provider.DocumentFile" - "import org.json.JSONObject" - "import java.io.File" + "import org.json.JSONObject" + "import java.io.File" + "import java.io.PrintWriter" + "import java.io.StringWriter" + "import java.net.HttpURLConnection" + "import java.net.URL" "import java.util.UUID" "import kotlin.concurrent.thread" + "import kotlin.system.exitProcess" "import kotlin.math.ceil" "import kotlin.math.max" "import kotlin.math.sqrt" @@ -333,6 +339,8 @@ "" " override fun onCreate(savedInstanceState: Bundle?) {" " super.onCreate(savedInstanceState)" + " installClientCrashLogger()" + " logClientEvent(\"ssd-app-start\")" " truthStore = TruthStore(this)" " setContentView(buildUi())" " setStatus(\"Open an SSD PDF. ${truthStore.storageSummary()}\")" @@ -1668,8 +1676,108 @@ " return uri.lastPathSegment ?: \"ssd.pdf\"" " }" "" + " private fun installClientCrashLogger() {" + " if (clientCrashLoggerInstalled) return" + " clientCrashLoggerInstalled = true" + " val previous = Thread.getDefaultUncaughtExceptionHandler()" + " Thread.setDefaultUncaughtExceptionHandler { crashingThread, throwable ->" + " val payload = try {" + " clientLogPayload(" + " \"ssd-uncaught-exception\"," + " JSONObject()" + " .put(\"crashing-thread\", crashingThread.name ?: \"\")" + " .put(\"error\", throwableLog(throwable))" + " )" + " } catch (_: Exception) { JSONObject().put(\"event\", \"ssd-uncaught-exception\") }" + " try {" + " val sender = thread(start = true) { sendClientLog(payload, CLIENT_LOG_CRASH_TIMEOUT_MS) }" + " sender.join((CLIENT_LOG_CRASH_TIMEOUT_MS + 500).toLong())" + " } catch (_: Exception) {" + " // Diagnostics must not change Android crash handling." + " }" + " if (previous != null) previous.uncaughtException(crashingThread, throwable) else exitProcess(2)" + " }" + " }" + "" + " private fun logClientEvent(event: String, fields: JSONObject = JSONObject()) {" + " val payload = try { clientLogPayload(event, fields) } catch (_: Exception) { return }" + " thread {" + " try { sendClientLog(payload, CLIENT_LOG_READ_TIMEOUT_MS) } catch (_: Exception) { }" + " }" + " }" + "" + " private fun sendClientLog(payload: JSONObject, readTimeoutMillis: Int) {" + " val bytes = payload.toString().toByteArray(Charsets.UTF_8)" + " val connection = (URL(CLIENT_LOG_ENDPOINT).openConnection() as HttpURLConnection).apply {" + " requestMethod = \"POST\"" + " connectTimeout = CLIENT_LOG_CONNECT_TIMEOUT_MS" + " readTimeout = readTimeoutMillis" + " doOutput = true" + " setRequestProperty(\"Content-Type\", \"application/json; charset=utf-8\")" + " setFixedLengthStreamingMode(bytes.size)" + " }" + " try {" + " connection.outputStream.use { it.write(bytes) }" + " val code = connection.responseCode" + " if (code !in 200..299) throw IllegalStateException(\"client log HTTP $code\")" + " connection.inputStream.close()" + " } finally {" + " connection.disconnect()" + " }" + " }" + "" + " private fun clientLogPayload(event: String, fields: JSONObject): JSONObject =" + " JSONObject()" + " .put(\"schema\", 1)" + " .put(\"event\", event)" + " .put(\"client-time-ms\", System.currentTimeMillis())" + " .put(\"uptime-ms\", SystemClock.uptimeMillis())" + " .put(\"thread\", Thread.currentThread().name ?: \"\")" + " .put(\"app\", appLogInfo())" + " .put(\"device\", deviceLogInfo())" + " .put(\"pdf\", JSONObject().put(\"name\", pdfName).put(\"page\", pageIndex + 1).put(\"pages\", pageCount))" + " .put(\"selected-group\", selectedGroupId ?: \"\")" + " .put(\"fields\", fields)" + "" + " private fun appLogInfo(): JSONObject {" + " val out = JSONObject().put(\"package\", packageName)" + " return try {" + " val info = packageManager.getPackageInfo(packageName, 0)" + " val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else {" + " @Suppress(\"DEPRECATION\")" + " info.versionCode.toLong()" + " }" + " out.put(\"version-name\", info.versionName ?: \"\").put(\"version-code\", versionCode)" + " } catch (_: Exception) { out }" + " }" + "" + " private fun deviceLogInfo(): JSONObject =" + " JSONObject()" + " .put(\"sdk\", Build.VERSION.SDK_INT)" + " .put(\"release\", Build.VERSION.RELEASE ?: \"\")" + " .put(\"manufacturer\", Build.MANUFACTURER ?: \"\")" + " .put(\"model\", Build.MODEL ?: \"\")" + "" + " private fun throwableLog(error: Throwable): JSONObject {" + " val writer = StringWriter()" + " error.printStackTrace(PrintWriter(writer))" + " val stack = writer.toString()" + " return JSONObject()" + " .put(\"class\", error.javaClass.name)" + " .put(\"message\", error.message ?: \"\")" + " .put(\"stack\", if (stack.length <= CLIENT_LOG_STACK_CHARS) stack else stack.take(CLIENT_LOG_STACK_CHARS) + \"...[truncated]\")" + " }" + "" + " private fun isErrorStatus(text: String): Boolean {" + " val lower = text.lowercase()" + " return lower.contains(\"failed\") || lower.contains(\"error\") || lower.contains(\"exception\") || lower.contains(\"rejected\")" + " }" + "" " private fun setStatus(text: String) {" " status.text = text" + " if (isErrorStatus(text)) {" + " logClientEvent(\"ssd-status-error\", JSONObject().put(\"status\", text))" + " }" " }" "" " companion object {" @@ -1685,6 +1793,12 @@ " private const val MAX_SSD_PDF_SCAN_DEPTH = 8" " private const val MAX_SSD_PDF_CHOICES = 1000" " private const val FAST_TRUTH_GROUP_THRESHOLD = 5" + " @Volatile private var clientCrashLoggerInstalled = false" + " private const val CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\"" + " private const val CLIENT_LOG_CONNECT_TIMEOUT_MS = 2500" + " private const val CLIENT_LOG_READ_TIMEOUT_MS = 1500" + " private const val CLIENT_LOG_CRASH_TIMEOUT_MS = 1500" + " private const val CLIENT_LOG_STACK_CHARS = 12000" " }" "" " private data class SsdPdfChoice(val label: String, val uri: Uri)"