feat: centralized error reporting → auto GitHub Issues
PHP (api/index.php): - reportError() endpoint (POST ?action=report_error): accepts source/type/message/stack/context/ua/version - _createOrCommentGithubIssue(): creates new issue OR adds comment on existing one (dedup by sha1 fingerprint via GitHub search API) - _appendErrorLog(): local data/error_reports.log fallback (500 KB rotation) - _phpErrorReport(): called by set_exception_handler + register_shutdown_function → catches all PHP fatals and uncaught exceptions - _githubRequest(): minimal curl-based GitHub REST v3 helper - Rate limit bucket: error_report (20 req/min) - Labels auto-created: auto-report, php-crash, js-error, kiosk-error, scale-error JS (assets/js/app.js): - reportError(payload): single POST to report_error, session-level dedup via _reportedFingerprints Set - window.onerror: reports uncaught-error with message+stack+location context - window.unhandledrejection: reports unhandled-promise with reason+stack - api(): reports api-server-error on HTTP 5xx responses Android Kiosk: - ErrorReporter.kt: singleton with init(context, serverUrl), report(Throwable), reportMessage(type, message) - Thread.setDefaultUncaughtExceptionHandler → catches ALL unhandled JVM crashes - Async executor (single thread), per-session fingerprint dedup, synchronous fallback for crash handler - doPost(): HttpURLConnection POST to /api/?action=report_error with device/version info - KioskActivity: ErrorReporter.init() in onCreate + finishWizard() - onReceivedError: reports webview-load-error with URL + error code - onConsoleMessage: reports webview-js-error for ERROR level console messages Config: GITHUB_ISSUE_TOKEN + GITHUB_REPO added to .env.example
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package it.dadaloop.evershelf.kiosk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
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 Kiosk.
|
||||
*
|
||||
* Sends structured JSON payloads to the EverShelf backend
|
||||
* (POST /api/?action=report_error) which in turn creates or
|
||||
* updates a GitHub Issue automatically.
|
||||
*
|
||||
* Usage:
|
||||
* // In Application or Activity onCreate:
|
||||
* ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!)
|
||||
*
|
||||
* // To report a caught exception:
|
||||
* ErrorReporter.report(e, "myMethod", mapOf("extra" to "data"))
|
||||
*
|
||||
* // To report a non-exception event:
|
||||
* ErrorReporter.reportMessage("webview-crash", "WebView died unexpectedly")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "EverShelfErrorReporter"
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
// Fingerprints already sent in this process to avoid flooding
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var serverBaseUrl: String = ""
|
||||
private var appVersion: String = ""
|
||||
private var deviceInfo: String = ""
|
||||
|
||||
/**
|
||||
* Call once (e.g. in KioskActivity.onCreate) before reporting any errors.
|
||||
* @param context Application or Activity context.
|
||||
* @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080"
|
||||
*/
|
||||
fun init(context: Context, baseUrl: String) {
|
||||
serverBaseUrl = baseUrl.trimEnd('/')
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
||||
|
||||
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
|
||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
reportSync(
|
||||
type = "uncaught-exception",
|
||||
message = throwable.message ?: throwable.javaClass.simpleName,
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = mapOf("thread" to thread.name)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
// Re-throw to the previous handler so the system crash dialog/restart still works
|
||||
previousHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught [Throwable] asynchronously (does not block UI thread).
|
||||
*/
|
||||
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)
|
||||
reportAsync(
|
||||
type = "kiosk-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a non-exception message (e.g. WebView page error, network failure).
|
||||
*/
|
||||
fun reportMessage(
|
||||
type: String,
|
||||
message: String,
|
||||
extra: Map<String, Any?> = emptyMap()
|
||||
) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
reportAsync(type = type, message = message, stack = "", context = ctx)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun fingerprint(type: String, message: String): String {
|
||||
val key = "$type:${message.take(120)}"
|
||||
return key.hashCode().toString(16)
|
||||
}
|
||||
|
||||
private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return // already reported this session
|
||||
}
|
||||
executor.execute { doPost(type, message, stack, context) }
|
||||
}
|
||||
|
||||
/** Synchronous variant used only in the UncaughtExceptionHandler (already off main thread). */
|
||||
private fun reportSync(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
|
||||
doPost(type, message, stack, context)
|
||||
}
|
||||
|
||||
private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val url = serverBaseUrl.ifEmpty { return }
|
||||
val endpoint = "$url/api/?action=report_error"
|
||||
try {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("source", "kiosk")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-Kiosk/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("url", url)
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
|
||||
val conn = URL(endpoint).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val responseCode = conn.responseCode
|
||||
conn.disconnect()
|
||||
|
||||
Log.d(TAG, "Reported '$type' → HTTP $responseCode")
|
||||
} catch (e: Exception) {
|
||||
// Never rethrow from the error reporter itself
|
||||
Log.w(TAG, "Failed to report error '$type': ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,11 @@ class KioskActivity : AppCompatActivity() {
|
||||
enableKioskLock()
|
||||
requestAllPermissions()
|
||||
|
||||
// Initialise centralised error reporter as early as possible so the
|
||||
// UncaughtExceptionHandler is installed before any background work starts.
|
||||
val savedUrl = prefs.getString(KEY_URL, "") ?: ""
|
||||
ErrorReporter.init(this, savedUrl)
|
||||
|
||||
// Initialise native TTS engine so the JS bridge works even when
|
||||
// Web Speech API voices are unavailable in the Android WebView.
|
||||
tts = TextToSpeech(this) { status ->
|
||||
@@ -320,6 +325,9 @@ class KioskActivity : AppCompatActivity() {
|
||||
private fun finishWizard() {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||
wizardContainer.visibility = View.GONE
|
||||
// Re-init ErrorReporter with the confirmed URL so future errors are reported
|
||||
val confirmedUrl = prefs.getString(KEY_URL, "") ?: ""
|
||||
ErrorReporter.init(this, confirmedUrl)
|
||||
launchWebView()
|
||||
}
|
||||
|
||||
@@ -468,7 +476,15 @@ class KioskActivity : AppCompatActivity() {
|
||||
view: WebView?, request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
val errorDesc = error?.description?.toString() ?: "unknown"
|
||||
val errorCode = error?.errorCode ?: -1
|
||||
val url = request?.url?.toString() ?: ""
|
||||
if (request?.isForMainFrame == true) {
|
||||
ErrorReporter.reportMessage(
|
||||
type = "webview-load-error",
|
||||
message = "WebView failed to load main frame: $errorDesc (code $errorCode)",
|
||||
extra = mapOf("url" to url, "errorCode" to errorCode)
|
||||
)
|
||||
view?.loadData(errorPageHtml(), "text/html", "UTF-8")
|
||||
}
|
||||
}
|
||||
@@ -509,7 +525,20 @@ class KioskActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean = true
|
||||
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean {
|
||||
// Forward JS errors and warnings to the error reporter
|
||||
if (msg != null && msg.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||
ErrorReporter.reportMessage(
|
||||
type = "webview-js-error",
|
||||
message = msg.message(),
|
||||
extra = mapOf(
|
||||
"source_id" to msg.sourceId(),
|
||||
"line" to msg.lineNumber()
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
override fun onShowFileChooser(
|
||||
wv: WebView?,
|
||||
callback: ValueCallback<Array<Uri>>?,
|
||||
|
||||
Reference in New Issue
Block a user