Applications
Log details
The base AppDaemon log format does not include causal links.
diff --git a/logger_interface.py b/logger_interface.py index 5799302..4cac5f1 100644 --- a/logger_interface.py +++ b/logger_interface.py @@ -1,44 +1,81 @@ import traceback +from contextlib import contextmanager +from contextvars import ContextVar +from uuid import uuid4 + +_ACTIVE_LOG_GROUP = ContextVar('appdaemon_log_group', default=None) + + +@contextmanager +def log_group(kind, label=None, *, inherit=True): + """Tag LoggerInterface messages in this synchronous operation with one group ID.""" + parent = _ACTIVE_LOG_GROUP.get() if inherit else None + group = parent or { + 'id': uuid4().hex[:12], + 'kind': kind, + 'label': str(label) if label is not None else '', + } + token = _ACTIVE_LOG_GROUP.set(group) + try: + yield group + finally: + _ACTIVE_LOG_GROUP.reset(token) + + class LoggerInterface: - def __init__(self): self.initialized = False - def __init__(self,ad_api, default_log = "main_log",mute = False): - self.initialize_logger_interface(ad_api,default_log,mute) + def __init__(self, ad_api, default_log='main_log', mute=False): + self.initialize_logger_interface(ad_api, default_log, mute) - def initialize_logger_interface(self,ad_api, default_log = "main_log",mute = False): + def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False): self.ad_api = ad_api self.default_log = default_log self.mute = False self.initialized = True - def mute_logger(self,mute : bool): + def log_group(self, kind, label=None): + return log_group(kind, label) + + @staticmethod + def _tag_log_message(message): + group = _ACTIVE_LOG_GROUP.get() + if group is None: + return message + marker = f"[group={group['kind']}:{group['id']}]" + if isinstance(message, str) and marker in message: + return message + return f"{message} {marker}" + + def mute_logger(self, mute: bool): if mute: - self.ad_api.log("Muting logger",level = "INFO", log = self.default_log) + self.ad_api.log(self._tag_log_message('Muting logger'), level='INFO', log=self.default_log) self.mute = bool(mute) - def log_info(self,message): + def log_info(self, message): assert self.initialized if not self.mute: - self.ad_api.log(message,level = "INFO", log = self.default_log) + self.ad_api.log(self._tag_log_message(message), level='INFO', log=self.default_log) - def log_warning(self,message): + def log_warning(self, message): assert self.initialized - self.ad_api.log(message,level = "WARNING", log = self.default_log) + self.ad_api.log(self._tag_log_message(message), level='WARNING', log=self.default_log) - def log_error(self,message,stop_app = False,dump_stack = False): + def log_error(self, message, stop_app=False, dump_stack=False): assert self.initialized if dump_stack: - stack = "\n" + stack = '\n' for line in traceback.format_stack()[:-1]: stack += line.strip() + '\n' - else: stack = "" + else: + stack = '' + + message = self._tag_log_message(message) + self.ad_api.log(message, level='ERROR', log=self.default_log) + self.ad_api.log(message + stack, level='ERROR', log='error_log') - self.ad_api.log(message,level = "ERROR", log = self.default_log) - self.ad_api.log(message + stack,level = "ERROR", log = "error_log") - if stop_app: - self.stop_app(self.name) \ No newline at end of file + self.stop_app(self.name) diff --git a/smartcondition.py b/smartcondition.py index 2116aaf..eff2d75 100644 --- a/smartcondition.py +++ b/smartcondition.py @@ -1,6 +1,7 @@ #import appdaemon.plugins.hass.hassapi as hass import enum from datetime import datetime +from contextlib import nullcontext #from pickle import FALSE from adexpressioncontext import ADExpressionContext @@ -56,6 +57,7 @@ class Evaluator(): self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first condition_name = parser.parse_args('condition_name',None,['log_prefix']) + self.__trace_name = ', '.join(map(str, condition_name)) if isinstance(condition_name, list) else str(condition_name or 'condition') if condition_name: if isinstance(condition_name,list): for name in condition_name: @@ -161,14 +163,24 @@ class Evaluator(): raise e + def __log_group(self): + # SmartObject itself implements LoggerInterface, but many evaluators are + # constructed with only the app as their API and no logger_interface kwarg. + logger = self.__logger_interface or self.__appdaemon_api + group_scope = getattr(logger, 'log_group', None) + if callable(group_scope): + return group_scope('smartcondition', self.__trace_name) + return nullcontext() + def evaluate(self,log = True): - try: result = self.__evaluate() - except EvaluationException as e: - self.__log(f"{e}",level = "ERROR") - return None - if log: - self.log_evaluation_result() - return result + with self.__log_group(): + try: result = self.__evaluate() + except EvaluationException as e: + self.__log(f"{e}",level = "ERROR") + return None + if log: + self.log_evaluation_result() + return result def log_callback_trigger_reason(self): if self.__callback_trigger_reason_log_string: @@ -491,6 +503,10 @@ class Evaluator(): self.__last_evaluation_result = new_result def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False): + with self.__log_group(): + return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation) + + def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False): has_log_trigger_reason = False has_evaluation_result = False diff --git a/smartobject.py b/smartobject.py index ece777a..4cec20f 100644 --- a/smartobject.py +++ b/smartobject.py @@ -183,10 +183,22 @@ class SmartObject(hass.Hass,LoggerInterface): # mute → entity → constants → templates_library → virtual_sensors # → attributes_override → dataset restore → on_initialize_smart_object() def initialize(self): + self.initialize_logger_interface(self.get_ad_api()) + with self.log_group('initialization', self.name) as group: + self.log(f"Initialization started [group=initialization:{group['id']}]") + try: + succeeded = self._initialize_smart_object() + except BaseException: + self.log(f"Initialization failed [group=initialization:{group['id']}]", level='ERROR') + raise + status = 'completed' if succeeded else 'failed' + level = 'INFO' if succeeded else 'ERROR' + self.log(f"Initialization {status} [group=initialization:{group['id']}]", level=level) + + def _initialize_smart_object(self): try: #self.depends_on_module(["smartobject","virtualsensors","logger_interface"]) - self.initialize_logger_interface(self.get_ad_api()) self.log_info(f"Name = {self.name}") # mute: true → suppress all log output for this app try: self.mute_logger(self.args['mute']) @@ -272,8 +284,10 @@ class SmartObject(hass.Hass,LoggerInterface): self.log_info(self.get_dataset_name() + " loaded") except (FileNotFoundError,EOFError): pass #self.log_info("File " + self.get_dataset_name() + " not found (and it's ok)") self.on_initialize_smart_object() - except ParsingException as e: self.log_error(str(e),stop_app = True) - + except ParsingException as e: + self.log_error(str(e), stop_app=True) + return False + return True # AppDaemon shutdown hook. Persists self.dataset to disk (if set). def terminate(self): diff --git a/unit_tests/test_events_forwarder.py b/unit_tests/test_events_forwarder.py index 1ff7f8e..048168d 100644 --- a/unit_tests/test_events_forwarder.py +++ b/unit_tests/test_events_forwarder.py @@ -31,7 +31,11 @@ classes = load_classes(TOOLBOX / 'eventsforwarder.py', { class ForwarderTests(unittest.TestCase): def setUp(self): - self.owner = Mock() + # Do not fabricate optional API methods such as log_group on lookup. + self.owner = Mock(spec=[ + 'entity_exists', 'get_state', 'log', 'log_info', 'log_warning', + 'log_error', 'fire_event', 'call_service', + ]) self.states = {'media_player.bravia_theatre': 'off', 'binary_sensor.is_tv_on': 'off'} self.owner.entity_exists.side_effect = lambda entity: entity in self.states diff --git a/unit_tests/test_logger_interface_grouping.py b/unit_tests/test_logger_interface_grouping.py new file mode 100644 index 0000000..7fb4a78 --- /dev/null +++ b/unit_tests/test_logger_interface_grouping.py @@ -0,0 +1,109 @@ +"""Verify scoped log grouping without replacing AppDaemon API methods.""" +import os +import pickle +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +TOOLBOX = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLBOX)) +from logger_interface import LoggerInterface +import smartcondition + + +def load_smart_object(): + source = (TOOLBOX / 'smartobject.py').read_text() + import ast + tree = ast.parse(source) + smart_object = next(node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == 'SmartObject') + class FakeHass: + def log(self, message, level='INFO', log=None): + self.rows.append((level, message, log)) + namespace = {'hass': SimpleNamespace(Hass=FakeHass), + 'LoggerInterface': LoggerInterface, 'VirtualSensors': object, + 'ParsingException': type('ParsingException', (Exception,), {}), + 'pickle': pickle, 'os': os} + exec(compile(ast.Module(body=[smart_object], type_ignores=[]), str(TOOLBOX / 'smartobject.py'), 'exec'), namespace) + return namespace['SmartObject'] + + +class FakeApp(LoggerInterface): + name = 'fake_app' + + def __init__(self): + self.rows = [] + super().__init__(self) + + def log(self, message, level='INFO', log=None): + self.rows.append((level, message, log)) + + def initialize(self): + self.log_info('LoggerInterface initialization message') + self.log('Direct app log stays readable') + + +class LoggerGroupingTests(unittest.TestCase): + def test_initialize_is_not_implicitly_wrapped_or_tagged(self): + app = FakeApp() + original_initialize = app.initialize.__func__ + original_log = app.log.__func__ + app.initialize() + self.assertIs(app.initialize.__func__, original_initialize) + self.assertIs(app.log.__func__, original_log) + self.assertEqual(app.rows[0][1], 'LoggerInterface initialization message') + self.assertEqual(app.rows[1][1], 'Direct app log stays readable') + + def test_smart_object_groups_its_own_lifecycle(self): + SmartObject = load_smart_object() + app = SmartObject.__new__(SmartObject) + app.rows = [] + app.name = 'test_smart_object' + app.args = {} + with tempfile.TemporaryDirectory() as folder: + app.AD = SimpleNamespace(app_dir=folder) + app.get_ad_api = lambda: app + app.on_initialize_smart_object = lambda: app.log_info('Subclass initialization hook') + app.initialize() + self.assertIn('Initialization started [group=initialization:', app.rows[0][1]) + group_id = app.rows[0][1].split(':', 1)[1].rstrip(']') + self.assertIn(f'[group=initialization:{group_id}]', app.rows[1][1]) + self.assertIn(f'[group=initialization:{group_id}]', app.rows[2][1]) + self.assertEqual(app.rows[-1][1], f'Initialization completed [group=initialization:{group_id}]') + + def test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument(self): + app = FakeApp() + evaluator = smartcondition.Evaluator.__new__(smartcondition.Evaluator) + evaluator._Evaluator__logger_interface = None + evaluator._Evaluator__appdaemon_api = app + evaluator._Evaluator__trace_name = 'smart_conditions' + with evaluator._Evaluator__log_group(): + app.log_info('Callback trigger by sensor.example(0 => 1)') + app.log_info('[smart_conditions][Evaluating] Triggered by condition') + marker_a = app.rows[0][1].split('[group=')[1].rstrip(']') + marker_b = app.rows[1][1].split('[group=')[1].rstrip(']') + self.assertEqual(marker_a, marker_b) + self.assertTrue(marker_a.startswith('smartcondition:')) + + def test_nested_scopes_inherit_outer_group_and_reset_after_exception(self): + app = FakeApp() + with app.log_group('value_selector', 'sensor.a') as outer: + app.log_info('candidate rejected') + with app.log_group('smartcondition', 'condition-a') as inner: + self.assertIs(inner, outer) + app.log_info('condition result') + app.log_info('outside group') + self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[0][1]) + self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[1][1]) + self.assertEqual(app.rows[2][1], 'outside group') + with self.assertRaises(RuntimeError): + with app.log_group('smartcondition'): + raise RuntimeError('expected') + app.log_info('after failure') + self.assertEqual(app.rows[3][1], 'after failure') + + +if __name__ == '__main__': + unittest.main() diff --git a/vscode-appdaemon/media/logExplorer.css b/vscode-appdaemon/media/logExplorer.css index 7066367..a991b56 100644 --- a/vscode-appdaemon/media/logExplorer.css +++ b/vscode-appdaemon/media/logExplorer.css @@ -1,4 +1,8 @@ :root{color-scheme:dark light;--bg:var(--vscode-editor-background,#111820);--fg:var(--vscode-editor-foreground,#dae3ed);--panel:var(--vscode-sideBar-background,#18212c);--border:var(--vscode-panel-border,#334252);--muted:var(--vscode-descriptionForeground,#a0afbf);--accent:var(--vscode-focusBorder,#6caef0)} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px var(--vscode-font-family,system-ui,sans-serif)}header{padding:22px 24px 16px;display:flex;justify-content:space-between;gap:20px;align-items:center}h1{font-size:21px;margin:0}h1 span{font-weight:400;color:var(--muted);font-size:16px;margin-left:10px}p{color:var(--muted);line-height:1.6}header p{margin:7px 0 0}.actions{display:flex;gap:8px}button,input,select{font:inherit;color:var(--fg);background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--accent)}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}nav{display:flex;align-items:center;gap:10px;padding:0 24px 18px;flex-wrap:wrap}nav input[type=search]{flex:1;min-width:220px}nav label{display:flex;align-items:center;gap:6px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(350px,1fr) minmax(380px,1fr);border-top:1px solid var(--border);height:calc(100vh - 150px)}section{overflow:auto;padding:18px 20px}.apps{border-right:1px solid var(--border)}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;gap:10px}h2{font-size:14px;margin:0}.section-title>span{color:var(--muted)}details{border:1px solid var(--border);border-radius:8px;margin-bottom:10px;background:var(--panel)}summary{cursor:pointer;padding:12px;display:flex;align-items:center;gap:10px}summary:before{content:'▸';color:var(--muted)}details[open]>summary:before{content:'▾'}summary .name{flex:1;overflow-wrap:anywhere}.badge{border-radius:5px;padding:3px 6px;font-size:11px;font-weight:600;white-space:nowrap}.INFO,.DEBUG{color:#91c5fa;background:#284561}.WARNING{color:#f3cd80;background:#594925}.ERROR,.CRITICAL{color:#ffacac;background:#5b3039}.rows{border-top:1px solid var(--border)}.row{display:block;width:100%;text-align:left;border:0;border-radius:0;background:transparent;padding:11px 13px;border-bottom:1px solid var(--border)}.row:last-child{border-bottom:0}.row.selected{background:var(--vscode-list-activeSelectionBackground,#263f57);box-shadow:inset 3px 0 var(--accent)}.row-meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;margin-bottom:5px}.row-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.5}.link-hint{margin-left:auto;color:var(--accent)}.empty{padding:40px 15px;color:var(--muted);line-height:1.6;text-align:center}.selected-message{font-size:15px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}.metadata{color:var(--muted);font-size:12px;margin:8px 0 15px}.legend{color:var(--muted);font-size:12px;margin:15px 0}.graph{overflow:auto;position:relative;margin:20px 0;min-height:120px}.graph svg{position:absolute;left:0;top:0;pointer-events:none}.graph-stage{position:relative}.node{position:absolute;width:320px;height:115px;overflow:auto;padding:12px;text-align:left;background:var(--panel);font-size:12px;line-height:1.5}.node strong{display:block;color:var(--accent);margin-bottom:5px}.node .text{white-space:pre-wrap;overflow-wrap:anywhere}.node.target{border-color:var(--accent)}.node.missing{border-style:dashed;color:var(--muted)}.tree ul{padding-left:23px;list-style:none;border-left:1px solid var(--border)}.tree li{margin:12px 0}.tree button{text-align:left;line-height:1.5}.tree .probable{color:#f3cd80;font-size:11px}.values{width:100%;border-collapse:collapse;font-size:12px}.values td{padding:8px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.values td:first-child{color:var(--muted)}#warnings{color:#f3cd80;padding:0 24px}#warnings:empty{display:none}.more{padding:12px;color:var(--muted)}@media(max-width:850px){main{grid-template-columns:1fr;height:auto}.apps{border-right:0;border-bottom:1px solid var(--border)}section{max-height:65vh}header{align-items:flex-start;padding:18px}nav{padding:0 18px 15px}summary{gap:6px}.badge{font-size:10px}} -.app-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}.last-log{color:var(--muted);font-size:11px;font-weight:400;overflow-wrap:anywhere}.row{cursor:pointer;user-select:text;-webkit-user-select:text}.row:hover{background:var(--vscode-list-hoverBackground,#203040)}.selected-message,.row-message,.node,.tree,.values{user-select:text;-webkit-user-select:text}.init-group{margin:10px;background:var(--bg)}.init-summary{font-size:12px;color:var(--muted);flex-wrap:wrap}.init-rows>button{margin:8px 12px}.section-title{flex-wrap:wrap}.section-title select{font-size:12px}.actions{flex-wrap:wrap} +.app-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}.last-log{color:var(--muted);font-size:11px;font-weight:400;overflow-wrap:anywhere}.row{cursor:pointer;user-select:text;-webkit-user-select:text}.row:hover{background:var(--vscode-list-hoverBackground,#203040)}.selected-message,.row-message,.node,.tree,.values{user-select:text;-webkit-user-select:text}.section-title{flex-wrap:wrap}.section-title select{font-size:12px}.actions{flex-wrap:wrap} @keyframes new-log-pulse{0%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}25%{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--accent) 85%,transparent);background-color:color-mix(in srgb,var(--accent) 12%,var(--panel))}100%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}}.new-log-app>summary{animation:new-log-pulse 1.8s ease-out}.recent-log{animation:new-log-pulse 1.8s ease-out}@media(prefers-reduced-motion:reduce){.new-log-app>summary,.recent-log{animation:none;outline:2px solid var(--accent);outline-offset:-2px}} + +.log-message.level-WARNING{color:var(--vscode-editorWarning-foreground,#f3cd80)}.log-message.level-ERROR{color:var(--vscode-editorError-foreground,#ffacac)} + +.block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere} diff --git a/vscode-appdaemon/media/logExplorer.html b/vscode-appdaemon/media/logExplorer.html index dfbd71b..c1c01da 100644 --- a/vscode-appdaemon/media/logExplorer.html +++ b/vscode-appdaemon/media/logExplorer.html @@ -4,7 +4,7 @@
Reading logs…
The base AppDaemon log format does not include causal links.