feat: centralized error reporting → GitHub Issues
- PHP (api/index.php): hardcode GH_ISSUE_TOKEN/GH_REPO constants at top of file (before exception handler runs); fix $fp_ variable interpolation bug; global set_exception_handler + register_shutdown_function; reportError() endpoint (POST ?action=report_error) with rate limiting, local log, dedup via fingerprint search on GitHub Issues API - Kiosk (ErrorReporter.kt): add crash persistence – saves crash payload to SharedPreferences before network POST, clears on success, retries as 'uncaught-exception-survived' on next launch via sendPendingCrash() in init() - Scale Gateway: new ErrorReporter.kt – calls GitHub Issues API directly (no relay needed, token hardcoded, scoped Issues R+W only); crash persistence via SharedPreferences; MainActivity.kt hooked at onCreate, startGatewayServer catch, onError (BLE errors) Tested end-to-end: issues #3-#6 created and closed during QA.
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Centralized error reporter for EverShelf Scale Gateway.
|
||||
*
|
||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
||||
* calls the GitHub Issues REST API directly.
|
||||
*
|
||||
* The token is intentionally hardcoded — it is scoped only to
|
||||
* Issues (Read+Write) on this single repository.
|
||||
*
|
||||
* Usage:
|
||||
* ErrorReporter.init(applicationContext)
|
||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "ScaleGWErrorReporter"
|
||||
|
||||
// ── Hardcoded credentials (scoped: Issues R+W on dadaloop82/EverShelf only) ──
|
||||
private const val GH_TOKEN = "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"
|
||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
||||
|
||||
// SharedPreferences key for pending (unsent) crash reports
|
||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var appVersion: String = "unknown"
|
||||
private var deviceInfo: String = ""
|
||||
private lateinit var appContext: Context
|
||||
|
||||
/**
|
||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Send any crash report that was saved from the previous session
|
||||
sendPendingCrash()
|
||||
|
||||
// Install global UncaughtExceptionHandler
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val crash = buildPayload(
|
||||
type = "uncaught-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = mapOf("thread" to thread.name)
|
||||
)
|
||||
// Save to prefs first (in case network POST fails before process dies)
|
||||
savePendingCrash(crash)
|
||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
||||
postToGitHub(crash)
|
||||
clearPendingCrash()
|
||||
} catch (_: Exception) {}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a caught [Throwable] asynchronously. */
|
||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
if (location.isNotEmpty()) ctx["location"] = location
|
||||
ctx.putAll(extra)
|
||||
enqueue(
|
||||
type = "scale-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun fingerprint(type: String, message: String) =
|
||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
||||
|
||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return
|
||||
}
|
||||
val payload = buildPayload(type, message, stack, context)
|
||||
executor.execute { postToGitHub(payload) }
|
||||
}
|
||||
|
||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
return JSONObject().apply {
|
||||
put("source", "scale")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
||||
private fun savePendingCrash(payload: JSONObject) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
||||
}
|
||||
|
||||
private fun clearPendingCrash() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_PENDING).apply()
|
||||
}
|
||||
|
||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
||||
private fun sendPendingCrash() {
|
||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PENDING, null) ?: return
|
||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
||||
executor.execute {
|
||||
try {
|
||||
val payload = JSONObject(json)
|
||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
||||
payload.put("type", "uncaught-exception-survived")
|
||||
payload.put("note", "Sent on next launch after crash")
|
||||
postToGitHub(payload)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
||||
* Uses the GitHub Issues Search API to deduplicate.
|
||||
*/
|
||||
private fun postToGitHub(payload: JSONObject) {
|
||||
val source = payload.optString("source", "scale")
|
||||
val type = payload.optString("type", "error")
|
||||
val message = payload.optString("message", "")
|
||||
val stack = payload.optString("stack", "")
|
||||
val version = payload.optString("version", "")
|
||||
val ua = payload.optString("user_agent", "")
|
||||
val ts = payload.optString("ts", "")
|
||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
||||
|
||||
val fp = fingerprint(type, message)
|
||||
|
||||
// ── 1. Search for existing open issue ──────────────────────────────
|
||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
||||
|
||||
// ── 2. Build body ─────────────────────────────────────────────────
|
||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
||||
|
||||
if (existingNumber != null) {
|
||||
// Comment on existing issue
|
||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:$fp_"
|
||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
||||
} else {
|
||||
// Create new issue
|
||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
||||
val title = "[SCALE] $shortMsg"
|
||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`$fp`_"
|
||||
ghPost(
|
||||
"https://api.github.com/repos/$GH_REPO/issues",
|
||||
JSONObject()
|
||||
.put("title", title)
|
||||
.put("body", body)
|
||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ghGet(url: String): JSONObject? = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "token $GH_TOKEN")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
||||
conn.disconnect()
|
||||
JSONObject(raw)
|
||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
||||
|
||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "token $GH_TOKEN")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
||||
code
|
||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
||||
}
|
||||
+10
@@ -77,6 +77,10 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
|
||||
bleManager = BleScaleManager(this, this)
|
||||
|
||||
// Initialise error reporter early so the UncaughtExceptionHandler is installed
|
||||
// and any pending crash from a previous session is sent
|
||||
ErrorReporter.init(this)
|
||||
|
||||
deviceAdapter = DeviceAdapter(devices) { info ->
|
||||
bleManager.connect(info.device)
|
||||
}
|
||||
@@ -191,6 +195,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
||||
} catch (e: Exception) {
|
||||
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
|
||||
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
|
||||
}
|
||||
|
||||
// Auto-scan if there's a saved device
|
||||
@@ -287,6 +292,11 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
override fun onError(message: String) {
|
||||
binding.tvScaleStatus.text = "❌ $message"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
|
||||
ErrorReporter.reportMessage(
|
||||
type = "ble-error",
|
||||
message = message,
|
||||
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onScanStopped() {
|
||||
|
||||
Reference in New Issue
Block a user