// The client log's app-wide hooks, installed once from main.tsx before anything renders: // the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the // user's consented level is raised — which control was used and what the tab itself went // through (hidden/shown, restored from the back-forward cache, resized). // // Navigation has no single choke point in this router-free app — `setPage`, the channel and search // overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` / // `replaceState` themselves (lib/history.ts, NavigationProvider, ChannelsProvider, …). Instrumenting // each would be one more thing a new call site can forget, so the History methods themselves are // wrapped here, and `popstate` covers Back/Forward. A replaceState that only saves a scroll position // changes nothing this logs, so it is not an event. import { FRONTEND_SHA, FRONTEND_VERSION } from "./version"; import { enabled, errorAttrs, log } from "./log"; import { pathTemplate } from "./http"; // URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at // TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its // value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys). const TYPED_PARAMS = new Set(); type Via = "start" | "push" | "replace" | "pop"; /** What a navigation event records of where the tab is now. Built from `history.state`'s own view * keys and the query, never the full URL (a `#reset=` fragment, a `?token=` link). */ function viewAttrs(): Record { const st = (window.history.state ?? {}) as Record; const params = new URLSearchParams(window.location.search); const view: Record = { path: pathTemplate(window.location.pathname), page: typeof st.sfPage === "string" ? st.sfPage : null, params: [...new Set(params.keys())].sort(), overlay: st._ov != null, channel: st._chan != null, search: st._yt != null, }; if (enabled("DEBUG")) { const values: Record = {}; for (const [k, v] of params) { if (!TYPED_PARAMS.has(k) || enabled("TRACE")) values[k] = v; } view.values = values; if (typeof st._sub === "string") view.sub = st._sub; } if (enabled("TRACE") && typeof st._yt === "string") view.query = st._yt; return view; } /** A script's path (`/assets/index-B1x9.js`): the bundle name is what finds the fault, and a script * URL carries no user data — but a malformed one is dropped rather than logged raw. */ function scriptPath(filename: string): string { try { return new URL(filename, window.location.href).pathname; } catch { return "?"; } } let lastView = ""; function recordView(via: Via): void { const view = viewAttrs(); const signature = JSON.stringify(view); if (via !== "start" && signature === lastView) return; lastView = signature; log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view }); } // What counts as a control for click capture: anything a user operates. const CONTROL = [ "button", "a[href]", "input", "select", "textarea", "summary", ...[ "button", "link", "tab", "menuitem", "menuitemradio", "menuitemcheckbox", "option", "switch", "checkbox", "radio", ].map((role) => `[role="${role}"]`), ].join(", "); const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const; type AppCopy = (text: string | null) => string | null; /** Which control this is — never what it says, unless what it says is the app's own copy. * Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the * English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag, * role and input type, its aria state, whether a checkbox is checked, and for a link the path * TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */ function controlAttrs(el: Element, appCopy: AppCopy): Record { const attrs: Record = { tag: el.tagName.toLowerCase() }; const role = el.getAttribute("role"); if (role) attrs.role = role; const testId = el.getAttribute("data-testid"); if (testId) attrs.control = testId; else { const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid"); if (within) attrs.within = within; } const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find( (c) => c.startsWith("lucide-") && c !== "lucide-icon", ); if (icon) attrs.icon = icon.slice("lucide-".length); const label = appCopy(el.getAttribute("aria-label")) ?? appCopy(el.textContent) ?? appCopy(el.getAttribute("title")); if (label) attrs.label = label; if (el instanceof HTMLInputElement) { attrs.type = el.type; if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked; } for (const name of ARIA_STATE) { const value = el.getAttribute(name); if (value !== null) attrs[name.slice("aria-".length)] = value; } if (el instanceof HTMLAnchorElement) { attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external"; } const page = (window.history.state as Record | null)?.sfPage; if (typeof page === "string") attrs.page = page; return attrs; } function installDetailHooks(appCopy: AppCopy): void { // Capture phase, so a handler that stops propagation cannot hide the click from the log. document.addEventListener( "click", (e) => { if (!enabled("DEBUG") || !(e.target instanceof Element)) return; const control = e.target.closest(CONTROL); if (!control) return; const attrs = controlAttrs(control, appCopy); const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag; log.debug("ui.click", `click ${String(name)}`, attrs); }, true, ); document.addEventListener("visibilitychange", () => { log.debug("app.visibility", document.visibilityState, { state: document.visibilityState }); }); window.addEventListener("pageshow", (e) => { if (e.persisted) log.debug("app.restored", "restored from the back-forward cache"); }); let resizeTimer: number | null = null; window.addEventListener("resize", () => { if (!enabled("DEBUG")) return; if (resizeTimer !== null) window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => { resizeTimer = null; const viewport = `${window.innerWidth}x${window.innerHeight}`; log.debug("app.resize", viewport, { viewport }); }, 500); }); // Connectivity is worth a line at the base level: it explains a burst of failed requests. window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false })); window.addEventListener("online", () => log.info("app.network", "online", { online: true })); } let installed = false; /** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. * `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this * module stays free of the i18n runtime. */ export function installClientLog( appCopy: AppCopy = () => null, typedParams: readonly string[] = [], ): void { if (installed) return; installed = true; for (const k of typedParams) TYPED_PARAMS.add(k); log.info("app.start", "app loaded", { version: FRONTEND_VERSION, sha: FRONTEND_SHA, user_agent: navigator.userAgent, language: navigator.language, viewport: `${window.innerWidth}x${window.innerHeight}`, dpr: window.devicePixelRatio, }); window.addEventListener("error", (e) => { // A failed /