diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 05ad90e89b..6ee6a9c8f4 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -65,6 +65,7 @@
     "@emotion/styled": "^11.10.6",
     "@fontsource/inter": "^4.5.15",
     "@reduxjs/toolkit": "^1.9.5",
+    "@roarr/browser-log-writer": "^1.1.5",
     "chakra-ui-contextmenu": "^1.0.5",
     "dateformat": "^5.0.3",
     "formik": "^2.2.9",
@@ -93,17 +94,19 @@
     "redux-deep-persist": "^1.0.7",
     "redux-dynamic-middlewares": "^2.2.0",
     "redux-persist": "^6.0.0",
+    "roarr": "^7.15.0",
     "socket.io-client": "^4.6.0",
     "use-image": "^1.1.0",
     "uuid": "^9.0.0"
   },
   "peerDependencies": {
+    "@chakra-ui/cli": "^2.4.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "ts-toolbelt": "^9.6.0",
-    "@chakra-ui/cli": "^2.4.0"
+    "ts-toolbelt": "^9.6.0"
   },
   "devDependencies": {
+    "@chakra-ui/cli": "^2.4.0",
     "@types/dateformat": "^5.0.0",
     "@types/lodash-es": "^4.14.194",
     "@types/node": "^18.16.2",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index e5245c7ad5..d3ccbcb395 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -530,7 +530,11 @@
         "resetWebUI": "Reset Web UI",
         "resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.",
         "resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.",
-        "resetComplete": "Web UI has been reset. Refresh the page to reload."
+        "resetComplete": "Web UI has been reset. Refresh the page to reload.",
+        "consoleLogLevel": "Log Level",
+        "shouldLogToConsole": "Console Logging",
+        "developer": "Developer",
+        "general": "General"
     },
     "toast": {
         "serverError": "Server Error",
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index b33a7cac3f..37d0c7ba72 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -1,5 +1,4 @@
 import ImageUploader from 'common/components/ImageUploader';
-import Console from 'features/system/components/Console';
 import ProgressBar from 'features/system/components/ProgressBar';
 import SiteHeader from 'features/system/components/SiteHeader';
 import InvokeTabs from 'features/ui/components/InvokeTabs';
@@ -27,6 +26,7 @@ import { PartialAppConfig } from 'app/types/invokeai';
 import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
 import { configChanged } from 'features/system/store/configSlice';
 import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
+import { useLogger } from 'app/logging/useLogger';
 
 const DEFAULT_CONFIG = {};
 
@@ -37,6 +37,7 @@ interface Props extends PropsWithChildren {
 const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
   useToastWatcher();
   useGlobalHotkeys();
+  const log = useLogger();
 
   const currentTheme = useAppSelector((state) => state.ui.currentTheme);
 
@@ -50,9 +51,9 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
   const dispatch = useAppDispatch();
 
   useEffect(() => {
-    console.log('Received config: ', config);
+    log.info({ namespace: 'App', data: config }, 'Received config');
     dispatch(configChanged(config));
-  }, [dispatch, config]);
+  }, [dispatch, config, log]);
 
   useEffect(() => {
     setColorMode(['light'].includes(currentTheme) ? 'light' : 'dark');
@@ -119,9 +120,6 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
       <Portal>
         <FloatingGalleryButton />
       </Portal>
-      <Portal>
-        <Console />
-      </Portal>
     </Grid>
   );
 };
