Skip to content

Commit

Permalink
Renderer errors log to disk and exceptions during rendering show UI
Browse files Browse the repository at this point in the history
  • Loading branch information
p-jackson committed Jan 18, 2024
1 parent 8076b1a commit b723ae6
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 42 deletions.
15 changes: 9 additions & 6 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { SiteDetailsProvider } from '../hooks/use-site-details';
import { SiteList } from './site-list';
import SiteList from './site-list';
import CreateSiteButton from './create-site-button';
import ErrorBoundary from './error-boundary';

export default function App() {
return (
<div className="p-8">
<SiteDetailsProvider>
<CreateSiteButton className="mb-6" />
<SiteList />
</SiteDetailsProvider>
<div className="relative p-8 min-h-screen">
<ErrorBoundary>
<SiteDetailsProvider>
<CreateSiteButton className="mb-6" />
<SiteList />
</SiteDetailsProvider>
</ErrorBoundary>
</div>
);
}
14 changes: 14 additions & 0 deletions src/components/default-error-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Button from './button';

export default function DefaultErrorFallback() {
return (
<div className="flex flex-col items-center gap-4 text-center">
<h1 className="text-2xl font-light">Something went wrong 😭</h1>
<p className="max-w-md">
We've logged the issue to help us track down the problem. Reloading <i>might</i> help in the
meantime.
</p>
<Button onClick={ () => window.location.reload() }>Reload</Button>
</div>
);
}
28 changes: 28 additions & 0 deletions src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Component } from 'react';
import DefaultErrorFallback from './default-error-fallback';

interface ErrorLoggerProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}

export default class ErrorBoundary extends Component< ErrorLoggerProps > {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch( error: Error, info: React.ErrorInfo ) {
// Error will be written to log by the main process
console.error( error, info.componentStack );
}

render() {
if ( this.state.hasError ) {
return this.props.fallback || <DefaultErrorFallback />;
}

return this.props.children;
}
}
2 changes: 1 addition & 1 deletion src/components/site-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useSiteDetails } from '../hooks/use-site-details';
import LinkButton from './link-button';
import StatusLed from './status-led';

