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:
@@ -20,3 +20,11 @@ TTS_AUTH_TYPE=bearer
|
|||||||
TTS_CONTENT_TYPE=application/json
|
TTS_CONTENT_TYPE=application/json
|
||||||
TTS_PAYLOAD_KEY=message
|
TTS_PAYLOAD_KEY=message
|
||||||
TTS_ENABLED=false
|
TTS_ENABLED=false
|
||||||
|
|
||||||
|
# GitHub Error Reporting (optional but recommended)
|
||||||
|
# Creates GitHub Issues automatically on crashes/errors from app, kiosk and server.
|
||||||
|
# Create a fine-grained PAT at https://github.com/settings/tokens?type=beta
|
||||||
|
# → Only selected repos: EverShelf
|
||||||
|
# → Permissions: Issues (Read+Write), Metadata (Read-only)
|
||||||
|
GITHUB_ISSUE_TOKEN=
|
||||||
|
GITHUB_REPO=dadaloop82/EverShelf
|
||||||
|
|||||||
+256
@@ -11,6 +11,28 @@
|
|||||||
// database.php must always be loaded (used both by HTTP router and cron)
|
// database.php must always be loaded (used both by HTTP router and cron)
|
||||||
require_once __DIR__ . '/database.php';
|
require_once __DIR__ . '/database.php';
|
||||||
|
|
||||||
|
// ── Global PHP error/exception reporters ─────────────────────────────────────
|
||||||
|
// These are registered immediately so any crash anywhere in this file is caught.
|
||||||
|
// The handler function _phpErrorReport() is defined later; PHP resolves function
|
||||||
|
// names at call time so forward-referencing is safe.
|
||||||
|
if (!defined('CRON_MODE')) {
|
||||||
|
set_exception_handler(function (Throwable $e): void {
|
||||||
|
_phpErrorReport(
|
||||||
|
$e->getMessage(),
|
||||||
|
$e->getFile(),
|
||||||
|
$e->getLine(),
|
||||||
|
$e->getTraceAsString(),
|
||||||
|
get_class($e)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
register_shutdown_function(function (): void {
|
||||||
|
$err = error_get_last();
|
||||||
|
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||||||
|
_phpErrorReport($err['message'], $err['file'], $err['line'], '', 'PHP Fatal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load environment variables from .env file.
|
* Load environment variables from .env file.
|
||||||
* Returns associative array of key => value pairs.
|
* Returns associative array of key => value pairs.
|
||||||
@@ -67,6 +89,7 @@ function checkRateLimit(string $action): void {
|
|||||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
|
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
|
||||||
$loginActions = [];
|
$loginActions = [];
|
||||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||||
|
$errorActions = ['report_error'];
|
||||||
|
|
||||||
if (in_array($action, $aiActions)) {
|
if (in_array($action, $aiActions)) {
|
||||||
$limit = 15;
|
$limit = 15;
|
||||||
@@ -76,6 +99,10 @@ function checkRateLimit(string $action): void {
|
|||||||
$limit = 5;
|
$limit = 5;
|
||||||
$window = 60;
|
$window = 60;
|
||||||
$bucket = 'recipe';
|
$bucket = 'recipe';
|
||||||
|
} elseif (in_array($action, $errorActions)) {
|
||||||
|
$limit = 20;
|
||||||
|
$window = 60;
|
||||||
|
$bucket = 'error_report';
|
||||||
} elseif (in_array($action, $loginActions)) {
|
} elseif (in_array($action, $loginActions)) {
|
||||||
$limit = 5;
|
$limit = 5;
|
||||||
$window = 60;
|
$window = 60;
|
||||||
@@ -325,6 +352,10 @@ try {
|
|||||||
getOpenedShelfLifeAction();
|
getOpenedShelfLifeAction();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'report_error':
|
||||||
|
reportError();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||||
@@ -5535,3 +5566,228 @@ function migrateUnitsToBase(PDO $db): void {
|
|||||||
|
|
||||||
echo json_encode(['success' => true, 'changes' => $changes]);
|
echo json_encode(['success' => true, 'changes' => $changes]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ==========================
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/?action=report_error
|
||||||
|
*
|
||||||
|
* Accepts error payloads from any client (PWA browser, Android kiosk, cron).
|
||||||
|
* Creates a GitHub issue on dadaloop82/EverShelf with deduplication:
|
||||||
|
* if an open issue with the same fingerprint already exists it posts a comment
|
||||||
|
* instead of opening a duplicate.
|
||||||
|
*
|
||||||
|
* Expected JSON body:
|
||||||
|
* source string 'pwa'|'kiosk'|'php'|'cron'|'scale'
|
||||||
|
* type string e.g. 'js-error'|'php-crash'|'unhandled-promise'|…
|
||||||
|
* message string Error message (required)
|
||||||
|
* stack string? Stack trace
|
||||||
|
* context object? Arbitrary key→value extra info
|
||||||
|
* url string? Page URL where the error occurred
|
||||||
|
* user_agent string? Navigator UA
|
||||||
|
* version string? App version
|
||||||
|
*/
|
||||||
|
function reportError(): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
|
||||||
|
$source = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['source'] ?? 'unknown'));
|
||||||
|
$type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['type'] ?? 'error'));
|
||||||
|
$message = substr(trim($input['message'] ?? ''), 0, 500);
|
||||||
|
$stack = substr(trim($input['stack'] ?? ''), 0, 4000);
|
||||||
|
$pageUrl = substr(trim($input['url'] ?? ''), 0, 300);
|
||||||
|
$ua = substr(trim($input['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300);
|
||||||
|
$version = substr(trim($input['version'] ?? ''), 0, 50);
|
||||||
|
$context = $input['context'] ?? [];
|
||||||
|
|
||||||
|
if (empty($message)) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'message required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write to local log regardless of GitHub availability ──────────────
|
||||||
|
_appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context);
|
||||||
|
|
||||||
|
// ── Fire GitHub issue (non-blocking: we always return ok to client) ───
|
||||||
|
$token = env('GITHUB_ISSUE_TOKEN');
|
||||||
|
$repo = env('GITHUB_REPO', 'dadaloop82/EverShelf');
|
||||||
|
if (!empty($token) && !empty($repo)) {
|
||||||
|
_createOrCommentGithubIssue($token, $repo, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append to data/error_reports.log (local safety net, max 500 KB)
|
||||||
|
*/
|
||||||
|
function _appendErrorLog(string $source, string $type, string $message, string $stack, string $url, string $ua, array $context): void {
|
||||||
|
$logFile = __DIR__ . '/../data/error_reports.log';
|
||||||
|
// Rotate if > 500 KB
|
||||||
|
if (file_exists($logFile) && filesize($logFile) > 500000) {
|
||||||
|
$lines = file($logFile);
|
||||||
|
$lines = array_slice($lines, -300);
|
||||||
|
file_put_contents($logFile, implode('', $lines));
|
||||||
|
}
|
||||||
|
$ts = date('Y-m-d H:i:s');
|
||||||
|
$ctx = $context ? ' ctx=' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||||
|
$line = "[$ts] [$source] [$type] $message" . ($url ? " | url=$url" : '') . $ctx . "\n";
|
||||||
|
if ($stack) $line .= " STACK: " . str_replace("\n", "\n ", $stack) . "\n";
|
||||||
|
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint = sha1(source:type:first-120-chars-of-message)
|
||||||
|
* Used to deduplicate open issues.
|
||||||
|
*/
|
||||||
|
function _errorFingerprint(string $source, string $type, string $message): string {
|
||||||
|
return sha1($source . ':' . $type . ':' . substr($message, 0, 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a GitHub issue, or add a comment to an existing open issue with the
|
||||||
|
* same fingerprint. Uses the REST API v3 directly (no library needed).
|
||||||
|
*/
|
||||||
|
function _createOrCommentGithubIssue(
|
||||||
|
string $token, string $repo,
|
||||||
|
string $source, string $type, string $message,
|
||||||
|
string $stack, string $pageUrl, string $ua,
|
||||||
|
string $version, array $context
|
||||||
|
): void {
|
||||||
|
$fp = _errorFingerprint($source, $type, $message);
|
||||||
|
|
||||||
|
// ── 1. Search for an existing open issue with this fingerprint ─────────
|
||||||
|
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||||
|
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||||
|
|
||||||
|
$existingIssueNumber = null;
|
||||||
|
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
|
||||||
|
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build the common details block ─────────────────────────────────────
|
||||||
|
$ts = date('Y-m-d H:i:s T');
|
||||||
|
$ctxMd = '';
|
||||||
|
if ($context) {
|
||||||
|
$ctxMd = "\n**Context:**\n```json\n" . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n```\n";
|
||||||
|
}
|
||||||
|
$stackMd = $stack ? "\n**Stack trace:**\n```\n$stack\n```\n" : '';
|
||||||
|
$urlMd = $pageUrl ? "\n**URL:** `$pageUrl`" : '';
|
||||||
|
$uaMd = $ua ? "\n**User-Agent:** `$ua`" : '';
|
||||||
|
$verMd = $version ? "\n**Version:** `$version`" : '';
|
||||||
|
|
||||||
|
if ($existingIssueNumber) {
|
||||||
|
// ── 2a. Post a comment to the existing issue ──────────────────────
|
||||||
|
$body = "### 🔁 Recurrence — $ts\n"
|
||||||
|
. "**Source:** `$source` | **Type:** `$type`\n"
|
||||||
|
. $urlMd . $uaMd . $verMd . "\n"
|
||||||
|
. $ctxMd . $stackMd
|
||||||
|
. "\n---\n_fp:$fp_";
|
||||||
|
_githubRequest($token, 'POST',
|
||||||
|
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
|
||||||
|
['body' => $body]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ── 2b. Create a new issue ────────────────────────────────────────
|
||||||
|
// Determine labels from source
|
||||||
|
$labelMap = [
|
||||||
|
'pwa' => 'js-error',
|
||||||
|
'kiosk' => 'kiosk-error',
|
||||||
|
'php' => 'php-crash',
|
||||||
|
'cron' => 'php-crash',
|
||||||
|
'scale' => 'scale-error',
|
||||||
|
];
|
||||||
|
$typeLabel = $labelMap[$source] ?? 'js-error';
|
||||||
|
|
||||||
|
$shortMsg = strlen($message) > 70 ? substr($message, 0, 70) . '…' : $message;
|
||||||
|
$title = "[" . strtoupper($source) . "] $shortMsg";
|
||||||
|
|
||||||
|
$body = "## 🚨 Automatic Error Report\n\n"
|
||||||
|
. "**Source:** `$source` \n"
|
||||||
|
. "**Type:** `$type` \n"
|
||||||
|
. "**Reported at:** $ts \n"
|
||||||
|
. $urlMd . "\n"
|
||||||
|
. $uaMd . "\n"
|
||||||
|
. $verMd . "\n\n"
|
||||||
|
. "**Error message:**\n> $message\n"
|
||||||
|
. $stackMd
|
||||||
|
. $ctxMd
|
||||||
|
. "\n---\n"
|
||||||
|
. "<!-- auto-report fp:$fp -->\n"
|
||||||
|
. "_This issue was created automatically by EverShelf's error reporter. fp:`$fp`_";
|
||||||
|
|
||||||
|
_githubRequest($token, 'POST',
|
||||||
|
"https://api.github.com/repos/$repo/issues",
|
||||||
|
[
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => ['auto-report', $typeLabel],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal GitHub REST API helper (curl).
|
||||||
|
* Returns ['http_code' => int, 'body' => array].
|
||||||
|
*/
|
||||||
|
function _githubRequest(string $token, string $method, string $url, array $payload = []): array {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = [
|
||||||
|
'Authorization: token ' . $token,
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version: 2022-11-28',
|
||||||
|
'User-Agent: EverShelf-ErrorReporter/1.0',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
];
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
if ($method === 'POST') {
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the PHP exception/shutdown handlers registered at the top of this file.
|
||||||
|
* Writes to local log + creates a GitHub issue.
|
||||||
|
*/
|
||||||
|
function _phpErrorReport(string $message, string $file, int $line, string $trace, string $type): void {
|
||||||
|
// Prevent infinite loops if this function itself throws
|
||||||
|
static $running = false;
|
||||||
|
if ($running) return;
|
||||||
|
$running = true;
|
||||||
|
|
||||||
|
$source = 'php';
|
||||||
|
$errType = 'php-crash';
|
||||||
|
$context = [
|
||||||
|
'file' => $file,
|
||||||
|
'line' => $line,
|
||||||
|
'php' => PHP_VERSION,
|
||||||
|
'action' => $_GET['action'] ?? '',
|
||||||
|
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
_appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context);
|
||||||
|
|
||||||
|
$token = env('GITHUB_ISSUE_TOKEN');
|
||||||
|
$repo = env('GITHUB_REPO', 'dadaloop82/EverShelf');
|
||||||
|
if (!empty($token) && !empty($repo)) {
|
||||||
|
_createOrCommentGithubIssue(
|
||||||
|
$token, $repo, $source, $errType,
|
||||||
|
"[$type] $message", $trace,
|
||||||
|
'', '', PHP_VERSION, $context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$running = false;
|
||||||
|
}
|
||||||
|
|||||||
+58
-5
@@ -7,8 +7,11 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ===== REMOTE LOGGING =====
|
// ===== REMOTE LOGGING + ERROR REPORTING =====
|
||||||
// Global remote logger: captures all errors, warnings and key operations
|
// Two-tier system:
|
||||||
|
// 1. remoteLog() — batched INFO/WARN/ERROR → existing client_log endpoint (debug tail)
|
||||||
|
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
|
||||||
|
|
||||||
const _remoteLogBuffer = [];
|
const _remoteLogBuffer = [];
|
||||||
let _remoteLogTimer = null;
|
let _remoteLogTimer = null;
|
||||||
const _origConsoleError = console.error.bind(console);
|
const _origConsoleError = console.error.bind(console);
|
||||||
@@ -47,12 +50,54 @@ console.warn = function(...args) {
|
|||||||
remoteLog('WARN', ...args);
|
remoteLog('WARN', ...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Catch unhandled errors
|
// ── Error reporter: creates/updates GitHub Issues ────────────────────────────
|
||||||
|
// Rate-limit client-side: max 1 report per fingerprint per page session.
|
||||||
|
const _reportedFingerprints = new Set();
|
||||||
|
|
||||||
|
function reportError(payload) {
|
||||||
|
// Build fingerprint to deduplicate within the same page session
|
||||||
|
const fp = `${payload.source}:${payload.type}:${String(payload.message).slice(0, 120)}`;
|
||||||
|
if (_reportedFingerprints.has(fp)) return;
|
||||||
|
_reportedFingerprints.add(fp);
|
||||||
|
|
||||||
|
const body = Object.assign({
|
||||||
|
source: 'pwa',
|
||||||
|
version: document.querySelector('.header-version')?.textContent?.trim() || '',
|
||||||
|
url: location.href,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
}, payload);
|
||||||
|
|
||||||
|
fetch('api/index.php?action=report_error', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => {}); // fire-and-forget; never throw from error handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global uncaught error handler ────────────────────────────────────────────
|
||||||
window.addEventListener('error', function(e) {
|
window.addEventListener('error', function(e) {
|
||||||
remoteLog('UNCAUGHT', `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`);
|
const msg = e.message || String(e.error);
|
||||||
|
// Ignore benign third-party noise
|
||||||
|
if (/Script error/i.test(msg)) return;
|
||||||
|
remoteLog('UNCAUGHT', `${msg} at ${e.filename}:${e.lineno}:${e.colno}`);
|
||||||
|
reportError({
|
||||||
|
type: 'uncaught-error',
|
||||||
|
message: msg,
|
||||||
|
stack: e.error?.stack || '',
|
||||||
|
context: { filename: e.filename, lineno: e.lineno, colno: e.colno },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', function(e) {
|
window.addEventListener('unhandledrejection', function(e) {
|
||||||
remoteLog('UNHANDLED_PROMISE', e.reason);
|
const reason = e.reason;
|
||||||
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||||
|
const stack = reason instanceof Error ? (reason.stack || '') : '';
|
||||||
|
remoteLog('UNHANDLED_PROMISE', msg);
|
||||||
|
reportError({
|
||||||
|
type: 'unhandled-promise',
|
||||||
|
message: msg,
|
||||||
|
stack: stack,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== CONFIGURATION =====
|
// ===== CONFIGURATION =====
|
||||||
@@ -2068,6 +2113,14 @@ async function api(action, params = {}, method = 'GET', body = null) {
|
|||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
||||||
|
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
|
||||||
|
if (res.status >= 500) {
|
||||||
|
reportError({
|
||||||
|
type: 'api-server-error',
|
||||||
|
message: `API ${action} returned HTTP ${res.status}`,
|
||||||
|
context: { action, status: res.status },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && data.error) {
|
if (data && data.error) {
|
||||||
|
|||||||
@@ -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()
|
enableKioskLock()
|
||||||
requestAllPermissions()
|
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
|
// Initialise native TTS engine so the JS bridge works even when
|
||||||
// Web Speech API voices are unavailable in the Android WebView.
|
// Web Speech API voices are unavailable in the Android WebView.
|
||||||
tts = TextToSpeech(this) { status ->
|
tts = TextToSpeech(this) { status ->
|
||||||
@@ -320,6 +325,9 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
private fun finishWizard() {
|
private fun finishWizard() {
|
||||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||||
wizardContainer.visibility = View.GONE
|
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()
|
launchWebView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +476,15 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
view: WebView?, request: WebResourceRequest?,
|
view: WebView?, request: WebResourceRequest?,
|
||||||
error: WebResourceError?
|
error: WebResourceError?
|
||||||
) {
|
) {
|
||||||
|
val errorDesc = error?.description?.toString() ?: "unknown"
|
||||||
|
val errorCode = error?.errorCode ?: -1
|
||||||
|
val url = request?.url?.toString() ?: ""
|
||||||
if (request?.isForMainFrame == true) {
|
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")
|
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(
|
override fun onShowFileChooser(
|
||||||
wv: WebView?,
|
wv: WebView?,
|
||||||
callback: ValueCallback<Array<Uri>>?,
|
callback: ValueCallback<Array<Uri>>?,
|
||||||
|
|||||||
Reference in New Issue
Block a user