diff --git a/integrations/calendar/app/controller.ts b/integrations/calendar/app/controller.ts index 966bcba7..d2077566 100644 --- a/integrations/calendar/app/controller.ts +++ b/integrations/calendar/app/controller.ts @@ -34,6 +34,7 @@ import { type Outcome, type PendingEdit, } from './sync.js'; +import { errorStack, report } from './report.js'; /** What went wrong, in the terms the banners use (DESIGN.md ยง5.12). */ export interface Problem { @@ -412,7 +413,13 @@ export function createController( } } - const fail = (error: unknown, outcomes?: Outcome[]) => + const fail = (error: unknown, outcomes?: Outcome[]) => { + const problem = classify(error); + report('error', message(error), { + problem: problem.kind, + ...(problem.status ? { status: problem.status } : {}), + stack: errorStack(error), + }); set({ kind: 'error', message: message(error), @@ -420,10 +427,11 @@ export function createController( connections.length === 0 || (error as { reconnect?: boolean }).reconnect === true || spent(error), - problem: classify(error), + problem, ...(outcomes ? { outcomes } : {}), ...(last ? { summary: last.summary, at: last.at } : {}), }); + }; const reload = async () => { if (!meta) return; @@ -590,6 +598,19 @@ export function createController( ); stale = false; last = { summary, at: new Date() }; + report(summary.unreadable.length ? 'warn' : 'info', 'Sync finished', { + total: summary.total, + added: summary.added, + updated: summary.updated, + unchanged: summary.unchanged, + skipped: summary.skipped, + conflicts: summary.conflicts.length, + review: summary.review.length, + unreadable: summary.unreadable.map(({ id, reason }) => ({ + id, + reason, + })), + }); await reload(); set({ kind: 'ready', at: last.at, summary, outcomes: [] }); } catch (error) { diff --git a/integrations/calendar/app/main.ts b/integrations/calendar/app/main.ts index fb301c72..1d12ebd7 100644 --- a/integrations/calendar/app/main.ts +++ b/integrations/calendar/app/main.ts @@ -29,6 +29,7 @@ import { detail, editor } from './drawer.js'; import { busyDays, nextEvent, type CalEvent } from './events.js'; import { firstRun, importing, noRelay, picker } from './screens.js'; import { conflicts, review, shortcuts } from './sheets.js'; +import { reportUncaught } from './report.js'; import { anySkipped, notShown, sidebar } from './sidebar.js'; import type { ViewArgs } from './store.js'; import type { Choice, Conflict, ImportSummary } from './sync.js'; @@ -96,6 +97,7 @@ const EMPTY_SUMMARY: ImportSummary = { }; export async function view({ root, store }: ViewArgs): Promise { + reportUncaught(root.ownerDocument.defaultView ?? window); const doc = root.ownerDocument; const win = doc.defaultView!; installTheme(root, `${PLUGIN_CSS}\n${CALENDAR_CSS}`, store); diff --git a/integrations/calendar/app/report.test.ts b/integrations/calendar/app/report.test.ts new file mode 100644 index 00000000..767c5105 --- /dev/null +++ b/integrations/calendar/app/report.test.ts @@ -0,0 +1,31 @@ +// @wc-ignore-file +import { describe, expect, it, vi } from 'vitest'; +import { report } from './report.js'; + +describe('report', () => { + it('does nothing without a hook, as in every published build', () => { + expect(() => report('error', 'boom')).not.toThrow(); + expect(() => report('error', 'boom', {}, 'not a function')).not.toThrow(); + }); + + it('hands one entry to the hook a test build installs', () => { + const hook = vi.fn(); + + report('warn', 'Sync finished', { total: 3 }, hook); + + expect(hook).toHaveBeenCalledWith({ + source: 'calendar', + level: 'warn', + message: 'Sync finished', + total: 3, + }); + }); + + it('never throws when the hook does', () => { + const hook = () => { + throw new Error('collector down'); + }; + + expect(() => report('error', 'boom', {}, hook)).not.toThrow(); + }); +}); diff --git a/integrations/calendar/app/report.ts b/integrations/calendar/app/report.ts new file mode 100644 index 00000000..6261fe6a --- /dev/null +++ b/integrations/calendar/app/report.ts @@ -0,0 +1,62 @@ +// @wc-ignore-file +/** + * Hands this app's errors, and a line per finished sync, to a user-testing + * log hook, when one is installed. + * + * The app runs in a sandboxed frame, and the host shows an app's errors there + * without reporting them anywhere, so without this nobody but the person at + * the screen sees them. The app itself still makes no network request + * (build.test.ts checks the bundle for `fetch(`): the test catalog's build + * (ontola/atomic-plugins `usertest/catalog.mjs`) prepends a small prelude + * that defines `globalThis.__USERTEST_REPORT__` and posts to the collector. + * A published build has no prelude, so `report` does nothing there. + * + * Handed over: the error message and stack, the banner's problem kind and + * status, and per sync the counts plus the ids and reasons of unreadable + * events. Never: event titles, descriptions or other row content. + */ + +export type Level = 'error' | 'warn' | 'info'; + +type Hook = (entry: Record) => void; + +declare global { + var __USERTEST_REPORT__: Hook | undefined; +} + +export function report( + level: Level, + message: string, + data: Record = {}, + hook: unknown = globalThis.__USERTEST_REPORT__, +): void { + if (typeof hook !== 'function') return; + + try { + (hook as Hook)({ source: 'calendar', level, message, ...data }); + } catch { + // A broken hook never affects the app. + } +} + +export const errorStack = (error: unknown) => + error instanceof Error ? error.stack : undefined; + +/** Reports what escapes every handler. */ +export function reportUncaught(target: Window): void { + target.addEventListener('error', event => + report('error', event.message, { + kind: 'uncaught', + stack: errorStack(event.error), + }), + ); + target.addEventListener('unhandledrejection', event => + report( + 'error', + event.reason instanceof Error + ? event.reason.message + : String(event.reason), + { kind: 'unhandled-rejection', stack: errorStack(event.reason) }, + ), + ); +}