Option C oc_diagnostics
Loading...
Searching...
No Matches
ui_event_logger.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_diagnostics/ui_event_logger.py
4
5"""
6UI Event Logger
7===============
8
9Purpose
10-------
11Register a single Dash callback that captures all user interactions across
12an instrumented Dash application and routes every genuine event through
13``_dbg()``.
14
15Responsibilities
16----------------
17- Register a universal ``ALL``-pattern ``Input`` callback that listens to
18 ``n_clicks``, ``value``, ``data``, ``figure``, and ``selectedData`` on
19 all instrumented components (those whose IDs were rewritten to
20 ``{"event": "<original-id>"}``).
21- Use ``ctx.triggered_prop_ids`` to build the set of ``(original_id,
22 prop_name)`` pairs that actually triggered the current cycle, filtering
23 out components that are non-``None`` but did not change (e.g. a Dropdown
24 holding its default selection).
25- Log each genuine user interaction via
26 ``_dbg("UI", ..., override=True)`` so UI events always appear in the log
27 regardless of domain debug levels.
28- Truncate large event values before logging (``_MAX_EVENT_VALUE_LEN``).
29 For ``figure`` and ``data`` properties, log type + length summary unless
30 ``DEBUG_LEVELS["UI"] >= 9``.
31- Emit a clear L1 diagnostic warning when the required
32 ``dcc.Store(id=REQUIRED_STORE_ID)`` component is absent from
33 ``app.layout`` at registration time.
34
35Diagnostics
36-----------
37Domain: UI
38Levels:
39 L2 — lifecycle
40 L3 — semantic details
41 L4 — deep tracing
42
43Contracts
44---------
45- Must only log events that actually triggered the current callback cycle.
46- Must route all log output through ``_dbg()`` with ``override=True``.
47- Requires Dash (``pip install oc_diagnostics[dash]``).
48- Must return ``no_update`` from the callback so the store is not dirtied.
49- Must not raise; logging failures must be silently swallowed.
50- Event values must be truncated to ``_MAX_EVENT_VALUE_LEN`` characters in
51 ``repr()`` form before being passed to ``_dbg()``.
52- A L1 warning must be emitted when ``REQUIRED_STORE_ID`` is not found in
53 ``app.layout`` at ``register_callbacks()`` time.
54"""
55
56from dash import Input, Output, State, ALL, ctx, no_update
57from oc_diagnostics.unified_debug import _dbg, DEBUG_LEVELS, UI_EVENT_PROPERTIES
58
59
60# ============================================================
61# PUBLIC CONSTANT — required dcc.Store ID
62# ============================================================
63
64REQUIRED_STORE_ID = "debug-settings-data-store"
65"""The ``id`` of the ``dcc.Store`` component that the UI event logger callback
66writes to. This store must be present in the Dash layout before
67``register_callbacks()`` is called; if it is absent Dash will raise a generic
68callback registration error at startup."""
69
70
71# ============================================================
72# PRIVATE CONSTANTS
73# ============================================================
74
75_MAX_EVENT_VALUE_LEN = 500
76"""Maximum length (in characters) of the ``repr()`` of a logged event value.
77
78Values whose repr exceeds this limit are truncated and annotated with the
79original length. ``figure`` and ``data`` properties are summarised as
80``<type len=N>`` unless ``DEBUG_LEVELS["UI"] >= 9``.
81"""
82
83# Properties whose payloads can be very large; summarised at low debug levels.
84_STRUCTURED_PROPS = frozenset({"figure", "data"})
85
86
87# ============================================================
88# INTERNAL HELPERS
89# ============================================================
90
91def _format_event_value(prop_name: str, value) -> str:
92 """Return a log-safe string representation of *value*.
93
94 For structured properties (``figure``, ``data``) the full repr is only
95 returned when ``DEBUG_LEVELS["UI"] >= 9``; otherwise a concise type +
96 length summary is returned. For all other properties the repr is
97 truncated at ``_MAX_EVENT_VALUE_LEN`` characters.
98 """
99 if prop_name in _STRUCTURED_PROPS:
100 if DEBUG_LEVELS.get("UI", 0) < 9:
101 length = len(value) if isinstance(value, (dict, list)) else "?"
102 return f"<{type(value).__name__} len={length}> (set UI level=9 for full value)"
103
104 raw = repr(value)
105 if len(raw) > _MAX_EVENT_VALUE_LEN:
106 return raw[:_MAX_EVENT_VALUE_LEN] + f"... [{len(raw)} chars total]"
107 return raw
108
109
110def _find_component_id(node, target_id: str, _depth: int = 0) -> bool:
111 """Recursively search *node* (a Dash layout) for a component with *target_id*.
112
113 Returns ``True`` as soon as a match is found; stops at depth 100 to
114 avoid performance issues on very large layouts.
115 """
116 if _depth > 100 or node is None:
117 return False
118 if callable(node):
119 # Callable layout — cannot inspect before first request; skip check.
120 return False
121 if getattr(node, "id", None) == target_id:
122 return True
123 children = getattr(node, "children", None)
124 if isinstance(children, (list, tuple)):
125 return any(_find_component_id(ch, target_id, _depth + 1) for ch in children)
126 if children is not None:
127 return _find_component_id(children, target_id, _depth + 1)
128 return False
129
130
131def _check_store_present(app) -> None:
132 """Emit a L1 warning if ``REQUIRED_STORE_ID`` is absent from ``app.layout``.
133
134 Silently skips the check when:
135 - ``app.layout`` is ``None`` (layout not yet assigned), or
136 - ``app.layout`` is a callable (resolved at request time).
137 """
138 try:
139 layout = getattr(app, "layout", None)
140 if layout is None:
141 _dbg(
142 "UI",
143 "register_callbacks",
144 f"WARNING: app.layout is None — cannot verify "
145 f"'{REQUIRED_STORE_ID}' is present. "
146 f"Add dcc.Store(id='{REQUIRED_STORE_ID}') before calling "
147 f"register_callbacks().",
148 level=1,
149 override=True,
150 )
151 return
152 if callable(layout):
153 return # Callable layout resolved at request time; skip check.
154 if not _find_component_id(layout, REQUIRED_STORE_ID):
155 _dbg(
156 "UI",
157 "register_callbacks",
158 f"WARNING: '{REQUIRED_STORE_ID}' not found in app.layout — "
159 f"the UI event logger callback will fail at runtime. "
160 f"Add dcc.Store(id='{REQUIRED_STORE_ID}', storage_type='memory') "
161 f"to the layout before calling register_callbacks().",
162 level=1,
163 override=True,
164 )
165 except Exception:
166 pass
167
168
169# ============================================================
170# REGISTER CALLBACKS
171# ============================================================
172
174 """
175 Register the universal UI event logger callback.
176
177 This callback listens to the following properties on ALL components
178 whose IDs were instrumented into {"event": "<original-id>"}:
179
180 - n_clicks
181 - value
182 - data
183 - figure
184 - selectedData
185
186 These cover all interactive Dash components:
187 Buttons, Dropdowns, Inputs, Checklists, Sliders, Stores,
188 Graphs, Tabs, etc.
189
190 Dash requires explicit property names (no ALL wildcard allowed),
191 so we register one Input() per property.
192
193 Prerequisites
194 -------------
195 The layout must contain ``dcc.Store(id=REQUIRED_STORE_ID, ...)``. A L1
196 warning is emitted at registration time when this component is not found.
197
198 Note
199 ----
200 The ``ui_event_properties`` setting is read once at callback registration
201 time. Changes to ``global_settings.ui_event_properties`` (via config
202 hot-reload or ``set_global_setting()``) will NOT be picked up by an
203 already-registered callback — a full application restart is required.
204 """
205
207 _dbg("UI", "register_callbacks", "UI event logger registered", override=True)
208
209 @app.callback(
210 Output(REQUIRED_STORE_ID, "data", allow_duplicate=True),
211 # FS-10: property list is configurable via global_settings.ui_event_properties
212 [Input({"event": ALL}, prop) for prop in UI_EVENT_PROPERTIES],
213 State({"event": ALL}, "id"),
214 prevent_initial_call=True,
215 )
216 def _log_ui_events(*args):
217 # OI-28: *args accepts any number of Input() lists so this callback
218 # works correctly regardless of the UI_EVENT_PROPERTIES length.
219 # Positional layout: one list per UI_EVENT_PROPERTIES entry (in
220 # registration order), followed by the event_ids list from State().
221 n_props = len(UI_EVENT_PROPERTIES)
222 prop_lists = args[:n_props]
223 event_ids = args[n_props] if len(args) > n_props else []
224
225 func = "_log_ui_events"
226
227 if not event_ids:
228 _dbg("UI", func, "No event_ids received", override=True)
229 return no_update
230
231 # Build a set of (original_event_id, prop_name) pairs that actually
232 # triggered this callback cycle. ctx.triggered_prop_ids maps each
233 # Dash prop_id string (e.g. '{"event":"btn"}.n_clicks') to its id
234 # dict. We parse the property name from the trailing ".prop" segment
235 # and the original component id from the id dict so we can filter the
236 # inner loop to real user-driven changes only.
237 triggered_set: set[tuple[str, str]] = set()
238 for prop_id_str, id_dict in (ctx.triggered_prop_ids or {}).items():
239 if "." in prop_id_str and isinstance(id_dict, dict) and "event" in id_dict:
240 prop_part = prop_id_str.rsplit(".", 1)[1]
241 triggered_set.add((id_dict["event"], prop_part))
242
243 # Iterate dynamically over whatever properties were registered.
244 for prop_name, values in zip(UI_EVENT_PROPERTIES, prop_lists):
245 for value, eid in zip(values, event_ids):
246
247 # Skip noise (None means "no change")
248 if value is None:
249 continue
250
251 original_id = eid.get("event")
252
253 # Skip components whose value is non-None but did not change
254 # this cycle (e.g. a Dropdown holding its default selection).
255 if (original_id, prop_name) not in triggered_set:
256 continue
257
258 formatted = _format_event_value(prop_name, value)
259 _dbg(
260 "UI",
261 func,
262 f"EVENT: id={original_id}, property={prop_name}, value={formatted}",
263 override=True,
264 )
265
266 return no_update
267
268
269# Backwards-compatible alias used in your self-test app
270def ui_event_logger(app):
271 """Convenience wrapper to match existing usage."""
bool _find_component_id(node, str target_id, int _depth=0)
str _format_event_value(str prop_name, value)