diff --git a/invokeai/frontend/web/src/app/logging/useLogger.ts b/invokeai/frontend/web/src/app/logging/useLogger.ts
new file mode 100644
index 0000000000..c69b13dc72
--- /dev/null
+++ b/invokeai/frontend/web/src/app/logging/useLogger.ts
@@ -0,0 +1,94 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { systemSelector } from 'features/system/store/systemSelectors';
+import { isEqual } from 'lodash-es';
+import { useEffect } from 'react';
+import { LogLevelName, ROARR, Roarr } from 'roarr';
+import { createLogWriter } from '@roarr/browser-log-writer';
+
+// Base logging context includes only the package name
+const baseContext = { package: '@invoke-ai/invoke-ai-ui' };
+
+// Create browser log writer
+ROARR.write = createLogWriter();
+
+// Module-scoped logger - can be imported and used anywhere
+export let log = Roarr.child(baseContext);
+
+// Translate human-readable log levels to numbers, used for log filtering
+export const LOG_LEVEL_MAP: Record<LogLevelName, number> = {
+  trace: 10,
+  debug: 20,
+  info: 30,
+  warn: 40,
+  error: 50,
+  fatal: 60,
+};
+
+export const VALID_LOG_LEVELS = [
+  'trace',
+  'debug',
+  'info',
+  'warn',
+  'error',
+  'fatal',
+] as const;
+
+export type InvokeLogLevel = (typeof VALID_LOG_LEVELS)[number];
+
+const selector = createSelector(
+  systemSelector,
+  (system) => {
+    const { app_version, consoleLogLevel, shouldLogToConsole } = system;
+
+    return {
+      version: app_version,
+      consoleLogLevel,
+      shouldLogToConsole,
+    };
+  },
+  {
+    memoizeOptions: {
+      resultEqualityCheck: isEqual,
+    },
+  }
+);
+
+export const useLogger = () => {
+  const { version, consoleLogLevel, shouldLogToConsole } =
+    useAppSelector(selector);
+
+  // The provided Roarr browser log writer uses localStorage to config logging to console
+  useEffect(() => {
+    if (shouldLogToConsole) {
+      // Enable console log output
+      localStorage.setItem('ROARR_LOG', 'true');
+
+      // Use a filter to show only logs of the given level
+      localStorage.setItem(
+        'ROARR_FILTER',
+        `context.logLevel:>=${LOG_LEVEL_MAP[consoleLogLevel]}`
+      );
+    } else {
+      // Disable console log output
+      localStorage.setItem('ROARR_LOG', 'false');
+    }
+    ROARR.write = createLogWriter();
+  }, [consoleLogLevel, shouldLogToConsole]);
+
+  // Update the module-scoped logger context as needed
+  useEffect(() => {
+    const newContext: Record<string, any> = {
+      ...baseContext,
+    };
+
+    if (version) {
+      newContext.version = version;
+    }
+
+    log = Roarr.child(newContext);
+  }, [version]);
+
+  // Use the logger within components - no different than just importing it directly
+  return log;
+};
diff --git a/invokeai/frontend/web/src/app/socketio/emitters.ts b/invokeai/frontend/web/src/app/socketio/emitters.ts
index 5c9057cac3..610f05b826 100644
--- a/invokeai/frontend/web/src/app/socketio/emitters.ts
+++ b/invokeai/frontend/web/src/app/socketio/emitters.ts
@@ -12,7 +12,6 @@ import {
   removeImage,
 } from 'features/gallery/store/gallerySlice';
 import {
-  addLogEntry,
   generationRequested,
   modelChangeRequested,
   modelConvertRequested,
diff --git a/invokeai/frontend/web/src/app/socketio/listeners.ts b/invokeai/frontend/web/src/app/socketio/listeners.ts
index 39d4b4ec23..de2f86fd4c 100644
--- a/invokeai/frontend/web/src/app/socketio/listeners.ts
+++ b/invokeai/frontend/web/src/app/socketio/listeners.ts
@@ -6,7 +6,6 @@ import { v4 as uuidv4 } from 'uuid';
 import * as InvokeAI from 'app/types/invokeai';
 
 import {
-  addLogEntry,
   addToast,
   errorOccurred,
   processingCanceled,
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 8a805d6f16..627c4f0063 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -28,7 +28,7 @@ import { lightboxDenylist } from 'features/lightbox/store/lightboxPersistDenylis
 import { modelsDenylist } from 'features/system/store/modelsPersistDenylist';
 import { nodesDenylist } from 'features/nodes/store/nodesPersistDenylist';
 import { postprocessingDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
-import { systemDenylist } from 'features/system/store/systemPersistsDenylist';
+import { systemDenylist } from 'features/system/store/systemPersistDenylist';
 import { uiDenylist } from 'features/ui/store/uiPersistDenylist';
 
 /**
@@ -82,7 +82,6 @@ const rootPersistConfig = getPersistConfig({
     'hotkeys',
     'config',
   ],
-  debounce: 300,
 });
 
 const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
diff --git a/invokeai/frontend/web/src/common/components/IAISelect.tsx b/invokeai/frontend/web/src/common/components/IAISelect.tsx
index f0998b8937..7bc8952d94 100644
--- a/invokeai/frontend/web/src/common/components/IAISelect.tsx
+++ b/invokeai/frontend/web/src/common/components/IAISelect.tsx
@@ -16,13 +16,23 @@ type IAISelectProps = SelectProps & {
   validValues:
     | Array<number | string>
     | Array<{ key: string; value: string | number }>;
+  horizontal?: boolean;
+  spaceEvenly?: boolean;
 };
 /**
  * Customized Chakra FormControl + Select multi-part component.
  */
 const IAISelect = (props: IAISelectProps) => {
-  const { label, isDisabled, validValues, tooltip, tooltipProps, ...rest } =
-    props;
+  const {
+    label,
+    isDisabled,
+    validValues,
+    tooltip,
+    tooltipProps,
+    horizontal,
+    spaceEvenly,
+    ...rest
+  } = props;
   return (
     <FormControl
       isDisabled={isDisabled}
@@ -32,10 +42,28 @@ const IAISelect = (props: IAISelectProps) => {
         e.nativeEvent.stopPropagation();
         e.nativeEvent.cancelBubble = true;
       }}
+      sx={
+        horizontal
+          ? {
+              display: 'flex',
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              gap: 4,
+            }
+          : {}
+      }
     >
-      {label && <FormLabel>{label}</FormLabel>}
+      {label && (
+        <FormLabel sx={spaceEvenly ? { flexBasis: 0, flexGrow: 1 } : {}}>
+          {label}
+        </FormLabel>
+      )}
       <Tooltip label={tooltip} {...tooltipProps}>
-        <Select {...rest}>
+        <Select
+          {...rest}
+          rootProps={{ sx: spaceEvenly ? { flexBasis: 0, flexGrow: 1 } : {} }}
+        >
           {validValues.map((opt) => {
             return typeof opt === 'string' || typeof opt === 'number' ? (
               <IAIOption key={opt} value={opt}>
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index 960625135a..d0202a5932 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -16,6 +16,8 @@ import { receivedOpenAPISchema } from 'services/thunks/schema';
 import { isFulfilledAnyGraphBuilt } from 'services/thunks/session';
 import { InvocationTemplate, InvocationValue } from '../types/types';
 import { parseSchema } from '../util/parseSchema';
+import { log } from 'app/logging/useLogger';
+import { size } from 'lodash-es';
 
 export type NodesState = {
   nodes: Node<InvocationValue>[];
@@ -85,7 +87,12 @@ const nodesSlice = createSlice({
     parsedOpenAPISchema: (state, action: PayloadAction<OpenAPIV3.Document>) => {
       try {
         const parsedSchema = parseSchema(action.payload);
-        console.debug('Parsed schema: ', parsedSchema);
+
+        // TODO: Achtung! Side effect in a reducer!
+        log.info(
+          { namespace: 'schema', nodes: parsedSchema },
+          `Parsed ${size(parsedSchema)} nodes`
+        );
         state.invocationTemplates = parsedSchema;
       } catch (err) {
         console.error(err);
diff --git a/invokeai/frontend/web/src/features/system/components/Console.tsx b/invokeai/frontend/web/src/features/system/components/Console.tsx
deleted file mode 100644
index 4f7946ee78..0000000000
--- a/invokeai/frontend/web/src/features/system/components/Console.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { Flex, Text, Tooltip } from '@chakra-ui/react';
-import { createSelector } from '@reduxjs/toolkit';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IAIIconButton from 'common/components/IAIIconButton';
-import {
-  errorSeen,
-  setShouldShowLogViewer,
-  SystemState,
-} from 'features/system/store/systemSlice';
-import { isEqual } from 'lodash-es';
-import { Resizable } from 're-resizable';
-import { memo, useLayoutEffect, useRef, useState } from 'react';
-import { useHotkeys } from 'react-hotkeys-hook';
-import { useTranslation } from 'react-i18next';
-import { FaAngleDoubleDown, FaCode, FaMinus } from 'react-icons/fa';
-import { systemSelector } from '../store/systemSelectors';
-
-const logSelector = createSelector(
-  systemSelector,
-  (system: SystemState) => system.log,
-  {
-    memoizeOptions: {
-      // We don't need a deep equality check for this selector.
-      resultEqualityCheck: (a, b) => a.length === b.length,
-    },
-  }
-);
-
-const consoleSelector = createSelector(
-  systemSelector,
-  (system: SystemState) => {
-    return {
-      shouldShowLogViewer: system.shouldShowLogViewer,
-      hasError: system.hasError,
-      wasErrorSeen: system.wasErrorSeen,
-    };
-  },
-  {
-    memoizeOptions: {
-      resultEqualityCheck: isEqual,
-    },
-  }
-);
-
-/**
- * Basic log viewer, floats on bottom of page.
- */
-const Console = () => {
-  const dispatch = useAppDispatch();
-  const { t } = useTranslation();
-  const log = useAppSelector(logSelector);
-  const { shouldShowLogViewer, hasError, wasErrorSeen } =
-    useAppSelector(consoleSelector);
-
-  // Rudimentary autoscroll
-  const [shouldAutoscroll, setShouldAutoscroll] = useState<boolean>(true);
-  const viewerRef = useRef<HTMLDivElement>(null);
-
-  /**
-   * If autoscroll is on, scroll to the bottom when:
-   * - log updates
-   * - viewer is toggled
-   *
-   * Also scroll to the bottom whenever autoscroll is turned on.
-   */
-  useLayoutEffect(() => {
-    if (viewerRef.current !== null && shouldAutoscroll) {
-      viewerRef.current.scrollTop = viewerRef.current.scrollHeight;
-    }
-  }, [shouldAutoscroll, log, shouldShowLogViewer]);
-
-  const handleClickLogViewerToggle = () => {
-    dispatch(errorSeen());
-    dispatch(setShouldShowLogViewer(!shouldShowLogViewer));
-  };
-
-  useHotkeys(
-    '`',
-    () => {
-      dispatch(setShouldShowLogViewer(!shouldShowLogViewer));
-    },
-    [shouldShowLogViewer]
-  );
-
-  useHotkeys('esc', () => {
-    dispatch(setShouldShowLogViewer(false));
-  });
-
-  const handleOnScroll = () => {
-    if (!viewerRef.current) return;
-    if (
-      shouldAutoscroll &&
-      viewerRef.current.scrollTop <
-        viewerRef.current.scrollHeight - viewerRef.current.clientHeight
-    ) {
-      setShouldAutoscroll(false);
-    }
-  };
-
-  return (
-    <>
-      {shouldShowLogViewer && (
-        <Resizable
-          defaultSize={{
-            width: '100%',
-            height: 200,
-          }}
-          style={{
-            display: 'flex',
-            position: 'fixed',
-            insetInlineStart: 0,
-            bottom: 0,
-            zIndex: 1,
-          }}
-          maxHeight="90vh"
-        >
-          <Flex
-            sx={{
-              flexDirection: 'column',
-              width: '100vw',
-              overflow: 'auto',
-              direction: 'column',
-              fontFamily: 'monospace',
-              pt: 0,
-              pr: 4,
-              pb: 4,
-              pl: 12,
-              borderTopWidth: 5,
-              bg: 'base.850',
-              borderColor: 'base.700',
-              zIndex: 2,
-            }}
-            ref={viewerRef}
-            onScroll={handleOnScroll}
-          >
-            {log.map((entry, i) => {
-              const { timestamp, message, level } = entry;
-              const colorScheme = level === 'info' ? 'base' : level;
-              return (
-                <Flex
-                  key={i}
-                  sx={{
-                    gap: 2,
-                    color: `${colorScheme}.300`,
-                  }}
-                >
-                  <Text fontWeight="600">{timestamp}:</Text>
-                  <Text wordBreak="break-all">{message}</Text>
-                </Flex>
-              );
-            })}
-          </Flex>
-        </Resizable>
-      )}
-      {shouldShowLogViewer && (
-        <Tooltip
-          hasArrow
-          label={shouldAutoscroll ? 'Autoscroll On' : 'Autoscroll Off'}
-        >
-          <IAIIconButton
-            size="sm"
-            aria-label={t('accessibility.toggleAutoscroll')}
-            icon={<FaAngleDoubleDown />}
-            onClick={() => setShouldAutoscroll(!shouldAutoscroll)}
-            isChecked={shouldAutoscroll}
-            sx={{
-              position: 'fixed',
-              insetInlineStart: 2,
-              bottom: 12,
-              zIndex: 1,
-            }}
-          />
-        </Tooltip>
-      )}
-      <Tooltip
-        hasArrow
-        label={shouldShowLogViewer ? 'Hide Console' : 'Show Console'}
-      >
-        <IAIIconButton
-          size="sm"
-          aria-label={t('accessibility.toggleLogViewer')}
-          icon={shouldShowLogViewer ? <FaMinus /> : <FaCode />}
-          onClick={handleClickLogViewerToggle}
-          sx={{
-            position: 'fixed',
-            insetInlineStart: 2,
-            bottom: 2,
-            zIndex: 1,
-          }}
-          colorScheme={hasError || !wasErrorSeen ? 'error' : 'base'}
-        />
-      </Tooltip>
-    </>
-  );
-};
-
-export default memo(Console);
diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx
index 3edd6229a4..a806ef262b 100644
--- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx
+++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx
@@ -23,12 +23,14 @@ import IAISelect from 'common/components/IAISelect';
 import IAISwitch from 'common/components/IAISwitch';
 import { systemSelector } from 'features/system/store/systemSelectors';
 import {
+  consoleLogLevelChanged,
   InProgressImageType,
   setEnableImageDebugging,
   setSaveIntermediatesInterval,
   setShouldConfirmOnDelete,
   setShouldDisplayGuides,
   setShouldDisplayInProgressType,
+  shouldLogToConsoleChanged,
   SystemState,
 } from 'features/system/store/systemSlice';
 import { uiSelector } from 'features/ui/store/uiSelectors';
@@ -39,8 +41,11 @@ import {
 import { UIState } from 'features/ui/store/uiTypes';
 import { isEqual, map } from 'lodash-es';
 import { persistor } from 'app/store/persistor';
-import { ChangeEvent, cloneElement, ReactElement } from 'react';
+import { ChangeEvent, cloneElement, ReactElement, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
+import { InvokeLogLevel, VALID_LOG_LEVELS } from 'app/logging/useLogger';
+import { LogLevelName } from 'roarr';
+import { F } from 'ts-toolbelt';
 
 const selector = createSelector(
   [systemSelector, uiSelector],
@@ -52,6 +57,8 @@ const selector = createSelector(
       model_list,
       saveIntermediatesInterval,
       enableImageDebugging,
+      consoleLogLevel,
+      shouldLogToConsole,
     } = system;
 
     const { shouldUseCanvasBetaLayout, shouldUseSliders } = ui;
@@ -65,6 +72,8 @@ const selector = createSelector(
       enableImageDebugging,
       shouldUseCanvasBetaLayout,
       shouldUseSliders,
+      consoleLogLevel,
+      shouldLogToConsole,
     };
   },
   {
@@ -77,6 +86,7 @@ const modalSectionStyles: ChakraProps['sx'] = {
   gap: 2,
   p: 4,
   bg: 'base.900',
+  borderRadius: 'base',
 };
 
 type SettingsModalProps = {
@@ -116,6 +126,8 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
     enableImageDebugging,
     shouldUseCanvasBetaLayout,
     shouldUseSliders,
+    consoleLogLevel,
+    shouldLogToConsole,
   } = useAppSelector(selector);
 
   /**
@@ -135,6 +147,20 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
     dispatch(setSaveIntermediatesInterval(value));
   };
 
+  const handleLogLevelChanged = useCallback(
+    (e: ChangeEvent<HTMLSelectElement>) => {
+      dispatch(consoleLogLevelChanged(e.target.value as LogLevelName));
+    },
+    [dispatch]
+  );
+
+  const handleLogToConsoleChanged = useCallback(
+    (e: ChangeEvent<HTMLInputElement>) => {
+      dispatch(shouldLogToConsoleChanged(e.target.checked));
+    },
+    [dispatch]
+  );
+
   return (
     <>
       {cloneElement(children, {
@@ -145,15 +171,20 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
         isOpen={isSettingsModalOpen}
         onClose={onSettingsModalClose}
         size="xl"
+        isCentered
       >
         <ModalOverlay />
         <ModalContent paddingInlineEnd={4}>
           <ModalHeader>{t('common.settingsLabel')}</ModalHeader>
           <ModalCloseButton />
           <ModalBody>
-            <Grid gap={4}>
+            <Flex sx={{ gap: 4, flexDirection: 'column' }}>
               <Flex sx={modalSectionStyles}>
+                <Heading size="sm">{t('settings.general')}</Heading>
+
                 <IAISelect
+                  horizontal
+                  spaceEvenly
                   label={t('settings.displayInProgress')}
                   validValues={IN_PROGRESS_IMAGE_TYPES}
                   value={shouldDisplayInProgressType}
@@ -208,9 +239,21 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
               </Flex>
 
               <Flex sx={modalSectionStyles}>
-                <Heading size="sm" style={{ fontWeight: 'bold' }}>
-                  Developer
-                </Heading>
+                <Heading size="sm">{t('settings.developer')}</Heading>
+                <IAISwitch
+                  label={t('settings.shouldLogToConsole')}
+                  isChecked={shouldLogToConsole}
+                  onChange={handleLogToConsoleChanged}
+                />
+                <IAISelect
+                  horizontal
+                  spaceEvenly
+                  isDisabled={!shouldLogToConsole}
+                  label={t('settings.consoleLogLevel')}
+                  onChange={handleLogLevelChanged}
+                  value={consoleLogLevel}
+                  validValues={VALID_LOG_LEVELS.concat()}
+                />
                 <IAISwitch
                   label={t('settings.enableImageDebugging')}
                   isChecked={enableImageDebugging}
@@ -228,7 +271,7 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
                 <Text>{t('settings.resetWebUIDesc1')}</Text>
                 <Text>{t('settings.resetWebUIDesc2')}</Text>
               </Flex>
-            </Grid>
+            </Flex>
           </ModalBody>
 
           <ModalFooter>
diff --git a/invokeai/frontend/web/src/features/system/store/systemPersistsDenylist.ts b/invokeai/frontend/web/src/features/system/store/systemPersistDenylist.ts
similarity index 96%
rename from invokeai/frontend/web/src/features/system/store/systemPersistsDenylist.ts
rename to invokeai/frontend/web/src/features/system/store/systemPersistDenylist.ts
index bac2a652b5..8a4d381775 100644
--- a/invokeai/frontend/web/src/features/system/store/systemPersistsDenylist.ts
+++ b/invokeai/frontend/web/src/features/system/store/systemPersistDenylist.ts
@@ -17,7 +17,6 @@ const itemsToDenylist: (keyof SystemState)[] = [
   'totalSteps',
   'openModel',
   'isCancelScheduled',
-  // 'sessionId',
   'progressImage',
   'wereModelsReceived',
   'wasSchemaParsed',
diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts
index 3f572a253d..84176bd096 100644
--- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts
+++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts
@@ -14,13 +14,14 @@ import {
 } from 'services/events/actions';
 
 import i18n from 'i18n';
-import { isImageOutput } from 'services/types/guards';
 import { ProgressImage } from 'services/events/types';
 import { initialImageSelected } from 'features/parameters/store/generationSlice';
 import { makeToast } from '../hooks/useToastWatcher';
 import { sessionCanceled, sessionInvoked } from 'services/thunks/session';
 import { receivedModels } from 'services/thunks/model';
 import { parsedOpenAPISchema } from 'features/nodes/store/nodesSlice';
+import { LogLevelName } from 'roarr';
+import { InvokeLogLevel } from 'app/logging/useLogger';
 
 export type LogLevel = 'info' | 'warning' | 'error';
 
@@ -42,7 +43,6 @@ export interface SystemState
   extends InvokeAI.SystemStatus,
     InvokeAI.SystemConfig {
   shouldDisplayInProgressType: InProgressImageType;
-  log: Array<LogEntry>;
   shouldShowLogViewer: boolean;
   isGFPGANAvailable: boolean;
   isESRGANAvailable: boolean;
@@ -97,12 +97,16 @@ export interface SystemState
    * Whether or not the OpenAPI schema was received and parsed
    */
   wasSchemaParsed: boolean;
+  /**
+   * The console output logging level
+   */
+  consoleLogLevel: InvokeLogLevel;
+  shouldLogToConsole: boolean;
 }
 
 const initialSystemState: SystemState = {
   isConnected: false,
   isProcessing: false,
-  log: [],
   shouldShowLogViewer: false,
   shouldDisplayInProgressType: 'latents',
   shouldDisplayGuides: true,
@@ -144,11 +148,10 @@ const initialSystemState: SystemState = {
   cancelType: 'immediate',
   isCancelScheduled: false,
   subscribedNodeIds: [],
-  // shouldTransformUrls: false,
-  // disabledTabs: [],
-  // disabledFeatures: [],
   wereModelsReceived: false,
   wasSchemaParsed: false,
+  consoleLogLevel: 'error',
+  shouldLogToConsole: true,
 };
 
 export const systemSlice = createSlice({
@@ -189,25 +192,6 @@ export const systemSlice = createSlice({
         ? i18n.t('common.statusConnected')
         : i18n.t('common.statusDisconnected');
     },
-    addLogEntry: (
-      state,
-      action: PayloadAction<{
-        timestamp: string;
-        message: string;
-        level?: LogLevel;
-      }>
-    ) => {
-      const { timestamp, message, level } = action.payload;
-      const logLevel = level || 'info';
-
-      const entry: LogEntry = {
-        timestamp,
-        message,
-        level: logLevel,
-      };
-
-      state.log.push(entry);
-    },
     setShouldShowLogViewer: (state, action: PayloadAction<boolean>) => {
       state.shouldShowLogViewer = action.payload;
     },
@@ -346,6 +330,12 @@ export const systemSlice = createSlice({
     subscribedNodeIdsSet: (state, action: PayloadAction<string[]>) => {
       state.subscribedNodeIds = action.payload;
     },
+    consoleLogLevelChanged: (state, action: PayloadAction<LogLevelName>) => {
+      state.consoleLogLevel = action.payload;
+    },
+    shouldLogToConsoleChanged: (state, action: PayloadAction<boolean>) => {
+      state.shouldLogToConsole = action.payload;
+    },
   },
   extraReducers(builder) {
     /**
@@ -353,7 +343,6 @@ export const systemSlice = createSlice({
      */
     builder.addCase(socketSubscribed, (state, action) => {
       state.sessionId = action.payload.sessionId;
-      console.log(`Subscribed to session ${action.payload.sessionId}`);
     });
 
     /**
@@ -371,11 +360,6 @@ export const systemSlice = createSlice({
 
       state.isConnected = true;
       state.currentStatus = i18n.t('common.statusConnected');
-      state.log.push({
-        timestamp,
-        message: `Connected to server`,
-        level: 'info',
-      });
     });
 
     /**
@@ -386,11 +370,6 @@ export const systemSlice = createSlice({
 
       state.isConnected = false;
       state.currentStatus = i18n.t('common.statusDisconnected');
-      state.log.push({
-        timestamp,
-        message: `Disconnected from server`,
-        level: 'error',
-      });
     });
 
     /**
@@ -433,15 +412,6 @@ export const systemSlice = createSlice({
       state.totalSteps = 0;
       state.progressImage = null;
       state.currentStatus = i18n.t('common.statusProcessingComplete');
-
-      // TODO: handle logging for other invocation types
-      if (isImageOutput(data.result)) {
-        state.log.push({
-          timestamp,
-          message: `Generated: ${data.result.image.image_name}`,
-          level: 'info',
-        });
-      }
     });
 
     /**
@@ -450,12 +420,6 @@ export const systemSlice = createSlice({
     builder.addCase(invocationError, (state, action) => {
       const { data, timestamp } = action.payload;
 
-      state.log.push({
-        timestamp,
-        message: `Server error: ${data.error}`,
-        level: 'error',
-      });
-
       state.wasErrorSeen = true;
       state.progressImage = null;
       state.isProcessing = false;
@@ -463,12 +427,6 @@ export const systemSlice = createSlice({
       state.toastQueue.push(
         makeToast({ title: i18n.t('toast.serverError'), status: 'error' })
       );
-
-      state.log.push({
-        timestamp,
-        message: `Server error: ${data.error}`,
-        level: 'error',
-      });
     });
 
     /**
@@ -495,12 +453,6 @@ export const systemSlice = createSlice({
       state.toastQueue.push(
         makeToast({ title: i18n.t('toast.canceled'), status: 'warning' })
       );
-
-      state.log.push({
-        timestamp,
-        message: `Processing canceled`,
-        level: 'warning',
-      });
     });
 
     /**
@@ -529,7 +481,6 @@ export const systemSlice = createSlice({
 export const {
   setShouldDisplayInProgressType,
   setIsProcessing,
-  addLogEntry,
   setShouldShowLogViewer,
   setIsConnected,
   setSocketId,
@@ -562,6 +513,8 @@ export const {
   scheduledCancelAborted,
   cancelTypeChanged,
   subscribedNodeIdsSet,
+  consoleLogLevelChanged,
+  shouldLogToConsoleChanged,
 } = systemSlice.actions;
 
 export default systemSlice.reducer;
diff --git a/invokeai/frontend/web/src/services/events/actions.ts b/invokeai/frontend/web/src/services/events/actions.ts
index 5ea0a1e9e1..84268773a9 100644
--- a/invokeai/frontend/web/src/services/events/actions.ts
+++ b/invokeai/frontend/web/src/services/events/actions.ts
@@ -1,6 +1,7 @@
 import { createAction } from '@reduxjs/toolkit';
 import {
   GeneratorProgressEvent,
+  GraphExecutionStateCompleteEvent,
   InvocationCompleteEvent,
   InvocationErrorEvent,
   InvocationStartedEvent,
@@ -45,6 +46,10 @@ export const invocationError = createAction<
   BaseSocketPayload & { data: InvocationErrorEvent }
 >('socket/invocationError');
 
+export const graphExecutionStateComplete = createAction<
+  BaseSocketPayload & { data: GraphExecutionStateCompleteEvent }
+>('socket/graphExecutionStateComplete');
+
 export const generatorProgress = createAction<
   BaseSocketPayload & { data: GeneratorProgressEvent }
 >('socket/generatorProgress');
diff --git a/invokeai/frontend/web/src/services/events/middleware.ts b/invokeai/frontend/web/src/services/events/middleware.ts
index 824e48648c..e57851e19c 100644
--- a/invokeai/frontend/web/src/services/events/middleware.ts
+++ b/invokeai/frontend/web/src/services/events/middleware.ts
@@ -6,13 +6,9 @@ import {
   ServerToClientEvents,
 } from 'services/events/types';
 import {
-  generatorProgress,
   invocationComplete,
-  invocationError,
-  invocationStarted,
   socketConnected,
   socketDisconnected,
-  socketReset,
   socketSubscribed,
   socketUnsubscribed,
 } from './actions';
@@ -25,7 +21,6 @@ import { getTimestamp } from 'common/util/getTimestamp';
 import {
   sessionInvoked,
   isFulfilledSessionCreatedAction,
-  sessionCanceled,
 } from 'services/thunks/session';
 import { OpenAPI } from 'services/api';
 import { receivedModels } from 'services/thunks/model';
@@ -33,6 +28,9 @@ import { receivedOpenAPISchema } from 'services/thunks/schema';
 import { isImageOutput } from 'services/types/guards';
 import { imageReceived, thumbnailReceived } from 'services/thunks/image';
 import { setEventListeners } from 'services/events/util/setEventListeners';
+import { log } from 'app/logging/useLogger';
+
+const moduleLog = log.child({ namespace: 'socketio' });
 
 export const socketMiddleware = () => {
   let areListenersSet = false;
@@ -91,6 +89,8 @@ export const socketMiddleware = () => {
       // Must happen in middleware to get access to `dispatch`
       if (!areListenersSet) {
         socket.on('connect', () => {
+          moduleLog.debug('Connected');
+
           dispatch(socketConnected({ timestamp: getTimestamp() }));
 
           const { results, uploads, models, nodes, config, system } =
@@ -116,7 +116,12 @@ export const socketMiddleware = () => {
           }
 
           if (system.sessionId) {
-            console.log(`Re-subscribing to session ${system.sessionId}`);
+            const sessionLog = moduleLog.child({ sessionId: system.sessionId });
+
+            sessionLog.debug(
+              `Subscribed to existing session (${system.sessionId})`
+            );
+
             socket.emit('subscribe', { session: system.sessionId });
             dispatch(
               socketSubscribed({
@@ -124,11 +129,12 @@ export const socketMiddleware = () => {
                 timestamp: getTimestamp(),
               })
             );
-            setEventListeners({ socket, store });
+            setEventListeners({ socket, store, sessionLog });
           }
         });
 
         socket.on('disconnect', () => {
+          moduleLog.debug('Disconnected');
           dispatch(socketDisconnected({ timestamp: getTimestamp() }));
         });
 
@@ -140,6 +146,8 @@ export const socketMiddleware = () => {
 
       // Everything else only happens once we have created a session
       if (isFulfilledSessionCreatedAction(action)) {
+        const sessionId = action.payload.id;
+        const sessionLog = moduleLog.child({ sessionId });
         const oldSessionId = getState().system.sessionId;
 
         // const subscribedNodeIds = getState().system.subscribedNodeIds;
@@ -152,6 +160,10 @@ export const socketMiddleware = () => {
         // };
 
         if (oldSessionId) {
+          sessionLog.debug(
+            { oldSessionId },
+            `Unsubscribed from old session (${oldSessionId})`
+          );
           // Unsubscribe when invocations complete
           socket.emit('unsubscribe', {
             session: oldSessionId,
@@ -176,8 +188,7 @@ export const socketMiddleware = () => {
           });
         }
 
-        const sessionId = action.payload.id;
-
+        sessionLog.debug(`Subscribe to new session (${sessionId})`);
         socket.emit('subscribe', { session: sessionId });
         dispatch(
           socketSubscribed({
@@ -185,7 +196,7 @@ export const socketMiddleware = () => {
             timestamp: getTimestamp(),
           })
         );
-        setEventListeners({ socket, store });
+        setEventListeners({ socket, store, sessionLog });
 
         // Finally we actually invoke the session, starting processing
         dispatch(sessionInvoked({ sessionId }));
diff --git a/invokeai/frontend/web/src/services/events/util/setEventListeners.ts b/invokeai/frontend/web/src/services/events/util/setEventListeners.ts
index 950cfb4083..90a285d238 100644
--- a/invokeai/frontend/web/src/services/events/util/setEventListeners.ts
+++ b/invokeai/frontend/web/src/services/events/util/setEventListeners.ts
@@ -5,34 +5,42 @@ import { sessionCanceled } from 'services/thunks/session';
 import { Socket } from 'socket.io-client';
 import {
   generatorProgress,
+  graphExecutionStateComplete,
   invocationComplete,
   invocationError,
   invocationStarted,
 } from '../actions';
 import { ClientToServerEvents, ServerToClientEvents } from '../types';
+import { Logger } from 'roarr';
+import { JsonObject } from 'roarr/dist/types';
 
 type SetEventListenersArg = {
   socket: Socket<ServerToClientEvents, ClientToServerEvents>;
   store: MiddlewareAPI<AppDispatch, RootState>;
+  sessionLog: Logger<JsonObject>;
 };
 
 export const setEventListeners = (arg: SetEventListenersArg) => {
-  const { socket, store } = arg;
+  const { socket, store, sessionLog } = arg;
   const { dispatch, getState } = store;
   // Set up listeners for the present subscription
   socket.on('invocation_started', (data) => {
+    sessionLog.child({ data }).info(`Invocation started (${data.node.type})`);
     dispatch(invocationStarted({ data, timestamp: getTimestamp() }));
   });
 
   socket.on('generator_progress', (data) => {
+    sessionLog.child({ data }).trace(`Generator progress (${data.node.type})`);
     dispatch(generatorProgress({ data, timestamp: getTimestamp() }));
   });
 
   socket.on('invocation_error', (data) => {
+    sessionLog.child({ data }).error(`Invocation error (${data.node.type})`);
     dispatch(invocationError({ data, timestamp: getTimestamp() }));
   });
 
   socket.on('invocation_complete', (data) => {
+    sessionLog.child({ data }).info(`Invocation complete (${data.node.type})`);
     const sessionId = data.graph_execution_state_id;
 
     const { cancelType, isCancelScheduled } = getState().system;
@@ -51,4 +59,13 @@ export const setEventListeners = (arg: SetEventListenersArg) => {
       })
     );
   });
+
+  socket.on('graph_execution_state_complete', (data) => {
+    sessionLog
+      .child({ data })
+      .info(
+        `Graph execution state complete (${data.graph_execution_state_id})`
+      );
+    dispatch(graphExecutionStateComplete({ data, timestamp: getTimestamp() }));
+  });
 };
diff --git a/invokeai/frontend/web/src/services/thunks/gallery.ts b/invokeai/frontend/web/src/services/thunks/gallery.ts
index 4361ce1499..f908cbddcb 100644
--- a/invokeai/frontend/web/src/services/thunks/gallery.ts
+++ b/invokeai/frontend/web/src/services/thunks/gallery.ts
@@ -1,8 +1,11 @@
+import { log } from 'app/logging/useLogger';
 import { createAppAsyncThunk } from 'app/store/storeUtils';
 import { ImagesService } from 'services/api';
 
 export const IMAGES_PER_PAGE = 20;
 
+const galleryLog = log.child({ namespace: 'gallery' });
+
 export const receivedResultImagesPage = createAppAsyncThunk(
   'results/receivedResultImagesPage',
   async (_arg, { getState }) => {
@@ -12,6 +15,8 @@ export const receivedResultImagesPage = createAppAsyncThunk(
       perPage: IMAGES_PER_PAGE,
     });
 
+    galleryLog.info({ response }, `Received ${response.items.length} results`);
+
     return response;
   }
 );
@@ -25,6 +30,8 @@ export const receivedUploadImagesPage = createAppAsyncThunk(
       perPage: IMAGES_PER_PAGE,
     });
 
+    galleryLog.info({ response }, `Received ${response.items.length} uploads`);
+
     return response;
   }
 );
diff --git a/invokeai/frontend/web/src/services/thunks/image.ts b/invokeai/frontend/web/src/services/thunks/image.ts
index d9fdd1c589..c4da9d9f16 100644
--- a/invokeai/frontend/web/src/services/thunks/image.ts
+++ b/invokeai/frontend/web/src/services/thunks/image.ts
@@ -1,10 +1,13 @@
 import { isFulfilled, isRejected } from '@reduxjs/toolkit';
+import { log } from 'app/logging/useLogger';
 import { createAppAsyncThunk } from 'app/store/storeUtils';
 import { imageSelected } from 'features/gallery/store/gallerySlice';
 import { clamp } from 'lodash-es';
 import { ImagesService } from 'services/api';
 import { getHeaders } from 'services/util/getHeaders';
 
+const imagesLog = log.child({ namespace: 'image' });
+
 type ImageReceivedArg = Parameters<(typeof ImagesService)['getImage']>[0];
 
 /**
@@ -14,6 +17,9 @@ export const imageReceived = createAppAsyncThunk(
   'api/imageReceived',
   async (arg: ImageReceivedArg, _thunkApi) => {
     const response = await ImagesService.getImage(arg);
+
+    imagesLog.info({ arg, response }, 'Received image');
+
     return response;
   }
 );
@@ -29,6 +35,9 @@ export const thumbnailReceived = createAppAsyncThunk(
   'api/thumbnailReceived',
   async (arg: ThumbnailReceivedArg, _thunkApi) => {
     const response = await ImagesService.getThumbnail(arg);
+
+    imagesLog.info({ arg, response }, 'Received thumbnail');
+
     return response;
   }
 );
@@ -43,6 +52,12 @@ export const imageUploaded = createAppAsyncThunk(
   async (arg: ImageUploadedArg, _thunkApi) => {
     const response = await ImagesService.uploadImage(arg);
     const { location } = getHeaders(response);
+
+    imagesLog.info(
+      { arg: '<Blob>', response, location },
+      `Image uploaded (${response.image_name})`
+    );
+
     return { response, location };
   }
 );
@@ -96,6 +111,11 @@ export const imageDeleted = createAppAsyncThunk(
 
     const response = await ImagesService.deleteImage(arg);
 
+    imagesLog.info(
+      { arg, response },
+      `Image deleted (${arg.imageType} - ${arg.imageName})`
+    );
+
     return response;
   }
 );
diff --git a/invokeai/frontend/web/src/services/thunks/model.ts b/invokeai/frontend/web/src/services/thunks/model.ts
index a4aac7563d..84f7a24e81 100644
--- a/invokeai/frontend/web/src/services/thunks/model.ts
+++ b/invokeai/frontend/web/src/services/thunks/model.ts
@@ -1,14 +1,18 @@
+import { log } from 'app/logging/useLogger';
 import { createAppAsyncThunk } from 'app/store/storeUtils';
 import { Model } from 'features/system/store/modelSlice';
-import { reduce } from 'lodash-es';
+import { reduce, size } from 'lodash-es';
 import { ModelsService } from 'services/api';
 
+const models = log.child({ namespace: 'model' });
+
 export const IMAGES_PER_PAGE = 20;
 
 export const receivedModels = createAppAsyncThunk(
   'models/receivedModels',
-  async (_arg) => {
+  async (_) => {
     const response = await ModelsService.listModels();
+
     const deserializedModels = reduce(
       response.models,
       (modelsAccumulator, model, modelName) => {
@@ -19,6 +23,8 @@ export const receivedModels = createAppAsyncThunk(
       {} as Record<string, Model>
     );
 
+    models.info({ response }, `Received ${size(response.models)} models`);
+
     return deserializedModels;
   }
 );
diff --git a/invokeai/frontend/web/src/services/thunks/schema.ts b/invokeai/frontend/web/src/services/thunks/schema.ts
index 7da8514427..bc93fa0fae 100644
--- a/invokeai/frontend/web/src/services/thunks/schema.ts
+++ b/invokeai/frontend/web/src/services/thunks/schema.ts
@@ -1,16 +1,19 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
+import { log } from 'app/logging/useLogger';
 import { parsedOpenAPISchema } from 'features/nodes/store/nodesSlice';
 import { OpenAPIV3 } from 'openapi-types';
 
+const schemaLog = log.child({ namespace: 'schema' });
+
 export const receivedOpenAPISchema = createAsyncThunk(
   'nodes/receivedOpenAPISchema',
   async (_, { dispatch }): Promise<OpenAPIV3.Document> => {
     const response = await fetch(`openapi.json`);
-    const openAPISchema = (await response.json()) as OpenAPIV3.Document;
+    const openAPISchema = await response.json();
 
-    console.debug('OpenAPI schema: ', openAPISchema);
+    schemaLog.info({ openAPISchema }, 'Received OpenAPI schema');
 
-    dispatch(parsedOpenAPISchema(openAPISchema));
+    dispatch(parsedOpenAPISchema(openAPISchema as OpenAPIV3.Document));
 
     return openAPISchema;
   }
diff --git a/invokeai/frontend/web/src/services/thunks/session.ts b/invokeai/frontend/web/src/services/thunks/session.ts
index ba796f7467..0641437d52 100644
--- a/invokeai/frontend/web/src/services/thunks/session.ts
+++ b/invokeai/frontend/web/src/services/thunks/session.ts
@@ -3,6 +3,9 @@ import { SessionsService } from 'services/api';
 import { buildLinearGraph as buildGenerateGraph } from 'features/nodes/util/linearGraphBuilder/buildLinearGraph';
 import { isAnyOf, isFulfilled } from '@reduxjs/toolkit';
 import { buildNodesGraph } from 'features/nodes/util/nodesGraphBuilder/buildNodesGraph';
+import { log } from 'app/logging/useLogger';
+
+const sessionLog = log.child({ namespace: 'session' });
 
 export const generateGraphBuilt = createAppAsyncThunk(
   'api/generateGraphBuilt',
@@ -43,12 +46,12 @@ type SessionCreatedArg = {
 export const sessionCreated = createAppAsyncThunk(
   'api/sessionCreated',
   async (arg: SessionCreatedArg, { dispatch, getState }) => {
-    console.log('Session created, graph: ', arg.graph);
-
     const response = await SessionsService.createSession({
       requestBody: arg.graph,
     });
 
+    sessionLog.info({ arg, response }, `Session created (${response.id})`);
+
     return response;
   }
 );
@@ -74,6 +77,8 @@ export const nodeAdded = createAppAsyncThunk(
       sessionId: arg.sessionId,
     });
 
+    sessionLog.info({ arg, response }, `Node added (${response})`);
+
     return response;
   }
 );
@@ -91,6 +96,8 @@ export const sessionInvoked = createAppAsyncThunk(
       all: true,
     });
 
+    sessionLog.info({ arg, response }, `Session invoked (${sessionId})`);
+
     return response;
   }
 );
@@ -111,6 +118,8 @@ export const sessionCanceled = createAppAsyncThunk(
       sessionId,
     });
 
+    sessionLog.info({ arg, response }, `Session canceled (${sessionId})`);
+
     return response;
   }
 );
@@ -127,6 +136,11 @@ export const listedSessions = createAppAsyncThunk(
   async (arg: SessionsListedArg, _thunkApi) => {
     const response = await SessionsService.listSessions(arg);
 
+    sessionLog.info(
+      { arg, response },
+      `Sessions listed (${response.items.length})`
+    );
+
     return response;
   }
 );
diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock
index b0811ad877..f117d4f2de 100644
--- a/invokeai/frontend/web/yarn.lock
+++ b/invokeai/frontend/web/yarn.lock
@@ -1421,6 +1421,15 @@
     redux-thunk "^2.4.2"
     reselect "^4.1.8"
 
+"@roarr/browser-log-writer@^1.1.5":
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/@roarr/browser-log-writer/-/browser-log-writer-1.1.5.tgz#755ff62ddaa297bb3488067408a7085db382352b"
+  integrity sha512-yLn//DRjh1/rUgZpZkwmT/5RqHYfkdOwGXWXnKBR3l/HE04DIhSVeYin3sc8aWHBa7s7WglQpYX/uw/WI6POpw==
+  dependencies:
+    boolean "^3.1.4"
+    globalthis "^1.0.2"
+    liqe "^3.6.0"
+
 "@rollup/pluginutils@^4.2.1":
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
@@ -2077,7 +2086,7 @@ aggregate-error@^3.0.0:
     clean-stack "^2.0.0"
     indent-string "^4.0.0"
 
-ajv@^6.10.0, ajv@^6.12.4, ajv@~6.12.6:
+ajv@^6.10.0, ajv@^6.11.0, ajv@^6.12.4, ajv@~6.12.6:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
   integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@@ -2307,6 +2316,11 @@ bl@^4.1.0:
     inherits "^2.0.4"
     readable-stream "^3.4.0"
 
+boolean@^3.1.4:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b"
+  integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==
+
 boxen@^5.0.0:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50"
@@ -2609,7 +2623,7 @@ commander@^10.0.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
   integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
 
-commander@^2.16.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1:
+commander@^2.16.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -2828,6 +2842,11 @@ deepmerge@^2.1.1:
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
   integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==
 
+deepmerge@^4.2.2:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+  integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
+
 defaults@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a"
@@ -3031,6 +3050,11 @@ dir-glob@^3.0.1:
   dependencies:
     path-type "^4.0.0"
 
+discontinuous-range@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
+  integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==
+
 doctrine@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -3442,11 +3466,28 @@ fast-json-stable-stringify@^2.0.0:
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
+fast-json-stringify@^2.7.10:
+  version "2.7.13"
+  resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-2.7.13.tgz#277aa86c2acba4d9851bd6108ed657aa327ed8c0"
+  integrity sha512-ar+hQ4+OIurUGjSJD1anvYSDcUflywhKjfxnsW4TBTD7+u0tJufv6DKRWoQk3vI6YBOWMoz0TQtfbe7dxbQmvA==
+  dependencies:
+    ajv "^6.11.0"
+    deepmerge "^4.2.2"
+    rfdc "^1.2.0"
+    string-similarity "^4.0.1"
+
 fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
 
+fast-printf@^1.6.9:
+  version "1.6.9"
+  resolved "https://registry.yarnpkg.com/fast-printf/-/fast-printf-1.6.9.tgz#212f56570d2dc8ccdd057ee93d50dd414d07d676"
+  integrity sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg==
+  dependencies:
+    boolean "^3.1.4"
+
 fastq@^1.6.0:
   version "1.15.0"
   resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
@@ -3768,7 +3809,7 @@ globals@^13.19.0:
   dependencies:
     type-fest "^0.20.2"
 
-globalthis@^1.0.3:
+globalthis@^1.0.2, globalthis@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf"
   integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==
@@ -4465,6 +4506,14 @@ lint-staged@^13.2.2:
     string-argv "^0.3.1"
     yaml "^2.2.2"
 
+liqe@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/liqe/-/liqe-3.6.0.tgz#2d05376e93ff9f4bfdb3e76481f6456d165b499f"
+  integrity sha512-CYVQr0bk5CCTkX3wW2MdyEWdr9FHLpiE/1cQXQ36Sdjn5gv7JIpm9jnkovFwiVzumw7f6JDFXpljwUY+fAcFYQ==
+  dependencies:
+    nearley "^2.20.1"
+    ts-error "^1.0.6"
+
 listr2@^5.0.7:
   version "5.0.8"
   resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.8.tgz#a9379ffeb4bd83a68931a65fb223a11510d6ba23"
@@ -4714,6 +4763,11 @@ module-lookup-amd@^7.0.1:
     requirejs "^2.3.5"
     requirejs-config-file "^4.0.0"
 
+moo@^0.5.0:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
+  integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==
+
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -4734,6 +4788,16 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
+nearley@^2.20.1:
+  version "2.20.1"
+  resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"
+  integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==
+  dependencies:
+    commander "^2.19.0"
+    moo "^0.5.0"
+    railroad-diagrams "^1.0.0"
+    randexp "0.4.6"
+
 neo-async@^2.6.0:
   version "2.6.2"
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
@@ -5215,6 +5279,19 @@ quote-unquote@^1.0.0:
   resolved "https://registry.yarnpkg.com/quote-unquote/-/quote-unquote-1.0.0.tgz#67a9a77148effeaf81a4d428404a710baaac8a0b"
   integrity sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==
 
+railroad-diagrams@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
+  integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==
+
+randexp@0.4.6:
+  version "0.4.6"
+  resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
+  integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==
+  dependencies:
+    discontinuous-range "1.0.0"
+    ret "~0.1.10"
+
 rc@1.2.8, rc@^1.2.7, rc@^1.2.8:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -5554,12 +5631,17 @@ restore-cursor@^3.1.0:
     onetime "^5.1.0"
     signal-exit "^3.0.2"
 
+ret@~0.1.10:
+  version "0.1.15"
+  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
 reusify@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
-rfdc@^1.3.0:
+rfdc@^1.2.0, rfdc@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
   integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
@@ -5578,6 +5660,18 @@ rimraf@^3.0.2:
   dependencies:
     glob "^7.1.3"
 
+roarr@^7.15.0:
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.0.tgz#09b792f0cd31b4a7f91030bb1c47550ceec98ee4"
+  integrity sha512-CV9WefQfUXTX6wr8CrEMhfNef3sjIt9wNhE/5PNu4tNWsaoDNDXqq+OGn/RW9A1UPb0qc7FQlswXRaJJJsqn8A==
+  dependencies:
+    boolean "^3.1.4"
+    fast-json-stringify "^2.7.10"
+    fast-printf "^1.6.9"
+    globalthis "^1.0.2"
+    safe-stable-stringify "^2.4.1"
+    semver-compare "^1.0.0"
+
 rollup-plugin-visualizer@^5.9.0:
   version "5.9.0"
   resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.9.0.tgz#013ac54fb6a9d7c9019e7eb77eced673399e5a0b"
@@ -5630,6 +5724,11 @@ safe-regex-test@^1.0.0:
     get-intrinsic "^1.1.3"
     is-regex "^1.1.4"
 
+safe-stable-stringify@^2.4.1:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886"
+  integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==
+
 sass-lookup@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-3.0.0.tgz#3b395fa40569738ce857bc258e04df2617c48cac"
@@ -5644,6 +5743,11 @@ scheduler@^0.23.0:
   dependencies:
     loose-envify "^1.1.0"
 
+semver-compare@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
+  integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==
+
 semver-diff@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
@@ -5810,6 +5914,11 @@ string-argv@^0.3.1, string-argv@~0.3.1:
   resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
   integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
 
+string-similarity@^4.0.1:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
+  integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
+
 string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -6032,6 +6141,11 @@ tree-kill@^1.2.2:
   resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
   integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
 
+ts-error@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/ts-error/-/ts-error-1.0.6.tgz#277496f2a28de6c184cfce8dfd5cdd03a4e6b0fc"
+  integrity sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==
+
 ts-graphviz@^1.5.0:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-1.6.1.tgz#f44525c048cb8c8c188b7324d2a91015fd31ceaf"