Debug-Log: Fehler lokal erfassen + automatisch zum Server laden [apk]
All checks were successful
Build APK / build-apk (push) Successful in 4m23s

Damit App-Fehler (Scan/Sync) ohne Kabel nachvollziehbar sind:
- debuglog.svelte.ts: faengt window.error, unhandledrejection und console.*
  in einem Ringpuffer ab, gespiegelt in Preferences (ueberlebt Neustart)
- Auto-Upload zum neuen Endpoint applog.php (gedrosselt, best effort);
  ToolDialog- und Sync-Fehler werden explizit mitgeloggt
- Seite Einstellungen -> Debug-Log: Eintraege ansehen, manuell senden, leeren
- initDebugLog() zuerst in +layout onMount, damit Startfehler erfasst werden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 17:39:43 +02:00
parent fca59545cc
commit a77bcd0355
7 changed files with 255 additions and 0 deletions

View file

@ -215,3 +215,17 @@ export function syncProtocol(protocol: Protocol): Promise<SyncResult> {
export function pdfUrl(serverProtocolId: number): string {
return `${serverUrl}${API_PATH}/pdf.php?id=${serverProtocolId}&jwt=${encodeURIComponent(token)}`;
}
export interface DebugLogUpload {
entries: { ts: number; level: string; msg: string }[];
appVersion: string;
device: string;
}
/** Debug-Log-Einträge zum Server übertragen (Endpoint applog.php) */
export function uploadDebugLog(payload: DebugLogUpload): Promise<{ ok: boolean; stored: number }> {
return request<{ ok: boolean; stored: number }>('applog.php', {
method: 'POST',
body: JSON.stringify(payload),
});
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import { debugLog } from '$lib/debuglog.svelte';
import { X } from 'lucide-svelte';
let {
@ -32,6 +33,7 @@
onclose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
debugLog.add('error', `Tool "${tool.id}":`, e);
} finally {
busy = false;
}

159
src/lib/debuglog.svelte.ts Normal file
View file

@ -0,0 +1,159 @@
/**
* Debug-Log (Svelte 5 Runes).
*
* Fängt globale Fehler (window.error / unhandledrejection) und alle
* console.*-Ausgaben in einem Ringpuffer ab. So lässt sich nachvollziehen,
* warum z.B. ein Scan oder die Synchronisierung scheitert ohne Kabel,
* ohne Logcat.
*
* Zwei Wege:
* - lokal sichtbar unter Einstellungen Debug-Log
* - automatischer Upload zum Server (api/applog.php llx_netdiag_applog),
* damit die Logs auch ohne Gerät auswertbar sind.
*
* Der Puffer wird in den Preferences gespiegelt und übersteht so einen
* App-Neustart (inkl. der Markierung, was schon hochgeladen wurde).
*/
import { Preferences } from '@capacitor/preferences';
import { isLoggedIn, uploadDebugLog } from './api';
import { APP_VERSION } from './updater';
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogEntry {
ts: number;
level: LogLevel;
msg: string;
/** true, sobald der Eintrag erfolgreich zum Server übertragen wurde */
sent?: boolean;
}
const MAX_ENTRIES = 200;
const STORE_KEY = 'netdiag_debuglog';
const UPLOAD_THROTTLE_MS = 10_000;
/** Beliebigen Wert für die Log-Zeile lesbar machen */
function fmt(v: unknown): string {
if (typeof v === 'string') return v;
if (v instanceof Error) return `${v.name}: ${v.message}`;
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
class DebugLog {
entries = $state<LogEntry[]>([]);
uploadState = $state<'idle' | 'sending' | 'error'>('idle');
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private uploadTimer: ReturnType<typeof setTimeout> | null = null;
/** Neuen Eintrag anhängen (Ringpuffer) */
add(level: LogLevel, ...parts: unknown[]): void {
const msg = parts.map(fmt).join(' ').trim();
this.entries = [...this.entries.slice(-(MAX_ENTRIES - 1)), { ts: Date.now(), level, msg }];
this.scheduleSave();
this.scheduleUpload();
}
/** Log leeren */
clear(): void {
this.entries = [];
void Preferences.remove({ key: STORE_KEY });
}
/** Gesamtes Log als Klartext */
asText(): string {
return this.entries
.map((e) => `${new Date(e.ts).toISOString()} [${e.level.toUpperCase()}] ${e.msg}`)
.join('\n');
}
/** Persistierten Puffer beim Start laden */
async load(): Promise<void> {
try {
const raw = (await Preferences.get({ key: STORE_KEY })).value;
if (raw) this.entries = JSON.parse(raw) as LogEntry[];
} catch {
/* defektes Log ignorieren */
}
this.scheduleUpload();
}
/**
* Noch nicht übertragene Einträge zum Server schieben.
* Best effort wirft nie und loggt eigene Fehler NICHT (sonst Endlosschleife).
*/
async flush(): Promise<void> {
if (this.uploadState === 'sending' || !isLoggedIn()) return;
const batch = this.entries.filter((e) => !e.sent).slice(0, 200);
if (batch.length === 0) return;
this.uploadState = 'sending';
try {
await uploadDebugLog({
entries: batch.map((e) => ({ ts: e.ts, level: e.level, msg: e.msg })),
appVersion: APP_VERSION,
device: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 128) : '',
});
const sent = new Set(batch);
this.entries = this.entries.map((e) => (sent.has(e) ? { ...e, sent: true } : e));
this.uploadState = 'idle';
void Preferences.set({ key: STORE_KEY, value: JSON.stringify(this.entries) });
} catch {
this.uploadState = 'error';
}
}
private scheduleSave(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
void Preferences.set({ key: STORE_KEY, value: JSON.stringify(this.entries) });
}, 1000);
}
/** Upload drosseln: spätestens 10 s nach der ersten neuen Meldung senden */
private scheduleUpload(): void {
if (this.uploadTimer) return;
this.uploadTimer = setTimeout(() => {
this.uploadTimer = null;
void this.flush();
}, UPLOAD_THROTTLE_MS);
}
}
export const debugLog = new DebugLog();
/**
* Globale Fehler-Hooks + console.*-Wrapper installieren.
* Muss einmalig und möglichst früh aufgerufen werden.
*/
let installed = false;
export function initDebugLog(): void {
if (installed || typeof window === 'undefined') return;
installed = true;
void debugLog.load();
window.addEventListener('error', (ev) => {
const ort = ev.filename ? ` (${ev.filename}:${ev.lineno})` : '';
debugLog.add('error', (ev.message || 'Fehler') + ort);
});
window.addEventListener('unhandledrejection', (ev) => {
debugLog.add('error', 'Unbehandelt:', ev.reason);
});
// console.* mitschneiden, Originalausgabe bleibt erhalten
for (const level of ['log', 'warn', 'error'] as const) {
const orig = console[level].bind(console);
console[level] = (...args: unknown[]) => {
debugLog.add(level === 'log' ? 'info' : level, ...args);
orig(...args);
};
}
debugLog.add('info', `Debug-Log gestartet — App ${APP_VERSION}`);
}