export function SiteList() {
export default function SiteList() {
const { data, startServer, stopServer } = useSiteDetails();

if ( ! data?.length ) {
Expand Down
8 changes: 8 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}

h1, h2, h3, h4, h5, h6, caption, figcaption {
text-wrap: balance;
}

p, ul, ol, blockquote {
text-wrap: pretty;
}
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function validateIpcSender( event: IpcMainInvokeEvent ) {

function setupIpc() {
for ( const [ key, handler ] of Object.entries( ipcHandlers ) ) {
if ( typeof handler === 'function' ) {
if ( typeof handler === 'function' && key !== 'logRendererMessage' ) {
ipcMain.handle( key, function ( event, ...args ) {
try {
validateIpcSender( event );
Expand All @@ -96,6 +96,20 @@ function setupIpc() {
}
} );
}

// logRendererMessage is handled specially because it uses the (hopefully more efficient)
// fire-and-forget .send method instead of .invoke
if ( typeof handler === 'function' && key === 'logRendererMessage' ) {
ipcMain.on( key, function ( event, level, ...args ) {
try {
validateIpcSender( event );
handler( event, level, ...args );
} catch ( error ) {
console.error( error );
throw error;
}
} );
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { loadUserData, saveUserData } from './storage/user-data';
import { SiteServer, createSiteWorkingDirectory } from './site-server';
import nodePath from 'path';
import crypto from 'crypto';
import { writeLogToFile, type LogLevel } from './logging';

// IPC functions must accept an `event` as the first argument.
/* eslint @typescript-eslint/no-unused-vars: ["warn", { "argsIgnorePattern": "event" }] */
Expand Down Expand Up @@ -108,3 +109,13 @@ export async function showOpenFolderDialog(

return filePaths[ 0 ];
}

export function logRendererMessage(
event: IpcMainInvokeEvent,
level: LogLevel,
...args: any[]
): void {
// 4 characters long so it aligns with the main process logs
const processId = `ren${ event.sender.id }`;
writeLogToFile( level, processId, ...args );
}
85 changes: 51 additions & 34 deletions src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,10 @@ import { app } from 'electron';
import path from 'path';
import * as FileStreamRotator from 'file-stream-rotator';

const originalWarn = console.warn;
const makeLogger =
( level: string, originalLogger: typeof console.log, write: ( str: string ) => void ) =>
( ...args: Parameters< typeof console.log > ) => {
const [ message ] = args;
let logStream: ReturnType< typeof FileStreamRotator.getStream > | null = null;

if ( typeof message === 'string' && message.includes( '%' ) ) {
originalWarn(
`[${ new Date().toISOString() }][${ level }] Attempting to log string with placeholders which isn't supported\n`
);
}

const stringifiedArgs = args.map( formatLogMessageArg ).join( ' ' );

write( `[${ new Date().toISOString() }][${ level }] ${ stringifiedArgs }\n` );
originalLogger( ...args );
};

function formatLogMessageArg( arg: unknown ): string {
if ( [ 'string', 'number', 'boolean' ].includes( typeof arg ) ) {
return `${ arg }`;
}

if ( arg instanceof Error ) {
return `${ arg.stack || arg }`;
}

return JSON.stringify( arg, null, 2 );
}
// Intentional typo of 'erro' so all levels the same number of characters
export type LogLevel = 'info' | 'warn' | 'erro';

export function setupLogging() {
// Set the logging path to the default for the platform.
Expand All @@ -41,7 +16,7 @@ export function setupLogging() {
// In the release build logs will be written to ~/Library/Logs/Local Environment/*.log
const logDir = app.getPath( 'logs' );

const logStream = FileStreamRotator.getStream( {
logStream = FileStreamRotator.getStream( {
filename: path.join( logDir, 'local-environment-%DATE%' ),
date_format: 'YYYYMMDD',
frequency: 'daily',
Expand All @@ -54,19 +29,61 @@ export function setupLogging() {
verbose: true, // file-stream-rotator itself will log to console too
} );

console.log = makeLogger( 'info', console.log, logStream.write.bind( logStream ) );
console.warn = makeLogger( 'warn', console.warn, logStream.write.bind( logStream ) );
console.error = makeLogger( 'erro', console.error, logStream.write.bind( logStream ) ); // Intentional typo so it's the same char-length as the other log levels
const makeLogger =
( level: LogLevel, originalLogger: typeof console.log ) =>
( ...args: Parameters< typeof console.log > ) => {
writeLogToFile( level, 'main', ...args );
originalLogger( ...args );
};

console.log = makeLogger( 'info', console.log.bind( console ) );
console.warn = makeLogger( 'warn', console.warn.bind( console ) );
console.error = makeLogger( 'erro', console.error.bind( console ) );

process.on( 'exit', () => {
logStream.end( 'App is terminating' );
logStream?.end( `[${ new Date().toISOString() }][info][main] App is terminating` );
} );

// Handle Ctrl+C (SIGINT) to gracefully close the log stream
process.on( 'SIGINT', () => {
logStream.end( 'App was terminated by SIGINT' );
logStream?.end( `[${ new Date().toISOString() }][info][main] App was terminated by SIGINT` );
process.exit();
} );

console.log( 'Starting new session' );
}

const originalWarningLog = console.warn;
export function writeLogToFile(
level: LogLevel,
processId: string,
...args: Parameters< typeof console.log >
) {
const [ message ] = args;

if ( typeof message === 'string' && message.includes( '%' ) ) {
const unsupportedPlaceholdersMessage =
"Attempted to log a string using placeholders, which isn't supported";
logStream?.write(
`[${ new Date().toISOString() }][warn][main] ${ unsupportedPlaceholdersMessage }\n`
);
originalWarningLog( unsupportedPlaceholdersMessage );
}

const stringifiedArgs = args.map( formatLogMessageArg ).join( ' ' );
logStream?.write(
`[${ new Date().toISOString() }][${ level }][${ processId }] ${ stringifiedArgs }\n`
);
}

function formatLogMessageArg( arg: unknown ): string {
if ( [ 'string', 'number', 'boolean' ].includes( typeof arg ) ) {
return `${ arg }`;
}

if ( arg instanceof Error ) {
return `${ arg.stack || arg }`;
}

return JSON.stringify( arg, null, 2 );
}
5 changes: 5 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts

import { contextBridge, ipcRenderer } from 'electron';
import type { LogLevel } from './logging';
import type * as ipcHandlers from './ipc-handlers';

const api: Record< keyof typeof ipcHandlers, ( ...args: any[] ) => any > = {
Expand All @@ -10,6 +11,10 @@ const api: Record< keyof typeof ipcHandlers, ( ...args: any[] ) => any > = {
showOpenFolderDialog: ( title: string ) => ipcRenderer.invoke( 'showOpenFolderDialog', title ),
startServer: ( id: string ) => ipcRenderer.invoke( 'startServer', id ),
stopServer: ( id: string ) => ipcRenderer.invoke( 'stopServer', id ),

// Use .send instead of .invoke because logging is fire-and-forget
logRendererMessage: ( level: LogLevel, ...args: any[] ) =>
ipcRenderer.send( 'logRendererMessage', level, ...args ),
};

contextBridge.exposeInMainWorld( 'ipcApi', api );
33 changes: 33 additions & 0 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,41 @@
import { createRoot } from 'react-dom/client';
import { createElement, StrictMode } from 'react';
import App from './components/app';
import { getIpcApi } from './get-ipc-api';
import './index.css';

const makeLogger =
( level: 'info' | 'warn' | 'erro', originalLogger: typeof console.log ) =>
( ...args: Parameters< typeof console.log > ) => {
// Map Error objects to strings so we can preserve their stack trace
const mappedErrors = args.map( ( arg ) =>
arg instanceof Error && arg.stack ? arg.stack : arg
);

getIpcApi().logRendererMessage( level, ...mappedErrors );
originalLogger( ...args );
};

console.log = makeLogger( 'info', console.log.bind( console ) );
console.warn = makeLogger( 'warn', console.warn.bind( console ) );
console.error = makeLogger( 'erro', console.error.bind( console ) );

window.onerror = ( message, source, lineno, colno, error ) => {
getIpcApi().logRendererMessage(
'erro',
'Uncaught error in window.onerror',
error?.stack || error
);
};

window.onunhandledrejection = ( event ) => {
getIpcApi().logRendererMessage(
'erro',
'Unhandled promise rejection in window.onunhandledrejection',
event.reason instanceof Error && event.reason.stack ? event.reason.stack : event.reason
);
};

const rootEl = document.getElementById( 'root' );
if ( rootEl ) {
const root = createRoot( rootEl );
Expand Down

0 comments on commit b723ae6

Please sign in to comment.