diff --git a/docs/adr/018-glitchtip-frontend-error-tracking.md b/docs/adr/018-glitchtip-frontend-error-tracking.md new file mode 100644 index 00000000..15e0ed83 --- /dev/null +++ b/docs/adr/018-glitchtip-frontend-error-tracking.md @@ -0,0 +1,86 @@ +# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit + +**Date:** 2026-05-17 +**Status:** Accepted +**Deciders:** Marcel Raddatz + +--- + +## Context + +The Familienarchiv had no client-side error reporting. When a user encountered a crash +or unhandled error in the SvelteKit frontend, there was no way for the operator to +observe it — errors were invisible until a user manually reported them. A GlitchTip +instance (self-hosted, Sentry-compatible) was already running as part of the +observability stack (`docker-compose.observability.yml`). The backend already reported +server-side errors to it. + +We needed a way to: +1. Capture frontend errors automatically and route them to GlitchTip. +2. Give users a visible error identifier they can include in a support message. +3. Do this without leaking personally identifiable information (PII) from the family + archive — documents contain personal histories, names, and relationships. + +--- + +## Decision + +Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to: + +- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`. +- Pass a callback to `Sentry.handleErrorWithSentry()` that returns + `{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry + captured the event, or a fresh `crypto.randomUUID()` as fallback. +- Display the `errorId` on the `+error.svelte` page so users can include it in a + report to the operator. + +The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that +development and CI builds without a DSN configured do not send any events. + +`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but +cannot read them. It is safe to include in the client bundle per the Sentry security +model; it does not require rotation like a password. + +--- + +## Alternatives considered + +**Sentry SaaS** — rejected. The archive contains private family documents and personal +history. Sending error events with stack traces to a US-hosted third party is +inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on +the same Hetzner VPS keeps all data on infrastructure the operator controls. + +**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles +SvelteKit's hook lifecycle, source-map upload, and event grouping automatically. +Reimplementing this would cost significant engineering time for no benefit. + +**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users +can only describe what happened in natural language, making it hard to correlate a +report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI +cost. + +--- + +## Consequences + +**Positive:** +- Frontend errors are now observable without requiring user reports. +- Users can provide an `errorId` that maps directly to a GlitchTip event. +- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in + captured events. +- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping + GlitchTip load low on the shared VPS. + +**Negative / trade-offs:** +- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be + reviewed for changes to the default PII scrubbing behaviour. +- The `handleError` callback in both hooks returns a hardcoded English message + (`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page + will always show English text when the hooks are active, regardless of the user's + locale. This is acceptable because: (a) the error page is a last-resort fallback + not part of normal UX, (b) the `errorId` is the actionable information, not the + message text. A future ADR may address this if internationalised error messages + become a requirement. +- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event + (e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId` + is always present, but that UUID will not appear in GlitchTip. diff --git a/docs/architecture/c4/l1-context.puml b/docs/architecture/c4/l1-context.puml index 8d2efb8f..d31ae49f 100644 --- a/docs/architecture/c4/l1-context.puml +++ b/docs/architecture/c4/l1-context.puml @@ -8,9 +8,11 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents") System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.") +System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.") Rel(admin, familienarchiv, "Manages via browser", "HTTPS") Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS") Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP") +Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS") @enduml diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b110ade9..5ef17b59 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -38,7 +38,10 @@ export default defineConfig( 'no-undef': 'off', // This rule is designed for Svelte 5's own routing system using resolve(). // In SvelteKit, and goto() from $app/navigation are the correct patterns — resolve() is not needed. - 'svelte/no-navigation-without-resolve': 'off' + 'svelte/no-navigation-without-resolve': 'off', + // Prevents accidental console.log left in source. console.warn and console.error + // are still permitted for intentional server-side logging (e.g. hooks.server.ts). + 'no-console': ['error', { allow: ['warn', 'error'] }] } }, { @@ -71,6 +74,13 @@ export default defineConfig( ] } }, + { + // E2E tests use console.log for diagnostic output — allow it there. + files: ['e2e/**'], + rules: { + 'no-console': 'off' + } + }, { files: ['**/*.spec.ts', '**/*.test.ts'], rules: { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 090c5725..38067459 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Gesch.", "dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}", - "dashboard_resume_label": "Zuletzt geöffnet:", + "dashboard_resume_label": "Weiter, wo du aufgehört hast", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", "doc_status_uploaded": "Hochgeladen", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Einladung erstellt", "admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:", "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", - "greeting_morning": "Guten Morgen, {name}.", "greeting_day": "Hallo, {name}.", "greeting_evening": "Guten Abend, {name}.", - - "dashboard_resume_label": "Weiter, wo du aufgehört hast", "dashboard_blocks": "{count} Abschnitte", "dashboard_resume_cta": "Weitertranskribieren", "dashboard_resume_other": "oder anderen Brief wählen", "dashboard_empty_title": "Noch kein Dokument begonnen", "dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.", "dashboard_empty_cta": "Zum Archiv", - "dashboard_mission_caption": "Offene Aufgaben", "queue_segment": "Segmentieren", "queue_segment_blurb": "Seiten aufteilen", @@ -795,7 +791,6 @@ "queue_review_blurb": "Texte kontrollieren", "queue_n_open": "{n} offen", "queue_show_all": "Alle anzeigen →", - "pulse_eyebrow": "Diese Woche", "pulse_headline": "Ihr habt {pages} Seiten bearbeitet.", "pulse_you": "Du selbst hast {pages} davon bearbeitet.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Textstellen markiert", "pulse_reviewed": "Textstellen transkribiert", "pulse_uploaded": "Dokumente hochgeladen", - "feed_caption": "Kommentare & Aktivität", "feed_show_all": "Alle anzeigen", "feed_for_you": "für dich", - "audit_action_text_saved": "hat Text gespeichert in", "audit_action_file_uploaded": "hat eine Datei hochgeladen:", "audit_action_annotation_created": "hat eine Markierung erstellt in", "audit_action_comment_added": "hat kommentiert:", "audit_action_mention_created": "hat dich erwähnt in", - "dropzone_release": "Loslassen zum Hochladen", - "chronik_page_title": "Aktivitäten", "chronik_for_you_caption": "Für dich", "chronik_for_you_count": "{count} neu", @@ -859,9 +850,7 @@ "pagination_page_of": "Seite {page} von {total}", "pagination_nav_label": "Seitennavigation", "pagination_page_button": "Seite {page}", - "common_opens_new_tab": "(öffnet in neuem Tab)", - "transcribe_coach_title": "Erste Transkription?", "transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:", "transcribe_coach_step_1_title": "Rahmen ziehen.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Speichert automatisch.", "transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗", "transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗", - "transcription_mode_help_label": "Lese- und Bearbeitungsmodus", "transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.", - "richtlinien_title": "Transkriptions-Richtlinien", "richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.", "richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.", "bulk_edit_topbar_title": "Massenbearbeitung", "bulk_edit_count_pill": "{count} werden bearbeitet", - "nav_stammbaum": "Stammbaum", "nav_geschichten": "Geschichten", - "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", - "geschichten_index_title": "Geschichten", "geschichten_new_button": "Neue Geschichte", "geschichten_filter_all_pill": "Alle", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Geschichte anhängen", "geschichten_card_show_all_for_person": "Alle Geschichten zu {name}", "geschichten_card_show_all": "Alle anzeigen", - "geschichte_editor_title_placeholder": "Titel der Geschichte", "geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…", "geschichte_editor_status_draft": "ENTWURF", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Unterüberschrift", "geschichte_editor_toolbar_ul": "Aufzählung", "geschichte_editor_toolbar_ol": "Nummerierte Liste", - "geschichte_delete_confirm_title": "Geschichte löschen?", "geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.", - "error_relationship_not_found": "Die Beziehung wurde nicht gefunden.", "error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.", "error_duplicate_relationship": "Diese Beziehung gibt es bereits.", - "relation_parent_of": "Elternteil von", "relation_child_of": "Kind von", "relation_spouse_of": "Ehegatte", @@ -1018,7 +998,6 @@ "relation_doctor": "Arzt", "relation_neighbor": "Nachbar", "relation_other": "Sonstige", - "relation_inferred_parent": "Elternteil", "relation_inferred_child": "Kind", "relation_inferred_spouse": "Ehegatte", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Schwager/Schwägerin", "relation_inferred_cousin_1": "Cousin/Cousine", "relation_inferred_distant": "Weitläufige Verwandtschaft", - "doc_details_field_relationship": "Verwandtschaft", - "stammbaum_empty_heading": "Noch keine Familienmitglieder", "stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.", "stammbaum_empty_link": "→ Zur Personenliste", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Vergrößern", "stammbaum_zoom_out": "Verkleinern", "stammbaum_generations": "Generationen", - "relation_error_duplicate": "Diese Beziehung gibt es bereits.", "relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.", "relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "Von Jahr", "relation_form_field_to_year": "Bis Jahr", "relation_form_year_placeholder": "z.B. 1920", - "person_relationships_heading": "Beziehungen", "person_relationships_empty": "Noch keine Beziehungen bekannt.", - "timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_clear_selection": "Auswahl zurücksetzen", "timeline_zoom_reset": "Zurück zur Übersicht", "timeline_bar_aria_singular": "{when}, 1 Dokument", "timeline_bar_aria_plural": "{when}, {count} Dokumente", - "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt" + "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt", + "error_page_id_label": "Fehler-ID", + "error_copy_id_label": "ID kopieren", + "error_copied": "Kopiert!" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 34c23200..2ecb565d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Stor.", "dashboard_reader_draft_meta": "Draft · last edited {relative}", - "dashboard_resume_label": "Last opened:", + "dashboard_resume_label": "Continue where you left off", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", "doc_status_uploaded": "Uploaded", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Invite created", "admin_invite_created_desc": "Share this link with the person you are inviting:", "admin_invite_revoke_confirm": "Really revoke this invite?", - "greeting_morning": "Good morning, {name}.", "greeting_day": "Hello, {name}.", "greeting_evening": "Good evening, {name}.", - - "dashboard_resume_label": "Continue where you left off", "dashboard_blocks": "{count} sections", "dashboard_resume_cta": "Continue transcribing", "dashboard_resume_other": "or choose another document", "dashboard_empty_title": "No document started yet", "dashboard_empty_body": "Choose a document from the archive to start transcribing.", "dashboard_empty_cta": "To the archive", - "dashboard_mission_caption": "Open tasks", "queue_segment": "Segment", "queue_segment_blurb": "Split pages", @@ -795,7 +791,6 @@ "queue_review_blurb": "Check texts", "queue_n_open": "{n} open", "queue_show_all": "Show all →", - "pulse_eyebrow": "This week", "pulse_headline": "You have worked on {pages} pages.", "pulse_you": "You personally worked on {pages} of them.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Passages annotated", "pulse_reviewed": "Passages transcribed", "pulse_uploaded": "Documents uploaded", - "feed_caption": "Comments & activity", "feed_show_all": "Show all", "feed_for_you": "for you", - "audit_action_text_saved": "saved text in", "audit_action_file_uploaded": "uploaded a file:", "audit_action_annotation_created": "created an annotation in", "audit_action_comment_added": "commented:", "audit_action_mention_created": "mentioned you in", - "dropzone_release": "Release to upload", - "chronik_page_title": "Activity", "chronik_for_you_caption": "For you", "chronik_for_you_count": "{count} new", @@ -859,9 +850,7 @@ "pagination_page_of": "Page {page} of {total}", "pagination_nav_label": "Pagination", "pagination_page_button": "Page {page}", - "common_opens_new_tab": "(opens in new tab)", - "transcribe_coach_title": "First transcription?", "transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:", "transcribe_coach_step_1_title": "Draw a frame.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Saves automatically.", "transcribe_coach_footer_kurrent": "Kurrent help ↗", "transcribe_coach_footer_richtlinien": "Transcription guidelines ↗", - "transcription_mode_help_label": "Read and edit mode", "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.", - "richtlinien_title": "Transcription Guidelines", "richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.", "richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "Could not load filter results — please retry.", "bulk_edit_topbar_title": "Bulk edit", "bulk_edit_count_pill": "{count} will be edited", - "nav_stammbaum": "Family tree", "nav_geschichten": "Stories", - "error_geschichte_not_found": "The story was not found.", - "geschichten_index_title": "Stories", "geschichten_new_button": "New story", "geschichten_filter_all_pill": "All", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Attach a story", "geschichten_card_show_all_for_person": "All stories about {name}", "geschichten_card_show_all": "Show all", - "geschichte_editor_title_placeholder": "Story title", "geschichte_editor_body_placeholder": "Write your story here…", "geschichte_editor_status_draft": "DRAFT", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Subheading", "geschichte_editor_toolbar_ul": "Bulleted list", "geschichte_editor_toolbar_ol": "Numbered list", - "geschichte_delete_confirm_title": "Delete story?", "geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.", - "error_relationship_not_found": "Relationship not found.", "error_circular_relationship": "This relationship would form a cycle.", "error_duplicate_relationship": "This relationship already exists.", - "relation_parent_of": "Parent of", "relation_child_of": "Child of", "relation_spouse_of": "Spouse", @@ -1018,7 +998,6 @@ "relation_doctor": "Doctor", "relation_neighbor": "Neighbour", "relation_other": "Other", - "relation_inferred_parent": "Parent", "relation_inferred_child": "Child", "relation_inferred_spouse": "Spouse", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Sibling-in-law", "relation_inferred_cousin_1": "Cousin", "relation_inferred_distant": "Distant relative", - "doc_details_field_relationship": "Relationship", - "stammbaum_empty_heading": "No family members yet", "stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.", "stammbaum_empty_link": "→ Go to person list", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Zoom in", "stammbaum_zoom_out": "Zoom out", "stammbaum_generations": "Generations", - "relation_error_duplicate": "This relationship already exists.", "relation_error_circular": "This relationship would form a cycle.", "relation_error_self": "A person cannot be related to themselves.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "From year", "relation_form_field_to_year": "To year", "relation_form_year_placeholder": "e.g. 1920", - "person_relationships_heading": "Relationships", "person_relationships_empty": "No relationships known yet.", - "timeline_aria_label": "Document density timeline", "timeline_clear_selection": "Clear selection", "timeline_zoom_reset": "Reset zoom", "timeline_bar_aria_singular": "{when}, 1 document", "timeline_bar_aria_plural": "{when}, {count} documents", - "timeline_dragging_aria_live": "Range {from} to {to} selected" + "timeline_dragging_aria_live": "Range {from} to {to} selected", + "error_page_id_label": "Error ID", + "error_copy_id_label": "Copy ID", + "error_copied": "Copied!" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 4e487ad8..a68b663d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Hist.", "dashboard_reader_draft_meta": "Borrador · editado hace {relative}", - "dashboard_resume_label": "Último abierto:", + "dashboard_resume_label": "Continuar donde lo dejaste", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", "doc_status_uploaded": "Cargado", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Invitación creada", "admin_invite_created_desc": "Comparte este enlace con la persona invitada:", "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", - "greeting_morning": "Buenos días, {name}.", "greeting_day": "Hola, {name}.", "greeting_evening": "Buenas noches, {name}.", - - "dashboard_resume_label": "Continuar donde lo dejaste", "dashboard_blocks": "{count} secciones", "dashboard_resume_cta": "Continuar transcripción", "dashboard_resume_other": "o elige otro documento", "dashboard_empty_title": "Aún no has comenzado ningún documento", "dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.", "dashboard_empty_cta": "Al archivo", - "dashboard_mission_caption": "Tareas pendientes", "queue_segment": "Segmentar", "queue_segment_blurb": "Dividir páginas", @@ -795,7 +791,6 @@ "queue_review_blurb": "Controlar textos", "queue_n_open": "{n} pendiente", "queue_show_all": "Ver todo →", - "pulse_eyebrow": "Esta semana", "pulse_headline": "Habéis trabajado {pages} páginas.", "pulse_you": "Tú mismo has trabajado {pages} de ellas.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Fragmentos anotados", "pulse_reviewed": "Fragmentos transcritos", "pulse_uploaded": "Documentos subidos", - "feed_caption": "Comentarios y actividad", "feed_show_all": "Ver todo", "feed_for_you": "para ti", - "audit_action_text_saved": "guardó texto en", "audit_action_file_uploaded": "subió un archivo:", "audit_action_annotation_created": "creó una anotación en", "audit_action_comment_added": "comentó:", "audit_action_mention_created": "te mencionó en", - "dropzone_release": "Suelta para subir", - "chronik_page_title": "Actividades", "chronik_for_you_caption": "Para ti", "chronik_for_you_count": "{count} nuevas", @@ -859,9 +850,7 @@ "pagination_page_of": "Página {page} de {total}", "pagination_nav_label": "Paginación", "pagination_page_button": "Página {page}", - "common_opens_new_tab": "(abre en pestaña nueva)", - "transcribe_coach_title": "¿Primera transcripción?", "transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:", "transcribe_coach_step_1_title": "Dibujar un marco.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Se guarda automáticamente.", "transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗", "transcribe_coach_footer_richtlinien": "Normas de transcripción ↗", - "transcription_mode_help_label": "Modo lectura y edición", "transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.", - "richtlinien_title": "Normas de transcripción", "richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.", "richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.", "bulk_edit_topbar_title": "Edición masiva", "bulk_edit_count_pill": "Se editarán {count}", - "nav_stammbaum": "Árbol genealógico", "nav_geschichten": "Historias", - "error_geschichte_not_found": "No se encontró la historia.", - "geschichten_index_title": "Historias", "geschichten_new_button": "Nueva historia", "geschichten_filter_all_pill": "Todas", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Adjuntar historia", "geschichten_card_show_all_for_person": "Todas las historias sobre {name}", "geschichten_card_show_all": "Mostrar todas", - "geschichte_editor_title_placeholder": "Título de la historia", "geschichte_editor_body_placeholder": "Escribe tu historia aquí…", "geschichte_editor_status_draft": "BORRADOR", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Subencabezado", "geschichte_editor_toolbar_ul": "Lista con viñetas", "geschichte_editor_toolbar_ol": "Lista numerada", - "geschichte_delete_confirm_title": "¿Eliminar historia?", "geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.", - "error_relationship_not_found": "La relación no fue encontrada.", "error_circular_relationship": "Esta relación crearía un ciclo.", "error_duplicate_relationship": "Esta relación ya existe.", - "relation_parent_of": "Progenitor de", "relation_child_of": "Hijo/a de", "relation_spouse_of": "Cónyuge", @@ -1018,7 +998,6 @@ "relation_doctor": "Médico", "relation_neighbor": "Vecino/a", "relation_other": "Otro", - "relation_inferred_parent": "Progenitor", "relation_inferred_child": "Hijo/a", "relation_inferred_spouse": "Cónyuge", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Cuñado/a", "relation_inferred_cousin_1": "Primo/a", "relation_inferred_distant": "Pariente lejano", - "doc_details_field_relationship": "Parentesco", - "stammbaum_empty_heading": "Aún no hay miembros de la familia", "stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.", "stammbaum_empty_link": "→ Ir a la lista de personas", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Acercar", "stammbaum_zoom_out": "Alejar", "stammbaum_generations": "Generaciones", - "relation_error_duplicate": "Esta relación ya existe.", "relation_error_circular": "Esta relación crearía un ciclo.", "relation_error_self": "Una persona no puede estar relacionada consigo misma.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "Desde año", "relation_form_field_to_year": "Hasta año", "relation_form_year_placeholder": "ej. 1920", - "person_relationships_heading": "Relaciones", "person_relationships_empty": "Aún no se conocen relaciones.", - "timeline_aria_label": "Cronología de densidad de documentos", "timeline_clear_selection": "Borrar selección", "timeline_zoom_reset": "Restablecer zoom", "timeline_bar_aria_singular": "{when}, 1 documento", "timeline_bar_aria_plural": "{when}, {count} documentos", - "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado" + "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado", + "error_page_id_label": "ID de error", + "error_copy_id_label": "Copiar ID", + "error_copied": "¡Copiado!" } diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 6057acbc..82a7a981 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -26,6 +26,11 @@ declare global { interface PageData { user?: User; // Available in $page.data.user } + + interface Error { + message: string; + errorId?: string; + } } } diff --git a/frontend/src/hooks.client.test.ts b/frontend/src/hooks.client.test.ts new file mode 100644 index 00000000..46cb8ad2 --- /dev/null +++ b/frontend/src/hooks.client.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@sentry/sveltekit', () => ({ + init: vi.fn(), + handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn, + lastEventId: vi.fn(() => 'sentry-event-id-abc123') +})); + +describe('hooks.client handleError', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns Sentry lastEventId as errorId', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123'); + + const { handleError } = await import('./hooks.client'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: {}, + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toBe('sentry-event-id-abc123'); + expect(result.message).toBe('An unexpected error occurred'); + }); + + it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue(undefined); + + const { handleError } = await import('./hooks.client'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: {}, + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(result.message).toBe('An unexpected error occurred'); + }); +}); diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 91ed4b10..3cfcd365 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,10 +1,16 @@ import * as Sentry from '@sentry/sveltekit'; +// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip +// but cannot read them. Safe to include in the client bundle per Sentry security model. Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, - tracesSampleRate: 1.0, + tracesSampleRate: 0.1, + sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); -export const handleError = Sentry.handleErrorWithSentry(); +export const handleError = Sentry.handleErrorWithSentry(() => { + const errorId = Sentry.lastEventId() ?? crypto.randomUUID(); + return { message: 'An unexpected error occurred', errorId }; +}); diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts new file mode 100644 index 00000000..b24f2e16 --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@sentry/sveltekit', () => ({ + init: vi.fn(), + handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn, + lastEventId: vi.fn(() => 'sentry-event-id-abc123') +})); + +vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() })); +vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) })); +vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() })); +vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 })); +vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') })); + +const makeEvent = () => ({ + url: { pathname: '/documents/123' }, + locals: {} +}); + +describe('hooks.server handleError', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns Sentry lastEventId as errorId', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123'); + + const { handleError } = await import('./hooks.server'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: makeEvent(), + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toBe('sentry-event-id-abc123'); + expect(result.message).toBe('An unexpected error occurred'); + }); + + it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue(undefined); + + const { handleError } = await import('./hooks.server'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: makeEvent(), + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(result.message).toBe('An unexpected error occurred'); + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 4baaa592..2c155dce 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,10 +6,13 @@ import { env } from 'process'; import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; import { detectLocale } from '$lib/shared/server/locale'; +// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip +// but cannot read them. Safe to include in the client bundle per Sentry security model. Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, - tracesSampleRate: 1.0, + tracesSampleRate: 0.1, + sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); @@ -122,4 +125,7 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); -export const handleError = Sentry.handleErrorWithSentry(); +export const handleError = Sentry.handleErrorWithSentry(() => { + const errorId = Sentry.lastEventId() ?? crypto.randomUUID(); + return { message: 'An unexpected error occurred', errorId }; +}); diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index c64461e4..96f98e57 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -1,13 +1,53 @@ {m.page_title_error()} -
-

