From 51a55a7d54f57a6ddf4b51ec19661a3337a6ef22 Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Fri, 13 Feb 2026 18:02:47 +0000
Subject: [PATCH] Allow user configurable log level
1. Allow users to select log level from settings UI dropdown
2. Fix DB logging config to remove handler when turned off
---
.../vue_components/config/ConfigSettings.vue | 27 ++++++++-
server/digi_server/app_server.py | 35 ++++++++----
server/digi_server/logger.py | 47 +++++++++++++---
server/digi_server/settings.py | 55 +++++++++++++++++--
4 files changed, 138 insertions(+), 26 deletions(-)
diff --git a/client/src/vue_components/config/ConfigSettings.vue b/client/src/vue_components/config/ConfigSettings.vue
index 2fec9230..fbf7f9af 100644
--- a/client/src/vue_components/config/ConfigSettings.vue
+++ b/client/src/vue_components/config/ConfigSettings.vue
@@ -32,8 +32,18 @@
+
+
+
+ Unknown setting type {{ setting.type }} for setting {{ key }}.
+
Reset
@@ -131,6 +144,16 @@ export default {
}
return mapping[fieldType];
},
+ getChoiceOptions(setting) {
+ const options = [];
+ if (setting._nullable) {
+ options.push({ value: null, text: 'N/A' });
+ }
+ setting.choice_options.forEach((option) => {
+ options.push({ value: option, text: option });
+ });
+ return options;
+ },
validateState(name) {
const { $dirty, $error } = this.$v.editSettings[name];
return $dirty ? !$error : null;
diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py
index 8359ac09..9e283908 100644
--- a/server/digi_server/app_server.py
+++ b/server/digi_server/app_server.py
@@ -17,7 +17,12 @@
from controllers import controllers
from controllers.ws_controller import WebSocketController
-from digi_server.logger import configure_db_logging, configure_file_logging, get_logger
+from digi_server.logger import (
+ configure_db_logging,
+ configure_file_logging,
+ configure_log_level,
+ get_logger,
+)
from digi_server.settings import Settings
from models import models
from models.cue import CueType
@@ -362,23 +367,29 @@ async def _configure_logging(self):
log_path = await self.digi_settings.get("log_path")
file_size = await self.digi_settings.get("max_log_mb")
backups = await self.digi_settings.get("log_backups")
+ log_level = await self.digi_settings.get("log_level")
if log_path:
self.app_log_handler = configure_file_logging(
- log_path, file_size, backups, self.app_log_handler
+ log_path=log_path,
+ max_size_mb=file_size,
+ log_backups=backups,
+ handler=self.app_log_handler,
)
+ configure_log_level(log_level)
# Database logging
use_db_logging = await self.digi_settings.get("db_log_enabled")
- if use_db_logging:
- db_log_path = await self.digi_settings.get("db_log_path")
- db_file_size = await self.digi_settings.get("db_max_log_mb")
- db_backups = await self.digi_settings.get("db_log_backups")
- self.db_file_handler = configure_db_logging(
- log_path=db_log_path,
- max_size_mb=db_file_size,
- log_backups=db_backups,
- handler=self.db_file_handler,
- )
+ db_log_path = await self.digi_settings.get("db_log_path")
+ db_file_size = await self.digi_settings.get("db_max_log_mb")
+ db_backups = await self.digi_settings.get("db_log_backups")
+ self.db_file_handler = configure_db_logging(
+ log_path=db_log_path,
+ max_size_mb=db_file_size,
+ log_backups=db_backups,
+ handler=self.db_file_handler,
+ log_level=log_level,
+ enable_db_logging=use_db_logging,
+ )
def _configure_rbac(self):
self._db.register_delete_hook(self.rbac.rbac_db.check_object_deletion)
diff --git a/server/digi_server/logger.py b/server/digi_server/logger.py
index b9aed4bc..d63a69e6 100644
--- a/server/digi_server/logger.py
+++ b/server/digi_server/logger.py
@@ -6,6 +6,12 @@
logger = logging.getLogger("DigiScript")
+ALL_LOGGERS = [
+ logging.getLogger("tornado.access"),
+ logging.getLogger("tornado.application"),
+ logging.getLogger("tornado.general"),
+ logger,
+]
def get_logger(name: Optional[str] = None):
@@ -14,26 +20,40 @@ def get_logger(name: Optional[str] = None):
return logger
-def configure_file_logging(log_path, max_size_mb=100, log_backups=5, handler=None):
+def configure_log_level(log_level=logging.DEBUG):
+ for _logger in ALL_LOGGERS:
+ logger.info(f"Setting log level to {log_level} for logger: {_logger.name}")
+ _logger.setLevel(log_level)
+
+
+def configure_file_logging(
+ log_path,
+ max_size_mb=100,
+ log_backups=5,
+ handler=None,
+):
size_bytes = max_size_mb * 1024 * 1024
- app_logger = get_logger()
if handler:
- app_logger.removeHandler(handler)
+ for _logger in ALL_LOGGERS:
+ _logger.removeHandler(handler)
file_handler = RotatingFileHandler(
log_path, maxBytes=size_bytes, backupCount=log_backups
)
file_handler.setFormatter(LogFormatter(color=False))
- app_logger.addHandler(file_handler)
- logging.getLogger("tornado.access").addHandler(file_handler)
- logging.getLogger("tornado.application").addHandler(file_handler)
- logging.getLogger("tornado.general").addHandler(file_handler)
+ for _logger in ALL_LOGGERS:
+ _logger.addHandler(file_handler)
return file_handler
def configure_db_logging(
- log_level=logging.DEBUG, log_path=None, max_size_mb=100, log_backups=5, handler=None
+ log_level=logging.DEBUG,
+ log_path=None,
+ max_size_mb=100,
+ log_backups=5,
+ handler=None,
+ enable_db_logging=False,
):
size_bytes = max_size_mb * 1024 * 1024
db_logger = logging.getLogger("sqlalchemy.engine")
@@ -41,6 +61,11 @@ def configure_db_logging(
if handler:
db_logger.removeHandler(handler)
+ if not enable_db_logging:
+ logger.info(f"Disabling logger: {db_logger.name}")
+ return None
+
+ logger.info(f"Setting log level to {log_level} for logger: {db_logger.name}")
db_logger.setLevel(log_level)
file_handler = None
if log_path:
@@ -75,3 +100,9 @@ def log_to_root(message, *args, **kwargs):
setattr(logging, level_name, level_num)
setattr(logging.getLoggerClass(), method_name, log_for_level)
setattr(logging, method_name, log_to_root)
+
+
+def get_level_names_by_order():
+ levels = logging.getLevelNamesMapping()
+ sorted_levels = sorted(levels.items(), key=lambda x: x[1])
+ return [name for name, _ in sorted_levels]
diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py
index aface3a7..dfe386d4 100644
--- a/server/digi_server/settings.py
+++ b/server/digi_server/settings.py
@@ -4,11 +4,11 @@
import os
import tomllib
from pathlib import Path
-from typing import TYPE_CHECKING, Dict
+from typing import TYPE_CHECKING, Dict, Optional
from tornado.locks import Lock
-from digi_server.logger import get_logger
+from digi_server.logger import get_level_names_by_order, get_logger
from utils.file_watcher import IOLoopFileWatcher
@@ -35,6 +35,8 @@ def get_version() -> str:
class SettingsObject:
+ ALLOWED_TYPES = [str, bool, int]
+
def __init__(
self,
key,
@@ -46,13 +48,38 @@ def __init__(
display_name: str = "",
help_text: str = "",
hide_from_ui: bool = False,
+ choice_options: Optional[list] = None,
):
- if val_type not in [str, bool, int]:
+ if val_type not in self.ALLOWED_TYPES:
raise RuntimeError(
f"Invalid type {val_type} for {key}. Allowed options are: "
- f"[str, int, bool]"
+ f"{[t.__name__ for t in self.ALLOWED_TYPES]}."
+ )
+
+ if default is None and not nullable:
+ raise RuntimeError(
+ f"Default value for {key} cannot be None if setting is not nullable."
)
+ if default is not None and not isinstance(default, val_type):
+ raise RuntimeError(
+ f"Default value {default} for {key} is not of type {val_type.__name__}."
+ )
+
+ if choice_options is not None:
+ if len(choice_options) == 0:
+ raise RuntimeError(f"Choice options for {key} cannot be an empty list.")
+
+ if any(not isinstance(option, val_type) for option in choice_options):
+ raise RuntimeError(
+ f"All choice options for {key} must be of type {val_type.__name__}."
+ )
+
+ if default not in choice_options:
+ raise RuntimeError(
+ f"Default value for {key} must be one of the choice options."
+ )
+
self.key = key
self.val_type = val_type
self.value = None
@@ -64,6 +91,7 @@ def __init__(
self.display_name = display_name
self.help_text = help_text
self.hide_from_ui = hide_from_ui
+ self.choice_options = choice_options
def set_to_default(self):
self.value = self.default
@@ -81,6 +109,12 @@ def set_value(self, value, spawn_callbacks=True):
f"type {self.val_type}"
)
+ if self.choice_options is not None and value not in self.choice_options:
+ raise ValueError(
+ f"Value for {self.key} must be one of the following options: "
+ f"{self.choice_options}"
+ )
+
changed = False
if value != self.value:
changed = True
@@ -106,6 +140,8 @@ def as_json(self):
"display_name": self.display_name,
"help_text": self.help_text,
"hide_from_ui": self.hide_from_ui,
+ "choice_options": self.choice_options,
+ "_nullable": self._nullable,
}
@@ -161,6 +197,15 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
hide_from_ui=True,
)
self.define("debug_mode", bool, False, True, display_name="Enable Debug Mode")
+ self.define(
+ "log_level",
+ str,
+ "DEBUG",
+ True,
+ self._application.regen_logging,
+ display_name="Log Level",
+ choice_options=get_level_names_by_order(),
+ )
self.define(
"log_path",
str,
@@ -254,6 +299,7 @@ def define(
display_name: str = "",
help_text: str = "",
hide_from_ui: bool = False,
+ choice_options: Optional[list] = None,
):
self.settings[key] = SettingsObject(
key,
@@ -265,6 +311,7 @@ def define(
display_name,
help_text,
hide_from_ui,
+ choice_options,
)
def file_deleted(self):