View file

@ -11,6 +11,7 @@ import { Network } from '@capacitor/network';
import { ApiError, isLoggedIn, syncProtocol } from './api';
import { getDirtyProtocols, saveProtocol } from './db';
import { toast } from './toast.svelte';
import { debugLog } from './debuglog.svelte';
type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error';
@ -77,6 +78,7 @@ class SyncState {
} catch (e) {
this.lastError = e instanceof ApiError ? e.message : 'Sync-Fehler';
this.status = 'error';
debugLog.add('error', `Sync (Protokoll ${p.clientUuid}):`, e);
await this.refreshPending();
return; // beim nächsten Lauf erneut versuchen
}

View file

@ -9,6 +9,7 @@
import { initDb } from '$lib/db';
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
import { checkForUpdate, openUpdate, type UpdateInfo } from '$lib/updater';
import { initDebugLog } from '$lib/debuglog.svelte';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
@ -19,6 +20,7 @@
const HOME = '/auftraege/';
onMount(async () => {
initDebugLog(); // zuerst — damit auch Startfehler erfasst werden
await auth.init();
await initDb();
if (auth.loggedIn) await sync.start();

View file

@ -0,0 +1,62 @@
<script lang="ts">
import AppHeader from '$lib/components/AppHeader.svelte';
import { debugLog } from '$lib/debuglog.svelte';
const levelColor: Record<string, string> = {
info: 'text-zinc-400',
warn: 'text-amber-400',
error: 'text-red-400',
};
function fmtTime(ts: number): string {
const d = new Date(ts);
return (
d.toLocaleTimeString('de-DE') + '.' + String(d.getMilliseconds()).padStart(3, '0')
);
}
// Anzahl noch nicht übertragener Einträge
const offen = $derived(debugLog.entries.filter((e) => !e.sent).length);
const uploadText = $derived(
debugLog.uploadState === 'sending'
? 'sendet …'
: debugLog.uploadState === 'error'
? 'Upload fehlgeschlagen'
: offen > 0
? `${offen} noch nicht übertragen`
: 'alle übertragen',
);
</script>
<AppHeader title="Debug-Log" back subtitle={`${debugLog.entries.length} Einträge`} />
<div class="flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-3 py-2">
<button
class="rounded bg-sky-600 px-3 py-1.5 text-sm text-white active:bg-sky-700 disabled:opacity-50"
onclick={() => debugLog.flush()}
disabled={debugLog.uploadState === 'sending'}
>
An Server senden
</button>
<span class="flex-1 text-xs text-zinc-500">{uploadText}</span>
<button
class="rounded bg-zinc-800 px-3 py-1.5 text-sm active:bg-zinc-700"
onclick={() => debugLog.clear()}
>
Leeren
</button>
</div>
<div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
{#if debugLog.entries.length === 0}
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p>
{:else}
{#each [...debugLog.entries].reverse() as e, i (i)}
<div class="border-b border-zinc-800/50 py-1">
<span class="text-zinc-600">{fmtTime(e.ts)}</span>
<span class="break-words {levelColor[e.level]}"> {e.msg}</span>
</div>
{/each}
{/if}
</div>

View file

@ -84,6 +84,20 @@
</p>
</section>
<!-- Diagnose -->
<section class="rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Diagnose</h2>
<p class="text-xs text-zinc-500">
Bei Problemen (Scan/Sync schlägt fehl): Log öffnen und teilen.
</p>
<a
class="mt-3 block rounded bg-zinc-800 px-3 py-2 text-center text-sm active:bg-zinc-700"
href="/debug/"
>
Debug-Log öffnen
</a>
</section>
<button
class="mt-2 rounded-lg bg-red-700 py-2.5 font-semibold text-white active:bg-red-800"
onclick={logout}