thats-me/frontend/test/vitest/client-log.spec.js
Kevin Adametz ab23c9f4ef Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder
Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429:
nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes
sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach
blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden"
erschien.

Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min),
POST /client-logs nimmt Warnungen und Fehler der App an (client-logs,
30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions
tragen user_id, path und method im Kontext.

Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung
und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit
Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch
ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen
Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service
mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im
Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer
Fläche in Timeline-Punkt, Key Image und Medienkacheln.

Dokumentation in documentation/logging.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:35 +00:00

128 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { db } from 'src/db'
import {
clientLog,
logEntries,
normalizeContext,
resetClientLogForTests,
restoreClientLog,
setClientLogTransport,
shipClientLog
} from 'src/services/clientLog'
import { flush, waitFor } from './helpers'
beforeEach(async () => {
resetClientLogForTests()
await db.clientLogs.clear()
})
afterEach(() => {
resetClientLogForTests()
})
describe('client log — collecting', () => {
it('bumps a counter for the same message instead of adding a line', () => {
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
expect(logEntries.value).toHaveLength(2)
expect(logEntries.value[0]).toMatchObject({ level: 'warning', scope: 'image', count: 3, shipped: 0 })
expect(logEntries.value[1]).toMatchObject({ level: 'error', scope: 'api', count: 1, shipped: 0 })
})
it('keeps info entries local and flattens errors into scalar context', () => {
const entry = clientLog.info('sync', 'Warteschlange geleert')
expect(entry.shipped).toBe(1)
const error = Object.assign(new Error('Too Many Requests'), { status: 429 })
error.name = 'ApiError'
expect(normalizeContext(error)).toMatchObject({ error: 'ApiError: Too Many Requests', status: 429 })
expect(normalizeContext({ nested: { a: 1 }, ok: true, skipped: undefined, empty: '' }))
.toEqual({ nested: '{"a":1}', ok: true })
expect(normalizeContext(null)).toBeNull()
})
})
describe('client log — shipping', () => {
it('sends unshipped warnings and errors as one batch and marks them', async () => {
const send = vi.fn(async () => {})
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.info('sync', 'nur lokal')
setClientLogTransport({ appVersion: '0.0.1', enabled: () => true, send })
expect(await shipClientLog()).toBe(true)
expect(send).toHaveBeenCalledTimes(1)
const [batch] = send.mock.calls[0]
expect(batch.appVersion).toBe('0.0.1')
expect(batch.entries).toHaveLength(2)
expect(batch.entries[0]).toMatchObject({
level: 'error',
scope: 'api',
message: 'PUT /settings → 429',
context: { status: 429 }
})
expect(batch.entries[1].message).toBe('Bild konnte nicht geladen werden (429) (×2)')
expect(batch.entries[0].occurredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(logEntries.value.every(entry => entry.shipped === 1)).toBe(true)
// Nothing left: the next call sends nothing.
expect(await shipClientLog()).toBe(false)
expect(send).toHaveBeenCalledTimes(1)
})
it('keeps everything in the browser when the transport says no (demo user)', async () => {
const send = vi.fn(async () => {})
clientLog.error('window', 'crash')
setClientLogTransport({ enabled: () => false, send })
expect(await shipClientLog()).toBe(false)
expect(send).not.toHaveBeenCalled()
expect(logEntries.value[0].shipped).toBe(0)
})
it('stops after the server rejects the batch, but retries after a hiccup', async () => {
const rejected = vi.fn(async () => {
throw Object.assign(new Error('Unprocessable'), { status: 422 })
})
clientLog.error('window', 'crash')
setClientLogTransport({ enabled: () => true, send: rejected })
expect(await shipClientLog()).toBe(false)
expect(await shipClientLog()).toBe(false)
expect(rejected).toHaveBeenCalledTimes(1)
resetClientLogForTests()
const flaky = vi.fn(async () => {
throw Object.assign(new Error('Server error'), { status: 500 })
})
clientLog.error('window', 'crash again')
setClientLogTransport({ enabled: () => true, send: flaky })
expect(await shipClientLog()).toBe(false)
expect(await shipClientLog()).toBe(false)
expect(flaky).toHaveBeenCalledTimes(2)
expect(logEntries.value[0].shipped).toBe(0)
})
it('restores unshipped entries from IndexedDB after a reload', async () => {
clientLog.error('window', 'Absturz vor dem Versand')
await waitFor(() => false, { rounds: 3 }).catch(() => {})
await flush(2)
expect(await db.clientLogs.count()).toBe(1)
// A reload: memory is gone, the table is not.
resetClientLogForTests()
expect(logEntries.value).toHaveLength(0)
await restoreClientLog()
expect(logEntries.value).toHaveLength(1)
expect(logEntries.value[0]).toMatchObject({ message: 'Absturz vor dem Versand', shipped: 0 })
})
})