From cded4a0200e200265f9cad4c8dc2b9f3e7b51681 Mon Sep 17 00:00:00 2001 From: Pierre Gironde Date: Sun, 27 Sep 2026 22:54:08 +0200 Subject: [PATCH] New group logging --- logger_interface.py | 71 +++++++++--- smartcondition.py | 30 +++-- smartobject.py | 20 +++- unit_tests/test_events_forwarder.py | 6 +- unit_tests/test_logger_interface_grouping.py | 109 +++++++++++++++++++ vscode-appdaemon/media/logExplorer.css | 6 +- vscode-appdaemon/media/logExplorer.html | 2 +- vscode-appdaemon/media/logExplorer.js | 70 ++++++------ vscode-appdaemon/src/logExplorer.ts | 29 ++++- vscode-appdaemon/src/logModel.ts | 6 +- vscode-appdaemon/tests/logExplorer.test.js | 15 +++ vscode-appdaemon/tests/logModel.test.js | 6 + vscode-appdaemon/tests/logUi.test.js | 29 ++++- vscode-appdaemon/vscode-appdaemon-0.2.0.vsix | Bin 52342 -> 53205 bytes 14 files changed, 324 insertions(+), 75 deletions(-) create mode 100644 unit_tests/test_logger_interface_grouping.py 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 @@ AppDaemon · Logs

AppDaemon Logs

Reading logs…

- +

Applications

Log details

Select a log to view its details.

The base AppDaemon log format does not include causal links.