{page.status}

-

{page.error?.message ?? 'Internal Error'}

-
+
+

{m.page_title_error()}

+

+ {page.error?.message ?? m.error_internal_error()} +

+

{page.status}

+ + {#if page.error?.errorId} +
+

+ {m.error_page_id_label()} +

+ + {page.error.errorId} + + +
+ {/if} +
diff --git a/frontend/src/routes/api/tags/+server.ts b/frontend/src/routes/api/tags/+server.ts index 1e2e760d..f985380e 100644 --- a/frontend/src/routes/api/tags/+server.ts +++ b/frontend/src/routes/api/tags/+server.ts @@ -24,7 +24,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => { } const data = await response.json(); - console.log('Tags Data', data); // 4. Daten zurück an den Browser schicken return json(data); diff --git a/frontend/src/routes/error.svelte.test.ts b/frontend/src/routes/error.svelte.test.ts index 097dc3f6..0ceca01f 100644 --- a/frontend/src/routes/error.svelte.test.ts +++ b/frontend/src/routes/error.svelte.test.ts @@ -4,7 +4,10 @@ import { page as browserPage } from 'vitest/browser'; const mockPage = { status: 500, - error: { message: 'Internal Error' } as { message: string } | null + error: { message: 'Internal Error', errorId: undefined } as { + message: string; + errorId?: string; + } | null }; vi.mock('$app/state', () => ({ @@ -13,6 +16,16 @@ vi.mock('$app/state', () => ({ } })); +vi.mock('$lib/paraglide/messages.js', () => ({ + m: { + page_title_error: () => 'Es ist etwas schiefgelaufen.', + error_internal_error: () => 'Ein unerwarteter Fehler ist aufgetreten.', + error_page_id_label: () => 'Fehler-ID', + error_copy_id_label: () => 'ID kopieren', + error_copied: () => 'Kopiert!' + } +})); + afterEach(cleanup); async function loadComponent() { @@ -20,7 +33,7 @@ async function loadComponent() { } describe('+error.svelte', () => { - it('renders the page status code prominently', async () => { + it('renders the page status code', async () => { mockPage.status = 404; mockPage.error = { message: 'Not Found' }; @@ -40,13 +53,79 @@ describe('+error.svelte', () => { await expect.element(browserPage.getByText('Database unavailable')).toBeVisible(); }); - it('falls back to the literal "Internal Error" when page.error is null', async () => { + it('falls back to error_internal_error message when page.error is null', async () => { mockPage.status = 500; mockPage.error = null; const ErrorPage = await loadComponent(); render(ErrorPage); - await expect.element(browserPage.getByText('Internal Error')).toBeVisible(); + await expect + .element(browserPage.getByText('Ein unerwarteter Fehler ist aufgetreten.')) + .toBeVisible(); + }); + + it('shows errorId when page.error.errorId is set', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('abc-123-def')).toBeVisible(); + }); + + it('shows copy button when errorId is present', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByRole('button', { name: 'ID kopieren' })).toBeVisible(); + }); + + it('does not render errorId section when errorId is absent', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('Fehler-ID')).not.toBeInTheDocument(); + }); + + it('shows "Kopiert!" after clicking the copy button', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + writable: true + }); + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await browserPage.getByRole('button', { name: 'ID kopieren' }).click(); + await expect.element(browserPage.getByText('Kopiert!')).toBeVisible(); + }); + + it('does not show "Kopiert!" when clipboard write is rejected', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + configurable: true, + writable: true + }); + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await browserPage.getByRole('button', { name: 'ID kopieren' }).click(); + await expect.element(browserPage.getByText('Kopiert!')).not.toBeInTheDocument(); }); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4c75bc70..02f2cc48 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -71,7 +71,8 @@ export default defineConfig({ 'src/lib/shared/utils/**', 'src/lib/shared/server/**', 'src/lib/shared/discussion/**', - 'src/lib/document/**' + 'src/lib/document/**', + 'src/hooks.server.ts' ], exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'], thresholds: {