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>
128 lines
4.7 KiB
JavaScript
128 lines
4.7 KiB
JavaScript
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 })
|
||
})
|
||
})
|