diff --git a/build-scripts/webpack.cjs b/build-scripts/webpack.cjs index 13b7c75c6a..efed17792f 100644 --- a/build-scripts/webpack.cjs +++ b/build-scripts/webpack.cjs @@ -202,7 +202,10 @@ const createWebpackConfig = ({ !existsSync(info.resourcePath) || info.resourcePath.startsWith("./node_modules") ) { - return new URL(info.resourcePath, "webpack://frontend/").href; + // Source URLs are unknown for dependencies, so we use a relative URL with a + // non - existent top directory. This results in a clean source tree in browser + // dev tools, and they stay happy getting 404s with valid requests. + return `/unknown${path.resolve("/", info.resourcePath)}`; } return new URL(info.resourcePath, bundle.sourceMapURL()).href; } diff --git a/package.json b/package.json index 1a2766802f..d8c69d9767 100644 --- a/package.json +++ b/package.json @@ -133,10 +133,12 @@ "roboto-fontface": "0.10.0", "rrule": "2.7.2", "sortablejs": "1.15.0", + "stacktrace-js": "2.0.2", "superstruct": "1.0.3", "tinykeys": "2.1.0", "tsparticles-engine": "2.12.0", "tsparticles-preset-links": "2.12.0", + "ua-parser-js": "1.0.35", "unfetch": "5.0.0", "vis-data": "7.1.6", "vis-network": "9.1.6", @@ -182,6 +184,7 @@ "@types/serve-handler": "6.1.1", "@types/sortablejs": "1.15.1", "@types/tar": "6.1.5", + "@types/ua-parser-js": "0.7.36", "@types/webspeechapi": "0.0.29", "@typescript-eslint/eslint-plugin": "6.4.0", "@typescript-eslint/parser": "6.4.0", diff --git a/src/data/system_log.ts b/src/data/system_log.ts index 61b02df458..593fcba0d6 100644 --- a/src/data/system_log.ts +++ b/src/data/system_log.ts @@ -1,15 +1,21 @@ -import { HomeAssistant, TranslationDict } from "../types"; +import { HomeAssistant } from "../types"; + +export type SystemLogLevel = + | "critical" + | "error" + | "warning" + | "info" + | "debug"; export interface LoggedError { name: string; message: [string]; - level: keyof TranslationDict["ui"]["panel"]["config"]["logs"]["level"]; + level: SystemLogLevel; source: [string, number]; - // unix timestamp in seconds - timestamp: number; exception: string; count: number; - // unix timestamp in seconds + // unix timestamps in seconds + timestamp: number; first_occurred: number; } diff --git a/src/entrypoints/core.ts b/src/entrypoints/core.ts index d9467f11ca..8cd2671645 100644 --- a/src/entrypoints/core.ts +++ b/src/entrypoints/core.ts @@ -25,7 +25,6 @@ import { subscribeUser } from "../data/ws-user"; import type { ExternalAuth } from "../external_app/external_auth"; import "../resources/array.flat.polyfill"; import "../resources/safari-14-attachshadow-patch"; -import { HomeAssistant } from "../types"; import { MAIN_WINDOW_NAME } from "../data/main_window"; window.name = MAIN_WINDOW_NAME; @@ -132,32 +131,3 @@ window.hassConnection.then(({ conn }) => { llWindow.llResProm = fetchResources(conn); } }); - -window.addEventListener("error", (e) => { - if ( - !__DEV__ && - typeof e.message === "string" && - (e.message.includes("ResizeObserver loop limit exceeded") || - e.message.includes( - "ResizeObserver loop completed with undelivered notifications" - )) - ) { - e.preventDefault(); - e.stopImmediatePropagation(); - e.stopPropagation(); - return; - } - const homeAssistant = document.querySelector("home-assistant") as any; - if ( - homeAssistant && - homeAssistant.hass && - (homeAssistant.hass as HomeAssistant).callService - ) { - homeAssistant.hass.callService("system_log", "write", { - logger: `frontend.${ - __DEV__ ? "js_dev" : "js" - }.${__BUILD__}.${__VERSION__.replace(".", "")}`, - message: `${e.filename}:${e.lineno}:${e.colno} ${e.message}`, - }); - } -}); diff --git a/src/resources/log-message.ts b/src/resources/log-message.ts new file mode 100644 index 0000000000..53237ffbac --- /dev/null +++ b/src/resources/log-message.ts @@ -0,0 +1,72 @@ +import "core-js/modules/web.url.can-parse"; +import { fromError } from "stacktrace-js"; +import { UAParser } from "ua-parser-js"; + +// URL paths to remove from filenames and max stack trace lines for brevity +const REMOVAL_PATHS = + /^\/(?:home-assistant\/frontend\/[^/]+|unknown|\/{2}\.)\//; +const MAX_STACK_FRAMES = 10; + +export const createLogMessage = async ( + error: unknown, + intro?: string, + messageFallback?: string, + stackFallback?: string +) => { + const lines: (string | undefined)[] = []; + // Append the originating browser/OS to any intro for easier identification + if (intro) { + const parser = new UAParser(); + const { + name: browserName = "unknown browser", + version: browserVersion = "", + } = parser.getBrowser(); + const { name: osName = "unknown OS", version: osVersion = "" } = + parser.getOS(); + const browser = `${browserName} ${browserVersion}`.trim(); + const os = `${osName} ${osVersion}`.trim(); + lines.push(`${intro} from ${browser} on ${os}`); + } + // In most cases, an Error instance will be thrown, which can have many details to log: + // - a standard string coercion to "ErrorType: Message" + // - a stack added by browsers (which must be converted to original source) + // - an optional cause chain + // - a possible list of aggregated errors + if (error instanceof Error) { + lines.push(error.toString() || messageFallback); + const stackLines = (await fromError(error)) + .slice(0, MAX_STACK_FRAMES) + .map((frame) => { + frame.fileName ??= ""; + // @ts-expect-error canParse not in DOM library yet + if (URL.canParse(frame.fileName)) { + frame.fileName = new URL(frame.fileName).pathname; + } + frame.fileName = frame.fileName.replace(REMOVAL_PATHS, ""); + return frame.toString(); + }); + lines.push(...(stackLines.length > 0 ? stackLines : [stackFallback])); + // @ts-expect-error Requires library bump to ES2022 + if (error.cause) { + // @ts-expect-error Requires library bump to ES2022 + lines.push(`Caused by: ${await createLogMessage(error.cause)}`); + } + if (error instanceof AggregateError) { + const subMessageEntries = error.errors.map( + async (e, i) => [i, await createLogMessage(e)] as const + ); + for await (const [i, m] of subMessageEntries) { + lines.push(`Part ${i + 1} of ${error.errors.length}: ${m}`); + } + } + } else { + // The error could be anything, so just stringify it and log with fallbacks + const errorString = JSON.stringify(error, null, 2); + lines.push( + messageFallback, + errorString === messageFallback ? "" : errorString, + stackFallback + ); + } + return lines.filter(Boolean).join("\n"); +}; diff --git a/src/state/logging-mixin.ts b/src/state/logging-mixin.ts index 1d1a232e31..677ccf45b0 100644 --- a/src/state/logging-mixin.ts +++ b/src/state/logging-mixin.ts @@ -1,9 +1,10 @@ import { HASSDomEvent } from "../common/dom/fire_event"; +import { SystemLogLevel } from "../data/system_log"; import { Constructor } from "../types"; import { HassBaseEl } from "./hass-base-mixin"; interface WriteLogParams { - level?: "debug" | "info" | "warning" | "error" | "critical"; + level?: SystemLogLevel; message: string; } @@ -21,6 +22,44 @@ export const loggingMixin = >( superClass: T ) => class extends superClass { + protected hassConnected() { + super.hassConnected(); + window.addEventListener("error", async (ev) => { + if ( + !__DEV__ && + (ev.message.includes("ResizeObserver loop limit exceeded") || + ev.message.includes( + "ResizeObserver loop completed with undelivered notifications" + )) + ) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + ev.stopPropagation(); + return; + } + const { createLogMessage } = await import("../resources/log-message"); + this._writeLog({ + // The error object from browsers includes the message and a stack trace, + // so use the data in the error event just as fallback + message: await createLogMessage( + ev.error, + "Uncaught error", + ev.message, + `@${ev.filename}:${ev.lineno}:${ev.colno}` + ), + }); + }); + window.addEventListener("unhandledrejection", async (ev) => { + const { createLogMessage } = await import("../resources/log-message"); + this._writeLog({ + message: await createLogMessage( + ev.reason, + "Unhandled promise rejection" + ), + }); + }); + } + protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this.addEventListener("write_log", (ev) => { diff --git a/yarn.lock b/yarn.lock index 141c3e1f47..19aced3cb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4602,6 +4602,13 @@ __metadata: languageName: node linkType: hard +"@types/ua-parser-js@npm:0.7.36": + version: 0.7.36 + resolution: "@types/ua-parser-js@npm:0.7.36" + checksum: 8c24d4dc12ed1b8b98195838093391c358c81bf75e9cae0ecec8f7824b441e069daaa17b974a3e257172caddb671439f0c0b44bf43bfcf409b7a574a25aab948 + languageName: node + linkType: hard + "@types/webspeechapi@npm:0.0.29": version: 0.0.29 resolution: "@types/webspeechapi@npm:0.0.29" @@ -7753,6 +7760,15 @@ __metadata: languageName: node linkType: hard +"error-stack-parser@npm:^2.0.6": + version: 2.1.4 + resolution: "error-stack-parser@npm:2.1.4" + dependencies: + stackframe: ^1.3.4 + checksum: 3b916d2d14c6682f287c8bfa28e14672f47eafe832701080e420e7cdbaebb2c50293868256a95706ac2330fe078cf5664713158b49bc30d7a5f2ac229ded0e18 + languageName: node + linkType: hard + "es-abstract@npm:^1.19.0, es-abstract@npm:^1.20.4, es-abstract@npm:^1.21.2": version: 1.22.1 resolution: "es-abstract@npm:1.22.1" @@ -9700,6 +9716,7 @@ __metadata: "@types/serve-handler": 6.1.1 "@types/sortablejs": 1.15.1 "@types/tar": 6.1.5 + "@types/ua-parser-js": 0.7.36 "@types/webspeechapi": 0.0.29 "@typescript-eslint/eslint-plugin": 6.4.0 "@typescript-eslint/parser": 6.4.0 @@ -9790,6 +9807,7 @@ __metadata: sinon: 15.2.0 sortablejs: 1.15.0 source-map-url: 0.4.1 + stacktrace-js: 2.0.2 superstruct: 1.0.3 systemjs: 6.14.2 tar: 6.1.15 @@ -9799,6 +9817,7 @@ __metadata: tsparticles-engine: 2.12.0 tsparticles-preset-links: 2.12.0 typescript: 5.1.6 + ua-parser-js: 1.0.35 unfetch: 5.0.0 vinyl-buffer: 1.0.1 vinyl-source-stream: 2.0.0 @@ -14500,6 +14519,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:0.5.6": + version: 0.5.6 + resolution: "source-map@npm:0.5.6" + checksum: 390b3f5165c9631a74fb6fb55ba61e62a7f9b7d4026ae0e2bfc2899c241d71c1bccb8731c496dc7f7cb79a5f523406eb03d8c5bebe8448ee3fc38168e2d209c8 + languageName: node + linkType: hard + "source-map@npm:^0.5.6": version: 0.5.7 resolution: "source-map@npm:0.5.7" @@ -14623,6 +14649,15 @@ __metadata: languageName: node linkType: hard +"stack-generator@npm:^2.0.5": + version: 2.0.10 + resolution: "stack-generator@npm:2.0.10" + dependencies: + stackframe: ^1.3.4 + checksum: 4fc3978a934424218a0aa9f398034e1f78153d5ff4f4ff9c62478c672debb47dd58de05b09fc3900530cbb526d72c93a6e6c9353bacc698e3b1c00ca3dda0c47 + languageName: node + linkType: hard + "stack-trace@npm:0.0.10": version: 0.0.10 resolution: "stack-trace@npm:0.0.10" @@ -14630,6 +14665,34 @@ __metadata: languageName: node linkType: hard +"stackframe@npm:^1.3.4": + version: 1.3.4 + resolution: "stackframe@npm:1.3.4" + checksum: bae1596873595c4610993fa84f86a3387d67586401c1816ea048c0196800c0646c4d2da98c2ee80557fd9eff05877efe33b91ba6cd052658ed96ddc85d19067d + languageName: node + linkType: hard + +"stacktrace-gps@npm:^3.0.4": + version: 3.1.2 + resolution: "stacktrace-gps@npm:3.1.2" + dependencies: + source-map: 0.5.6 + stackframe: ^1.3.4 + checksum: 85daa232d138239b6ae0f4bcdd87d15d302a045d93625db17614030945b5314e204b5fbcf9bee5b6f4f9e6af5fca05f65c27fe910894b861ef6853b99470aa1c + languageName: node + linkType: hard + +"stacktrace-js@npm:2.0.2": + version: 2.0.2 + resolution: "stacktrace-js@npm:2.0.2" + dependencies: + error-stack-parser: ^2.0.6 + stack-generator: ^2.0.5 + stacktrace-gps: ^3.0.4 + checksum: 081e786d56188ac04ac6604c09cd863b3ca2b4300ec061366cf68c3e4ad9edaa34fb40deea03cc23a05f442aa341e9171f47313f19bd588f9bec6c505a396286 + languageName: node + linkType: hard + "static-extend@npm:^0.1.1": version: 0.1.2 resolution: "static-extend@npm:0.1.2" @@ -15581,6 +15644,13 @@ __metadata: languageName: node linkType: hard +"ua-parser-js@npm:1.0.35": + version: 1.0.35 + resolution: "ua-parser-js@npm:1.0.35" + checksum: 02370d38a0c8b586f2503d1c3bbba5cbc0b97d407282f9023201a99e4c03eae4357a2800fdf50cf80d73ec25c0b0cc5bfbaa03975b0add4043d6e4c86712c9c1 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2"