From 256c2412e0f0e4bec5cc38103b38ac8949d74005 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 27 Oct 2023 00:20:07 -0400 Subject: [PATCH] Add crash reporter --- README.md | 1 + src/crashReporter/index.ts | 75 ++++++++++++++++++++++ src/crashReporter/template.ts | 87 ++++++++++++++++++++++++++ src/global.d.ts | 4 ++ src/utils/jellyfin-apiclient/compat.ts | 31 ++++++--- webpack.common.js | 6 +- 6 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 src/crashReporter/index.ts create mode 100644 src/crashReporter/template.ts diff --git a/README.md b/README.md index 314bff0e01..7ce6c74444 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Jellyfin Web is the frontend used for most of the clients available for end user ├── components # Higher order visual components and React components ├── constants # Common constant values ├── controllers # Legacy page views and controllers 🧹 ❌ + ├── crashReporter # Script to send crash report logs to a connected server ├── elements # Basic webcomponents and React equivalents 🧹 ├── hooks # Custom React hooks ├── lib # Reusable libraries diff --git a/src/crashReporter/index.ts b/src/crashReporter/index.ts new file mode 100644 index 0000000000..b51fdb9495 --- /dev/null +++ b/src/crashReporter/index.ts @@ -0,0 +1,75 @@ +import { getClientLogApi } from '@jellyfin/sdk/lib/utils/api/client-log-api'; + +import ServerConnections from 'components/ServerConnections'; +import { getSDK, toApi } from 'utils/jellyfin-apiclient/compat'; + +import { buildLogTemplate } from './template'; + +/** Firefox supports additional properties on the Error object */ +interface NonstandardError extends Error { + fileName?: string + columnNumber?: number + lineNumber?: number +} + +interface OnUnhandledRejectionHandler { + (this: WindowEventHandlers, ev: PromiseRejectionEvent): void +} + +const initialTime = Date.now(); + +const reporter: OnErrorEventHandler = ( + event, + source, + lineno, + colno, + error +) => { + const apiClient = window.ApiClient ?? ServerConnections.currentApiClient(); + + if (!apiClient) { + console.warn('[crash reporter] no api client; unable to report crash', { + event, + source, + lineno, + colno, + error + }); + return; + } + + const jellyfin = getSDK(apiClient); + + const log = buildLogTemplate(jellyfin, { + initialTime + }, { + event, + source, + lineno, + colno, + error + }); + + if (__WEBPACK_SERVE__) { + console.error('[crash reporter] crash report not submitted in dev server', log); + return; + } + + console.debug('[crash reporter] submitting crash report', log); + getClientLogApi(toApi(apiClient)) + .logFile({ + body: log + }) + .catch(err => { + console.error('[crash reporter] failed to submit crash log', err, log); + }); +}; + +const rejectionReporter: OnUnhandledRejectionHandler = (event) => { + const error = event.reason as NonstandardError; + const message = event.reason as string; + reporter(error.message ?? message, error.fileName, error.lineNumber, error.columnNumber, error); +}; + +window.onerror = reporter; +window.onunhandledrejection = rejectionReporter; diff --git a/src/crashReporter/template.ts b/src/crashReporter/template.ts new file mode 100644 index 0000000000..1bd134af77 --- /dev/null +++ b/src/crashReporter/template.ts @@ -0,0 +1,87 @@ +import type { Jellyfin } from '@jellyfin/sdk'; + +import browser from 'scripts/browser'; + +import pkg from '../../package.json'; + +interface CrashContext { + initialTime: number +} + +interface CrashDetails { + event: Event | string, + source?: string, + lineno?: number, + colno?: number, + error?: Error +} + +const buildDetailsTemplate = ( + message: string, + details: CrashDetails +) => { + const templates: string[] = []; + if (details.error?.name) templates.push(`***Name***: \`${details.error.name}\``); + templates.push(`***Message***: \`${message}\``); + if (details.source) templates.push(`***Source***: \`${details.source}\``); + if (details.lineno) templates.push(`***Line number***: \`${details.lineno}\``); + if (details.colno) templates.push(`***Column number***: \`${details.colno}\``); + + return templates.join('\n'); +}; + +export const buildLogTemplate = ( + jellyfin: Jellyfin, + context: CrashContext, + details: CrashDetails +) => { + const event = details.event as Event; + const message = (details.event as string) ?? details.error?.message; + const startTime = new Date(context.initialTime); + const crashTime = event?.timeStamp ? new Date(context.initialTime + event.timeStamp) : new Date(); + + return `--- +client: ${pkg.name} +client_version: ${pkg.version} +client_repository: ${pkg.repository} +type: crash_report +format: markdown +--- + +### Logs + +${buildDetailsTemplate(message, details)} +***Stack Trace***: +\`\`\`log +${details.error?.stack} +\`\`\` + +### App information + +***App name***: \`${jellyfin.clientInfo.name}\` +***App version***: \`${jellyfin.clientInfo.version}\` +***Package name***: \`${pkg.name}\` +***Package config***: +\`\`\`json +${JSON.stringify(pkg)} +\`\`\` +***Build options****: +| Option | Value | +|----------------------|-------------------------| +| __USE_SYSTEM_FONTS__ | ${__USE_SYSTEM_FONTS__} | +| __WEBPACK_SERVE__ | ${__WEBPACK_SERVE__} | + +### Device information + +***Device name***: \`${jellyfin.deviceInfo.name}\` +***Browser information***: +\`\`\`json +${JSON.stringify(browser)} +\`\`\` + +### Crash information + +***Start time***: \`${startTime.toISOString()}\` +***Crash time***: \`${crashTime.toISOString()}\` +`; +}; diff --git a/src/global.d.ts b/src/global.d.ts index 83fce94a76..60ebc47783 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,6 +1,10 @@ export declare global { import { ApiClient, Events } from 'jellyfin-apiclient'; + // Globals declared in webpack + declare const __USE_SYSTEM_FONTS__: boolean; + declare const __WEBPACK_SERVE__: boolean; + interface Window { ApiClient: ApiClient; Events: Events; diff --git a/src/utils/jellyfin-apiclient/compat.ts b/src/utils/jellyfin-apiclient/compat.ts index 444dbee45f..1b884dcdde 100644 --- a/src/utils/jellyfin-apiclient/compat.ts +++ b/src/utils/jellyfin-apiclient/compat.ts @@ -1,13 +1,14 @@ -import { Api, Jellyfin } from '@jellyfin/sdk'; -import { ApiClient } from 'jellyfin-apiclient'; +import type { Api } from '@jellyfin/sdk'; +import { Jellyfin } from '@jellyfin/sdk/lib/jellyfin'; +import { type ApiClient } from 'jellyfin-apiclient'; /** - * Returns an SDK Api instance using the same parameters as the provided ApiClient. + * Returns an SDK instance from the configuration of an ApiClient instance. * @param {ApiClient} apiClient The (legacy) ApiClient. - * @returns {Api} An equivalent SDK Api instance. + * @returns {Jellyfin} An instance of the Jellyfin SDK. */ -export const toApi = (apiClient: ApiClient): Api => { - return (new Jellyfin({ +export const getSDK = (apiClient: ApiClient): Jellyfin => ( + new Jellyfin({ clientInfo: { name: apiClient.appName(), version: apiClient.appVersion() @@ -16,8 +17,18 @@ export const toApi = (apiClient: ApiClient): Api => { name: apiClient.deviceName(), id: apiClient.deviceId() } - })).createApi( - apiClient.serverAddress(), - apiClient.accessToken() - ); + }) +); + +/** + * Returns an SDK Api instance using the same parameters as the provided ApiClient. + * @param {ApiClient} apiClient The (legacy) ApiClient. + * @returns {Api} An equivalent SDK Api instance. + */ +export const toApi = (apiClient: ApiClient): Api => { + return getSDK(apiClient) + .createApi( + apiClient.serverAddress(), + apiClient.accessToken() + ); }; diff --git a/webpack.common.js b/webpack.common.js index b66f0b7611..a8fed28436 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -45,7 +45,8 @@ const config = { target: 'browserslist', entry: { 'main.jellyfin': './index.jsx', - ...THEMES_BY_ID + ...THEMES_BY_ID, + 'crashReporter': './crashReporter/index.ts' }, resolve: { extensions: ['.tsx', '.ts', '.js'], @@ -74,7 +75,8 @@ const config = { hash: true, chunks: [ 'main.jellyfin', - 'serviceworker' + 'serviceworker', + 'crashReporter' ] }), new CopyPlugin({