diff --git a/vscode-appdaemon/media/logExplorer.js b/vscode-appdaemon/media/logExplorer.js index d2dfcda..9d83da5 100644 --- a/vscode-appdaemon/media/logExplorer.js +++ b/vscode-appdaemon/media/logExplorer.js @@ -15,7 +15,6 @@ const saved = vscode.getState() || {}; let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', paused = false, selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected; const expanded = new Set(saved.expanded || []); - const expandedInit = new Set(saved.expandedInit || []); const visibleEventGroups = new Map(); const recentEvents = new Set(); const recentApps = new Set(); @@ -25,7 +24,7 @@ const $ = id => document.getElementById(id); const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }; const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level; - function save() { vscode.setState({ selected, mode, origin, expanded: [...expanded], expandedInit: [...expandedInit], sort: $('sort').value, groupInit: $('group-init').checked }); } + function save() { vscode.setState({ selected, mode, origin, expanded: [...expanded], sort: $('sort').value }); } function choose(id, fromList = true) { if (fromList) origin = id; selected = id; save(); renderApps(); renderDetail(); } function filtered() { const q = $('search').value.toLowerCase(), level = $('level').value, minutes = Number($('period').value); @@ -34,7 +33,7 @@ && (level === 'all' || normalLevel(e) === level) && `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q)); } - const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[\w-]+\]/g, ''); + const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[\w-]+\]/g, '').replace(/ \[group=(?:initialization|smartcondition|value_selector):[a-f0-9]{12}\]/gi, ''); const formatTime = (entry, includeYear = false) => { if (!Number.isFinite(entry.time)) return entry.timestamp; try { @@ -57,6 +56,22 @@ return `${years} year${years === 1 ? '' : 's'} ago`; } function copy(text) { if (text) vscode.postMessage({ type: 'copy', text }); } + function logMessages(items, cls) { + const container = node('div', undefined, cls); + items.forEach((entry, index) => { + if (index) container.append(document.createTextNode('\n')); + container.append(node('span', cleanMessage(entry), `log-message level-${normalLevel(entry)}`)); + }); + return container; + } + function blockTitle(items, groupKey) { + const first = items[0]; + const kind = groupKey?.startsWith('initialization:') || first.initialization_id ? 'initialization' : items.find(entry => entry.group_kind)?.group_kind; + const names = { initialization: 'Initialization', smartcondition: 'Smart condition', value_selector: 'Value selector' }; + const name = names[kind] || (items.length > 1 || first.trace_id ? 'Event' : 'Log'); + const label = kind === 'initialization' ? first.app : items.map(entry => entry.group_label || cleanMessage(entry).match(/^\[([^\]]+)\]/)?.[1]).find(Boolean) || first.entity || first.app; + return node('div', `${name} · ${label}`, 'block-title'); + } function row(e) { const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`); item.dataset.eventId = e.event_id; item.dataset.eventIds = e.event_id; @@ -67,23 +82,23 @@ relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`; meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`)); if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint')); - item.append(meta, node('div', cleanMessage(e), 'row-message')); + item.append(blockTitle([e]), meta, logMessages([e], 'row-message')); item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); }; item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(e.event_id); } }; return item; } - function groupedByEvent(items) { + function groupedByEvent(items, groupKey) { const groups = []; items.forEach(e => { - const trace = e.kind === 'log' ? e.trace_id : undefined; + const key = groupKey || (e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined); const previous = groups[groups.length - 1]; - if (trace && previous?.trace === trace) previous.entries.push(e); - else groups.push({ trace, entries: [e] }); + if (key && previous?.key === key) previous.entries.push(e); + else groups.push({ key, entries: [e] }); }); return groups; } - function appendEventRows(parent, items) { - groupedByEvent(items).forEach(unit => { + function appendEventRows(parent, items, groupKey) { + groupedByEvent(items, groupKey).forEach(unit => { if (unit.entries.length === 1) { parent.append(row(unit.entries[0])); return; } const representative = [...unit.entries].reverse().find(e => e.cause_ids?.length) || unit.entries[unit.entries.length - 1]; const ids = unit.entries.map(e => e.event_id); @@ -102,7 +117,7 @@ if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`)); } if (unit.entries.some(e => e.cause_ids.length)) meta.append(node('span', 'View causes ↗', 'link-hint')); - item.append(meta, node('div', unit.entries.map(cleanMessage).join('\n'), 'row-message')); + item.append(blockTitle(unit.entries, groupKey), meta, logMessages(unit.entries, 'row-message')); item.onclick = () => { if (!window.getSelection()?.toString()) choose(representative.event_id); }; item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(representative.event_id); } }; parent.append(item); @@ -145,14 +160,14 @@ const rows = node('div', undefined, 'rows'); const units = [], initGroups = new Map(); logs.forEach(e => { - if ($('group-init').checked && e.initialization_id) { + if (e.initialization_id) { if (!initGroups.has(e.initialization_id)) { const unit = { id: e.initialization_id, entries: [] }; initGroups.set(e.initialization_id, unit); units.push(unit); } initGroups.get(e.initialization_id).entries.push(e); } else { - const trace = e.kind === 'log' ? e.trace_id : undefined; + const key = e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined; const previous = units[units.length - 1]; - if (trace && previous && !previous.id && previous.trace === trace) previous.entries.push(e); - else units.push({ trace, entries: [e] }); + if (key && previous && !previous.id && previous.key === key) previous.entries.push(e); + else units.push({ key, entries: [e] }); } }); const startTime = unit => unit.id @@ -160,25 +175,7 @@ : unit.entries[0].time; units.sort((a, b) => startTime(a) - startTime(b)); units.slice(-150).forEach(unit => { - if (!unit.id) { appendEventRows(rows, unit.entries); return; } - const group = node('details', undefined, 'init-group'), key = app + ':' + unit.id; - group.open = expandedInit.has(key); - const first = entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0], last = unit.entries[unit.entries.length - 1]; - const header = node('summary', undefined, 'init-summary'); - const failed = unit.entries.some(e => normalLevel(e) === 'ERROR' || e.status === 'failed'); - header.append(node('span', `Initialization · ${formatTime(first, true)}`, 'name'), node('span', `${unit.entries.length} entries`)); - if (failed) header.append(node('span', 'ERROR', 'badge ERROR')); - header.title = `Initialization ${unit.id} · Last entry: ${formatTime(last, true)} · Exact: ${last.timestamp}`; - group.append(header); - const body = node('div', undefined, 'init-rows'); - const copyGroup = node('button', 'Copy initialization'); - copyGroup.onclick = () => copy(unit.entries.map(copyText).join('\n')); - body.append(copyGroup); - appendEventRows(body, unit.entries.slice(-150)); - if (unit.entries.length > 150) body.append(node('div', 'Showing the last 150 entries. Copy initialization includes all loaded matches.', 'more')); - group.append(body); - group.addEventListener('toggle', event => { event.stopPropagation(); group.open ? expandedInit.add(key) : expandedInit.delete(key); save(); }); - rows.append(group); + appendEventRows(rows, unit.entries, unit.id ? `initialization:${unit.id}` : undefined); }); if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more')); detail.append(rows); @@ -194,7 +191,7 @@ $('graph-mode').parentElement.hidden = !e || !e.cause_ids.length; if (!e) { container.replaceChildren(node('div', selected ? 'This entry is no longer in the loaded history.' : 'Select a log to view its details.', 'empty')); return; } const selectedGroup = visibleEventGroups.get(e.event_id) || [e]; - container.replaceChildren(node('div', selectedGroup.map(cleanMessage).join('\n'), 'selected-message'), node('div', `${e.app} · ${e.timestamp} · ${selectedGroup.length} log lines`, 'metadata')); + container.replaceChildren(logMessages(selectedGroup, 'selected-message'), node('div', `${e.app} · ${e.timestamp} · ${selectedGroup.length} log lines`, 'metadata')); const raw = node('button', 'Open raw log'); raw.onclick = () => vscode.postMessage({ type: 'open', id: e.event_id }); container.append(raw); if (e.source) { const source = node('button', 'Open source'); source.onclick = () => vscode.postMessage({ type: 'source', id: e.event_id }); container.append(source); } if (origin && origin !== selected) { const back = node('button', 'Back to selected log'); back.onclick = () => choose(origin); container.append(back); } @@ -344,13 +341,12 @@ } }); $('copy').onclick = () => { const e = entries.find(e => e.event_id === selected); copy(window.getSelection()?.toString() || (e ? (visibleEventGroups.get(e.event_id) || [e]).map(copyText).join('\n') : '')); }; - ['search', 'period', 'level', 'group-init', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); })); + ['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); })); $('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; if (!paused && latest) apply(latest); else if (paused) $('status').textContent = 'Display paused · New events are still being collected'; }; $('import').onclick = () => vscode.postMessage({ type: 'import' }); $('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); }; $('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); }; $('sort').value = saved.sort || 'latest'; - $('group-init').checked = saved.groupInit !== false; vscode.postMessage({ type: 'ready' }); if (window.LOG_REVIEW_DATA) { apply({ timezone: 'Europe/Berlin', entries: window.LOG_REVIEW_DATA, warnings: ['Interface preview: sample data, no AppDaemon connection.'], limit: 10000 }); expanded.add('automation'); selected = 'review-5'; renderApps(); renderDetail(); } })(); diff --git a/vscode-appdaemon/src/logExplorer.ts b/vscode-appdaemon/src/logExplorer.ts index 5181692..e767d8e 100644 --- a/vscode-appdaemon/src/logExplorer.ts +++ b/vscode-appdaemon/src/logExplorer.ts @@ -169,13 +169,40 @@ export class LogExplorer implements vscode.Disposable { } finally { this.busy = false; } } private publish() { - const entries = [...this.entries.values()].sort((a, b) => a.time - b.time); + const entries = [...this.entries.values()].sort((a, b) => { + const time = a.time - b.time; + if (time) { return time; } + // Include raw app.log() lines written in the same second as initialization markers. + const priority = (entry: LogEntry) => /Initialization (?:started|completed|failed)/.test(entry.message) + ? (entry.message.includes('started') ? -1 : 1) : 0; + return priority(a) - priority(b); + }); // Legacy errors duplicated across main/error logs are merged only on exact match. const seen = new Set(); const unique = entries.filter(e => { const key = e.trace_id ? e.event_id : `${e.timestamp}|${e.app}|${e.level}|${e.message}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); + // Direct app.log() calls have no LoggerInterface tag. Infer their initialization + // group while an explicit per-app initialize() scope is open. + const activeInitialization = new Map(); + for (const entry of unique) { + const marker = entry.message.match(/Initialization (started|completed|failed) \[group=initialization:([a-f0-9]{12})\]/i); + if (marker?.[1] === 'started') { activeInitialization.set(entry.app, marker[2]); } + const activeId = activeInitialization.get(entry.app); + if (activeId && !entry.group_id) { + entry.group_id = activeId; + entry.group_kind = 'initialization'; + entry.initialization_id = activeId; + entry.phase = 'initialization'; + entry.group_inferred = true; + } + if (entry.group_kind === 'initialization') { + entry.initialization_id = entry.initialization_id || entry.group_id; + entry.phase = 'initialization'; + } + if (marker && (marker[1] === 'completed' || marker[1] === 'failed')) { activeInitialization.delete(entry.app); } + } const zone = vscode.workspace.getConfiguration('appdaemon').get('logTimezone', 'Europe/Berlin'); this.activeWebview?.postMessage({ type: 'entries', entries: unique, warnings: [...this.warnings], limit: 10000, timezone: zone }); } diff --git a/vscode-appdaemon/src/logModel.ts b/vscode-appdaemon/src/logModel.ts index f2c419d..887e29e 100644 --- a/vscode-appdaemon/src/logModel.ts +++ b/vscode-appdaemon/src/logModel.ts @@ -4,6 +4,7 @@ export interface LogEntry { entity?: string; values?: Record; source?: { path: string; line: number }; initialization_id?: string; phase?: string; status?: string; + group_id?: string; group_kind?: string; group_inferred?: boolean; file?: string; raw?: string; time: number; trace_id?: string; [key: string]: unknown; } @@ -38,8 +39,11 @@ export function parseLine(line: string, file: string, offset: number, zone = 'Eu const m = line.match(/^(\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+([^:]+):\s?(.*)$/); if (!m) { return undefined; } const init = m[4].match(/\[init=([\w-]+)\]/); + const group = m[4].match(/\[group=(initialization|smartcondition|value_selector):([a-f0-9]{12})\]/i); const id = m[4].match(/\[event=([\w-]+) trace=([\w-]+)\]/); - return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], initialization_id: init?.[1], phase: init ? 'initialization' : undefined, timestamp: m[1], time: rawTime(m[1], zone), app: m[3].trim(), level: m[2], message: m[4], kind: 'log', cause_ids: [], file, raw: line }; + const groupKind = group?.[1]?.toLowerCase(); + const initializationId = groupKind === 'initialization' ? group?.[2] : init?.[1]; + return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], initialization_id: initializationId, phase: initializationId ? 'initialization' : undefined, group_id: group?.[2], group_kind: groupKind, timestamp: m[1], time: rawTime(m[1], zone), app: m[3].trim(), level: m[2], message: m[4], kind: 'log', cause_ids: [], file, raw: line }; } /** Preserve valid UTF-8 spans, including files containing mixed legacy bytes. */ diff --git a/vscode-appdaemon/tests/logExplorer.test.js b/vscode-appdaemon/tests/logExplorer.test.js index caf5dd3..27e7fc4 100644 --- a/vscode-appdaemon/tests/logExplorer.test.js +++ b/vscode-appdaemon/tests/logExplorer.test.js @@ -45,3 +45,18 @@ test('bounds retained history',()=>{ e.poll();e.poll();assert.ok(e.entries.size<=10000); }); process.on('exit',()=>fs.rmSync(root,{recursive:true,force:true})); + +test('groups plain app.log lines between explicit initialization markers',()=>{ + const e=explorer();messages=[]; + fs.writeFileSync(file,[ + '27-09-26 10:12:13 INFO kitchen: Initialization started [group=initialization:0123456789ab]', + '27-09-26 10:12:13 INFO kitchen: Setting up an output through app.log()', + '27-09-26 10:12:14 INFO kitchen: Configured helper [group=initialization:0123456789ab]', + '27-09-26 10:12:14 INFO kitchen: Initialization completed [group=initialization:0123456789ab]' + ].join('\n')+'\n');e.poll(); + const grouped=latest().filter(entry=>entry.app==='kitchen'); + assert.equal(grouped.length,4); + assert.deepEqual(grouped.map(entry=>entry.initialization_id),Array(4).fill('0123456789ab')); + assert.equal(grouped[1].group_inferred,true); + assert.equal(grouped[2].group_inferred,undefined); +}); diff --git a/vscode-appdaemon/tests/logModel.test.js b/vscode-appdaemon/tests/logModel.test.js index e30b441..cd6d846 100644 --- a/vscode-appdaemon/tests/logModel.test.js +++ b/vscode-appdaemon/tests/logModel.test.js @@ -27,3 +27,9 @@ test('raw initialization IDs are retained without structured events', () => { const e=parseLine('27-09-26 10:12:13 INFO app: Setting up [event=e1 trace=t1] [init=init1]','log',0); assert.equal(e.initialization_id,'init1'); assert.equal(e.phase,'initialization'); }); + +test('parses readable group tags without changing the log message format', () => { + const e=parseLine('27-09-26 10:12:13 INFO app: Selected value [group=value_selector:0123456789ab]','log',0); + assert.equal(e.group_id,'0123456789ab'); assert.equal(e.group_kind,'value_selector'); + assert.equal(e.initialization_id,undefined); assert.match(e.message,/\[group=value_selector:/); +}); diff --git a/vscode-appdaemon/tests/logUi.test.js b/vscode-appdaemon/tests/logUi.test.js index 4376f05..27d44a1 100644 --- a/vscode-appdaemon/tests/logUi.test.js +++ b/vscode-appdaemon/tests/logUi.test.js @@ -26,18 +26,20 @@ test('English interface, latest timestamps, sorting and separate initialization d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input')); assert.equal(d.querySelector('[data-app]').dataset.app,'alpha'); const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); - assert.equal(alpha.querySelectorAll('.init-group').length,2); - d.getElementById('group-init').checked=false;d.getElementById('group-init').dispatchEvent(new dom.window.Event('input')); - assert.equal(d.querySelectorAll('.init-group').length,0); - assert.equal(d.querySelector('[data-app="alpha"]').querySelectorAll('.row').length,4); + assert.equal(alpha.querySelectorAll('.rows>.row').length,3); + assert.equal(alpha.querySelectorAll('.event-log-group').length,1); + assert.equal(alpha.querySelector('.event-log-group .row-message').textContent,'Preparing sensors\nReady'); + assert.equal(alpha.querySelectorAll('.rows details').length,0); + assert.equal(d.getElementById('group-init'),null); assert.deepEqual(errors,[]);dom.window.close(); }); test('copies selected logs and entire initialization groups through VS Code transport',()=>{ const {dom,d,sent,errors}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); [...alpha.querySelectorAll('.row')].find(e=>e.textContent.includes('Température déjà disponible')).click();d.getElementById('copy').click(); assert.equal(sent.at(-1).type,'copy');assert.match(sent.at(-1).text,/Température déjà disponible/); - const groups=[...alpha.querySelectorAll('.init-group')]; - groups.find(e=>e.textContent.includes('Preparing sensors')).querySelector('.init-rows>button').click(); + const group=alpha.querySelector('.event-log-group'); + group.click();d.getElementById('copy').click(); + assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/); assert.match(sent.at(-1).text,/Preparing sensors/);assert.match(sent.at(-1).text,/Ready/);assert.doesNotMatch(sent.at(-1).text,/Restarting/); const input=d.getElementById('search'),paste=new dom.window.KeyboardEvent('keydown',{key:'v',ctrlKey:true,bubbles:true,cancelable:true});input.dispatchEvent(paste);assert.equal(paste.defaultPrevented,false); assert.deepEqual(errors,[]);dom.window.close(); @@ -140,3 +142,18 @@ test('shows and refreshes relative time on each log entry while retaining exact dom.window.close(); }); + +test('shows consecutive logs from one explicit group as a single visible row',()=>{ + const {dom,d,entries,send}=setup(); + const group=[ + {...entries[3],event_id:'selector-1',time:entries[3].time+1000,message:'Rejected candidate',group_id:'0123456789ab',group_kind:'value_selector'}, + {...entries[3],event_id:'selector-2',time:entries[3].time+1000,message:'Selected candidate',group_id:'0123456789ab',group_kind:'value_selector'} + ]; + send(group); + const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); + const rows=[...app.querySelectorAll('.rows>.row')].filter(row=>row.textContent.includes('Rejected candidate')); + assert.equal(rows.length,1);assert.ok(rows[0].classList.contains('event-log-group')); + assert.match(rows[0].querySelector('.row-message').textContent,/Rejected candidate\nSelected candidate/); + assert.doesNotMatch(rows[0].textContent,/\[group=/); + dom.window.close(); +}); diff --git a/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix b/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix index bcff4957ec32da889de8eba21d4bb8cbe0d3944f..9e2097aaea2a7929f7718ddb681a37bdcb3d05bb 100644 GIT binary patch delta 20581 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

6tHyK z+GE@+KID7lI_W!S^x_4W)fmYXjbhcuOc+=1UUD5|r5@tF=)=5pr@~T~l8&=h(DT&L z*Mq$hY-PSx6zGoT*%ahg;oZ>UAhpmSb7i#l{t#GvZqjvjxSA06V7ujgG=-Oj$rCc#(K_5QJfwg+dKaXLrd9FbnSGrLa9qrgx$QzCD< z3T+1GC!X0fq1gF?6SL#QhC{8AWqgN;0}aE}aT8+)~)g zEZ@LgCajvlcSN&+O8#uYAW`p1=%>`;vj|zG}HBwJA%8U(9SV5-GOM@62ppRlwc=QB88^`4R zVsQv0UP8&a_PV|2Kxq4H^;ig=M+_ov&_tZdQ(TKkw-*;Qg87lW$pQzi3@dL}Qr1F5 zDVrFwSXA&622Gu974Y0fyqPrFWubkv&SKH)^ovD1*@^?-s>t!y<#V~c_3iG%NDz5M zJIksXxdY_wHlBN8JI?*_YHuptqLt}?_CtEsVJ$!YF)^%g^x1n3XQ8ukNK@Me^ z3<3V&x4%C3M603M4CSw2&`dV$jt(wb>UjeDdB5>}SlEIdmM7SDUyW0= zLB)V%kJhbCuaeYTq*q|URnsw}l50*FRLM{(ALk;akiM$AwBx(%o1}r*{q6U?icTZ+ z+^^^cQ8P}|o#^tF;$7zlzWTUuu7!7(M}cOZBF_fDiHO&ip}kJV9k(Sex6N3!rm?S$ znz0Ua#;mPX^YV^F+>fVeO|JanP4HHOH)Newbvi98a{u6v@4dKTBVBod7CAdg# zDmb4XSD@RXR+hlh)_t1!DKyV6bp;1)XNPgFE}SF6-%n2u=Rq*q9Hw5o5TTV3`O+AH z)RWgKpEF~bKke@3Q+`rTJP2<^46madN(Zn92CsKcR51K(ZR3XN6#fh?;ae@YXhUD6tQ zIl_$Sj>E<2|0Wyt#Ux#I>_;pHDz~~(Cb7`R>#wH64V=ZhKb@WXoJCR)78;r}B^nyt z|2n(>shOB+Lg!V8QGA~=j#zY3`($|3)ghKFkjU@E@$^{Ako6rKwFcUfE!g3K-U9R- zk?O$56EtqyB3Ykzv6HeF*dSZuKfm`IJQiwIONsPrUgA0QXQ13|e5IJ-^lA|i+Ii{y z@p><1^@6D#B`zr`DZBUcC&}Fj(>3cDthq4>6znA!9AfE_iQ;N+_4eF3PUxwPpZwzR z24>Mx+6@QGrXRcrAFBp;`5f@V4i~ON)`?CUeAZ}a#4aq-GV>+80wB{_3~#TktwS+K zhQaSFD6S-bE)9Chf*)d{N?s1tLx&LJ)VhxTX-aP4;T@fS8=Tni}tPvYv0)=-j_h&!M4LW#6!2EV_*= z?rXG-=+?wVK^>XHFSC8h%?PVTbw)(^1D5W@|2SD{4gMxF4BOWJj*fh5st*cu=%gofntEcJw-i+hs1R%6I+hmrjSrE9Hb3e< z{eHBYETO^XrnW=|uCI7lN>k;_NI!T`G`B{NC{#+xSM1Cl60TSqD2>CarT#`i z7S2uP<)Tc&)2`oJ9Hn5wjK^hauFF}DQ{-8QZD^k0-i%EIhUI z^m)8Dmi@!v1cJ{|T%^>lu$U>Ewq1Jetf+Mv|H&tB%3=ajGi81=pqz8lkKxQWF)!vL6 zD-$w6)p6aH7ni;gKe$@VPkl5l5pOnK0%``sqZ7xb#e0rTFUY&eP`gEq!eh4Ebx%nJ zQ5QSq*dU`T*R2jQf_a(r9g(DlV-Yzo4G@dsx)nVG=9b^~NJ$l*0P#Lk?T|~W%X89t z;k2j*Kl*|PgoA;w!MTw&G5I+;fx@QP@;Rvrem!?J)}VrJU1Pip(h%E{n?a{)-bq+)kz!!?(zI|;iru~oRGnIWeu z%+1ifhs?mmJ&ZO3>YyM%b`Xpa550$sK*Bu?3! zH-w>!CH{9<4jrTadJh?a`FqIN0W7vs0SqHxj3+(7k4|#HdW0BzX5jBj{sTH} BjV%BG delta 19681 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