6Unified Debugging System
7========================
11Provide a single, centralized ``_dbg()`` debug logger for Option C projects,
12together with per-domain level filtering, ANSI color output, timestamped log
13files, stdout/stderr interception, and an uncaught-exception hook.
17- Load debug configuration from ``diagnostics/debug_config.json`` (project
18 root) or the bundled fallback, recording ``CONFIG_SOURCE`` for the log header.
19- Provide ``_dbg()`` with domain/level filtering, caller-site reporting, and
20 optional override mode.
21- Write formatted output to terminal (ANSI colors retained) and/or a log file
22 (all ANSI escape sequences stripped) based on ``OUTPUT_MODE``.
23- Provide ``_StreamInterceptor`` wrapping ``sys.stdout``/``sys.stderr`` with a
24 full ``io.TextIOBase``-compatible interface and a thread-local re-entrant
26- Provide ``init()`` to activate stream interception and the exception hook
27 explicitly; importing this module has zero side effects.
28- Provide ``instrumentation_active()``, ``instrumentation_verbose()``, and
29 ``instrumentation_allow_custom_components()`` helpers consumed by other modules.
41- Importing this module must have zero side effects on ``sys.stdout``,
42 ``sys.stderr``, or ``sys.excepthook``.
43- ``init()`` must be idempotent; calling it more than once is a no-op.
44- ``_dbg()`` must never raise; all internal errors are silently swallowed.
45- ``CONFIG_SOURCE`` must be a non-empty string after module load.
49Debug domains, levels, colors, and global settings are loaded from
50`diagnostics/debug_config.json` in the project root if present, or from
51`debug_config.json` in the same directory as this module as a fallback.
53Each project supplies its own `diagnostics/debug_config.json` with
54project-specific domains. This keeps the library project-independent.
58``OC_DIAGNOSTICS_CONFIG``
59 Absolute path to a ``debug_config.json`` file. When set, this path is
60 used instead of ``<cwd>/diagnostics/debug_config.json``. Useful for
61 embedded usage, subprocess invocation, or CI environments where the
62 working directory is not the project root.
64``OC_DIAGNOSTICS_LOG_DIR``
65 Absolute path to the directory where log files are written. When set,
66 this replaces the default ``<cwd>/logs`` directory.
68``UNIFIED_DEBUG_DISABLE_EXCEPTION_HOOK``
69 Set to ``1`` to prevent ``init()`` from installing the custom
70 ``sys.excepthook``, even when ``intercept_exceptions=True`` is passed.
71 Useful in environments (pytest, debuggers, ASGI frameworks) that install
72 their own exception handlers and must not be displaced.
76When filename inclusion is enabled:
78 [DOMAIN][L2][file.py:123::caller=foo|logical context=bar] 14:03:12.345 Message...
82 [DOMAIN][L2][logical context=bar] 14:03:12.345 Message...
85from __future__
import annotations
99from contextlib
import contextmanager
109 "yellow":
"\033[93m",
111 "magenta":
"\033[95m",
115 "default":
"\033[0m",
125 "additionalProperties": {
127 {
"type":
"integer",
"minimum": 0,
"maximum": 9},
131 "level": {
"type":
"integer",
"minimum": 0,
"maximum": 9},
132 "color": {
"type":
"string"},
134 "required": [
"level"],
144 "DEBUG_LEVELS": _DOMAIN_SCHEMA,
146 "domains": _DOMAIN_SCHEMA,
147 "global_settings": {
"type":
"object"},
152_CONFIG_PATH: str =
""
153_CONFIG_MTIME: float = 0.0
163_MAX_ENV_PATH_LEN: int = 4096
164_MAX_CONFIG_BYTES: int = 10 * 1024 * 1024
168 """Return ``os.environ[name]`` if it passes OI-515 sanity checks.
170 Returns an empty string when the env var is unset, empty, contains a
171 NUL byte, or exceeds :data:`_MAX_ENV_PATH_LEN`. A warning is emitted
172 in the length/NUL-byte case so operators notice the rejection.
174 raw = os.environ.get(name,
"")
180 f
"oc_diagnostics: {name} contains NUL byte — ignored (OI-515)",
185 if len(value) > _MAX_ENV_PATH_LEN:
187 f
"oc_diagnostics: {name} exceeds {_MAX_ENV_PATH_LEN} chars — "
201 """Load debug_config.json from the project diagnostics/ directory if present,
202 otherwise fall back to debug_config.json next to this module.
204 Returns (domains_dict, color_codes_dict, global_settings_dict, config_source_str).
205 Falls back to minimal defaults if the file is missing or invalid.
206 config_source_str describes which file was loaded, or why defaults were used.
208 global _CONFIG_PATH, _CONFIG_MTIME
215 project_config_path = os.path.join(
216 os.getcwd(),
"diagnostics",
"debug_config.json"
219 module_config_path = os.path.join(os.path.dirname(__file__),
"debug_config.json")
222 config_path = env_config_path
223 config_source = f
"ENV:OC_DIAGNOSTICS_CONFIG={env_config_path}"
224 elif os.path.exists(project_config_path):
225 config_path = project_config_path
226 config_source = project_config_path
228 config_path = module_config_path
229 config_source = f
"DEFAULTS (project config not found at: {project_config_path})"
232 _CONFIG_PATH = config_path
234 _CONFIG_MTIME = os.path.getmtime(config_path)
239 default_levels = {
"GENERAL": 3}
240 default_colors = {
"DEFAULT":
"\033[0m",
"ERROR":
"\033[91m"}
242 "silent_mode":
False,
243 "enable_timestamps":
True,
244 "enable_colors":
True,
245 "include_filename":
True,
246 "output_mode":
"both",
247 "instrumentation_enabled":
False,
248 "structural_id_prefixes": [],
249 "structural_id_exact": [],
251 "max_log_size_mb": 0.0,
252 "instrumentation_allow_all":
False,
253 "ui_event_properties": [],
254 "http_redact_headers": [
"authorization",
"cookie",
"set-cookie",
"x-api-key"],
262 size = os.path.getsize(config_path)
263 if size > _MAX_CONFIG_BYTES:
265 f
"oc_diagnostics: config {config_path} exceeds "
266 f
"{_MAX_CONFIG_BYTES} bytes ({size}) — using defaults (OI-515)",
270 config_source = f
"DEFAULTS (config > {_MAX_CONFIG_BYTES} bytes)"
271 return default_levels, default_colors, default_settings, config_source
278 with open(config_path,
"r", encoding=
"utf-8")
as f:
279 config = json.load(f)
280 except (FileNotFoundError, json.JSONDecodeError)
as exc:
281 config_source = f
"DEFAULTS (load error: {exc})"
282 return default_levels, default_colors, default_settings, config_source
286 import jsonschema
as _jsonschema
288 _jsonschema.validate(config, _CONFIG_SCHEMA)
289 except _jsonschema.ValidationError
as _ve:
291 f
"oc_diagnostics: debug_config.json validation error: {_ve.message}",
301 domains = config.get(
"DEBUG_LEVELS")
or config.get(
"domains", {})
303 colors = {
"DEFAULT":
"\033[0m",
"ERROR":
"\033[91m"}
305 for domain_name, domain_def
in domains.items():
306 if isinstance(domain_def, dict):
307 levels[domain_name] = domain_def.get(
"level", 3)
308 color_name = domain_def.get(
"color",
"default")
309 colors[domain_name] = _COLOR_NAME_MAP.get(color_name,
"\033[0m")
310 elif isinstance(domain_def, int):
312 levels[domain_name] = domain_def
313 colors[domain_name] =
"\033[0m"
316 raw_settings = config.get(
"global_settings", {})
319 log_rotation = raw_settings.get(
"log_rotation", {})
321 log_rotation.get(
"max_files", 0)
322 or raw_settings.get(
"max_log_files", 0)
324 max_log_size_mb = float(
325 log_rotation.get(
"max_size_mb", 0)
326 or raw_settings.get(
"max_log_size_mb", 0)
330 log_to_file = raw_settings.get(
"log_to_file",
None)
331 log_file_path = raw_settings.get(
"log_file_path",
None)
332 log_format = raw_settings.get(
"log_format",
None)
335 output_mode = raw_settings.get(
"output_mode",
"both")
336 if log_format ==
"jsonl":
338 elif log_to_file
is False:
339 output_mode =
"terminal"
340 elif log_to_file
is True and output_mode ==
"both":
344 "silent_mode": raw_settings.get(
"silent_mode",
False),
345 "enable_timestamps": raw_settings.get(
"enable_timestamps",
True),
346 "enable_colors": raw_settings.get(
"enable_colors",
True),
347 "include_filename": raw_settings.get(
"include_filename",
True),
348 "output_mode": output_mode,
349 "instrumentation_enabled": raw_settings.get(
"instrumentation_enabled",
False),
351 "structural_id_prefixes": list(raw_settings.get(
"structural_id_prefixes", [])),
352 "structural_id_exact": list(raw_settings.get(
"structural_id_exact", [])),
354 "max_log_files": max_log_files,
355 "max_log_size_mb": max_log_size_mb,
357 "instrumentation_allow_all": bool(raw_settings.get(
"instrumentation_allow_all",
False)),
359 "ui_event_properties": list(raw_settings.get(
"ui_event_properties", [])),
361 "http_redact_headers": list(raw_settings.get(
"http_redact_headers", [
362 "authorization",
"cookie",
"set-cookie",
"x-api-key"
365 "mode": raw_settings.get(
"mode",
"").strip().lower(),
367 "prod_max_level": raw_settings.get(
"prod_max_level", 2),
369 "global_debug_level": raw_settings.get(
"global_debug_level",
None),
371 "watch_config": bool(raw_settings.get(
"watch_config",
False)),
373 "ai_trace_level": bool(raw_settings.get(
"ai_trace_level",
False)),
375 "redaction_patterns": list(raw_settings.get(
"redaction_patterns", [])),
377 "redact_secrets": bool(raw_settings.get(
"redact_secrets",
True)),
379 "log_file_path": log_file_path,
383 gdl = settings[
"global_debug_level"]
384 if gdl
is not None and isinstance(gdl, int)
and 0 <= gdl <= 9:
385 for domain_name
in levels:
386 levels[domain_name] = gdl
388 return levels, colors, settings, config_source
397DEBUG_LEVELS = _levels
400CONFIG_SOURCE = _config_source
401INSTRUMENTATION_ENABLED = _settings[
"instrumentation_enabled"]
402SILENT_MODE = _settings[
"silent_mode"]
406ADDITIONAL_STRUCTURAL_PREFIXES: tuple[str, ...] = tuple(
407 _settings.get(
"structural_id_prefixes", [])
409ADDITIONAL_STRUCTURAL_EXACT: frozenset[str] = frozenset(
410 _settings.get(
"structural_id_exact", [])
412ENABLE_TIMESTAMPS = _settings[
"enable_timestamps"]
413ENABLE_COLORS = _settings[
"enable_colors"]
414INCLUDE_FILENAME = _settings[
"include_filename"]
415OUTPUT_MODE = _settings[
"output_mode"]
418MAX_LOG_FILES: int = _settings[
"max_log_files"]
419MAX_LOG_SIZE_BYTES: int = (
420 int(_settings[
"max_log_size_mb"] * 1024 * 1024)
421 if _settings[
"max_log_size_mb"] > 0
else 0
425HTTP_REDACT_HEADERS: frozenset[str] = frozenset(
426 h.lower()
for h
in _settings.get(
"http_redact_headers",
427 [
"authorization",
"cookie",
"set-cookie",
"x-api-key"])
431INSTRUMENTATION_ALL: bool = _settings.get(
"instrumentation_allow_all",
False)
434_DEFAULT_UI_EVENT_PROPERTIES: tuple[str, ...] = (
435 "n_clicks",
"value",
"data",
"figure",
"selectedData"
437UI_EVENT_PROPERTIES: tuple[str, ...] = tuple(
438 _settings.get(
"ui_event_properties")
or _DEFAULT_UI_EVENT_PROPERTIES
444 os.environ.get(
"OC_DIAGNOSTICS_MODE",
"").strip().lower()
in (
"prod",
"production",
"safe")
445 or _settings.get(
"mode",
"")
in (
"prod",
"production",
"safe")
454 PROD_MAX_LEVEL: int = int(_settings.get(
"prod_max_level", 2))
455except (TypeError, ValueError):
457_prod_downgrade_warned: set[str] = set()
462AI_TRACE_ENABLED: bool = bool(_settings.get(
"ai_trace_level",
False))
465_env_level = os.environ.get(
"OC_DIAGNOSTICS_LEVEL",
"").strip()
466if _env_level.isdigit()
and 0 <= int(_env_level) <= 9:
467 _env_level_int = int(_env_level)
468 for _k
in DEBUG_LEVELS:
469 DEBUG_LEVELS[_k] = _env_level_int
474REDACTION_PATTERNS: list[re.Pattern] = []
475for _pat
in _settings.get(
"redaction_patterns", []):
477 REDACTION_PATTERNS.append(
479 rf
'(?i)({re.escape(_pat)})\s*[=:]\s*(?:"[^"]*"|\'[^\']*\'|\S+)',
488REDACT_SECRETS: bool = bool(_settings.get(
"redact_secrets",
True))
493_GIT_URL_RE: re.Pattern[str] = re.compile(
494 r"(?P<scheme>https?://)[^/@\s]+@",
499_TOKEN_PREFIXES_RE: re.Pattern[str] = re.compile(
500 r"\b(?P<prefix>ghp_|gho_|ghu_|ghs_|ghr_|sk-|xoxb-|xoxp-|eyJ)[A-Za-z0-9_\-\.]{10,}",
505_KV_SECRET_RE: re.Pattern[str] = re.compile(
506 r"(?i)\b(?P<key>token|secret|api[_-]?key|password|passwd|"
507 r"auth[_-]?token|access[_-]?token|bearer)\s*[=:]\s*"
508 r"(?:\"[^\"]*\"|'[^']*'|\S+)",
515LOG_DIR =
_safe_env_path(
"OC_DIAGNOSTICS_LOG_DIR")
or os.path.join(os.getcwd(),
"logs")
521_config_log_file_path = _settings.get(
"log_file_path")
526LOG_FILE: str |
None = _env_log_file
or _config_log_file_path
or None
528ORIGINAL_STDOUT = sys.stdout
529ORIGINAL_STDERR = sys.stderr
531_header_written =
False
532_log_lock = threading.Lock()
536 """Return the LOG_FILE path, computing it on the first call.
538 Always invoked from within a ``_log_lock``-protected block so the lazy
539 one-time initialization is thread-safe. After the first call ``LOG_FILE``
540 is a non-None string and subsequent calls are a single None-check.
544 LOG_FILE = os.path.join(
546 f
"unified_debug_logs_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt",
556_ANSI_ESCAPE_RE = re.compile(
557 r'\033(?:\[[^A-Za-z]*[A-Za-z]|\][^\007\033]*(?:\007|\033\\)|.)'
566 if not ENABLE_TIMESTAMPS:
568 ts = datetime.datetime.now().strftime(
"%H:%M:%S.%f")[:-3]
573 if not ENABLE_COLORS:
576 return COLOR_CODES.get(
"ERROR",
"\033[91m")
577 return COLOR_CODES.get(domain, COLOR_CODES.get(
"DEFAULT",
"\033[0m"))
581 return COLOR_CODES.get(
"DEFAULT",
"\033[0m")
if ENABLE_COLORS
else ""
585 """Delete the oldest log files when the count exceeds max_count. (FS-06)"""
588 pattern = os.path.join(log_dir,
"unified_debug_logs_*.txt")
589 files = sorted(_glob.glob(pattern), key=os.path.getmtime)
590 for path
in files[:-max_count]:
600 global _header_written
607 os.makedirs(LOG_DIR, exist_ok=
True)
609 if MAX_LOG_FILES > 0:
611 is_new_file =
not os.path.exists(log_file)
613 with open(log_file,
"a", encoding=
"utf-8")
as f:
615 now = datetime.datetime.now().strftime(
"%Y-%m-%d %H:%M:%S")
616 f.write(
"============================================================\n")
617 f.write(
" Unified Debug Log\n")
618 f.write(
"============================================================\n")
619 f.write(f
"Created at : {now}\n")
620 f.write(f
"Log file : {log_file}\n")
621 f.write(f
"Config source : {CONFIG_SOURCE}\n")
622 f.write(f
"SILENT_MODE : {SILENT_MODE}\n")
623 f.write(f
"INSTRUMENTATION_ENABLED: {INSTRUMENTATION_ENABLED}\n")
624 f.write(f
"ENABLE_TIMESTAMPS: {ENABLE_TIMESTAMPS}\n")
625 f.write(f
"INCLUDE_FILENAME: {INCLUDE_FILENAME}\n")
626 f.write(f
"ENABLE_COLORS : {ENABLE_COLORS}\n")
627 f.write(f
"OUTPUT_MODE : {OUTPUT_MODE}\n")
628 f.write(
"DEBUG_LEVELS :\n")
629 for dom, lvl
in sorted(DEBUG_LEVELS.items()):
630 f.write(f
" - {dom}: {lvl}\n")
631 f.write(
"============================================================\n\n")
633 _header_written =
True
639 global LOG_FILE, _header_written, OUTPUT_MODE, _io_fallback_warned
643 if MAX_LOG_SIZE_BYTES > 0:
647 if os.path.exists(lf)
and os.path.getsize(lf) > MAX_LOG_SIZE_BYTES:
649 _header_written =
False
656 except OSError
as _exc:
659 if not _io_fallback_warned:
660 _io_fallback_warned =
True
662 ORIGINAL_STDOUT.write(
663 f
"\n[oc_diagnostics] WARNING: file log write failed "
664 f
"({_exc}); switching OUTPUT_MODE to 'terminal'.\n"
666 ORIGINAL_STDOUT.flush()
669 OUTPUT_MODE =
"terminal"
675 """Apply configured redaction patterns to log output (production mode only).
677 OI-506 / FS-506 layer order (production mode):
679 1. Built-in secret/URL/token scrubbers (gated by ``REDACT_SECRETS``):
680 git ``user:token@`` credentials, common token prefixes (``ghp_``,
681 ``sk-``, ``xoxb-``, ``eyJ``...), and ``secret=value`` KV pairs.
682 2. User-defined ``REDACTION_PATTERNS`` from config.
684 The built-ins run unconditionally when ``REDACT_SECRETS`` is True so
685 production logs cannot leak credentials even if the operator forgot
686 to configure a pattern list. The toggle exists purely as an escape
687 hatch for environments that redact at the shipping layer instead.
692 text = _GIT_URL_RE.sub(
r"\g<scheme>***@", text)
693 text = _TOKEN_PREFIXES_RE.sub(
r"\g<prefix>[REDACTED]", text)
694 text = _KV_SECRET_RE.sub(
lambda m: f
"{m.group('key')}=[REDACTED]", text)
695 for pattern
in REDACTION_PATTERNS:
696 text = pattern.sub(
lambda m: f
"{m.group(1)}=[REDACTED]", text)
700def _emit(line: str, level: int, record: dict |
None =
None):
707 if OUTPUT_MODE
in (
"terminal",
"both",
"json"):
709 ORIGINAL_STDOUT.write(line +
"\n")
710 ORIGINAL_STDOUT.flush()
715 if OUTPUT_MODE ==
"json":
716 if record
is not None:
718 elif OUTPUT_MODE
in (
"file",
"both"):
719 plain = _ANSI_ESCAPE_RE.sub(
"", line)
727def _dbg(domain: str, func: str, msg: str, level: int | str |
None =
None, override: bool =
False, _depth: int = 1):
729 Unified debug logger.
734 Debug domain (e.g., "CONTROLLER", "AHK", "MODE").
736 Logical context name provided by the caller.
739 level : int | str | None
740 Debug level (0-9), or ``"ai_trace"`` for AI-Trace shadow logging.
743 If True, bypasses domain-level filtering.
747 - Automatically captures filename, line number, and caller function.
748 - Formats output according to INCLUDE_FILENAME and ENABLE_TIMESTAMPS.
749 - Writes to terminal, file, or both.
750 - When ``level="ai_trace"``: writes to file/JSONL only (no console output).
751 Requires ``ai_trace_level: true`` in config; otherwise silently discarded.
757 if level ==
"ai_trace":
758 if not AI_TRACE_ENABLED:
767 if not _initialized
and not _init_warned:
770 "_dbg() called before init() — log file output and stream interception "
771 "are not yet active. Call oc_diagnostics.init() at application startup.",
773 stacklevel=_depth + 1,
776 effective_level = level
if level
is not None else 2
781 if PROD_MODE
and isinstance(effective_level, int)
and effective_level > PROD_MAX_LEVEL:
782 _key = f
"{domain}:{effective_level}"
783 if _key
not in _prod_downgrade_warned:
784 _prod_downgrade_warned.add(_key)
786 f
"[WARN] _dbg() L{effective_level} suppressed in production mode "
787 f
"(domain={domain}, max=L{PROD_MAX_LEVEL}). This warning is one-shot.",
793 frame = sys._getframe(_depth)
794 filename = os.path.basename(frame.f_code.co_filename)
795 lineno = frame.f_lineno
796 caller_func = frame.f_code.co_name
805 f
"{filename}:{lineno}::caller={caller_func}|logical context={func}"
808 location = f
"logical context={func}"
811 record: dict |
None =
None
812 if OUTPUT_MODE ==
"json":
814 "ts": datetime.datetime.now().isoformat(timespec=
"milliseconds"),
816 "level": effective_level,
820 "caller": caller_func,
822 "override": override,
826 line = f
"{color}[{domain}][OVERRIDE][{location}] {ts}{msg}{reset}"
827 _emit(line, effective_level, record=record)
830 allowed = DEBUG_LEVELS.get(domain, 0)
831 if effective_level > allowed:
834 line = f
"{color}[{domain}][L{effective_level}][{location}] {ts}{msg}{reset}"
835 _emit(line, effective_level, record=record)
839 """Write an AI-Trace message to file/JSONL only (never console).
841 Called internally by ``_dbg()`` when ``level="ai_trace"``.
843 frame = sys._getframe(_depth)
844 filename = os.path.basename(frame.f_code.co_filename)
845 lineno = frame.f_lineno
846 caller_func = frame.f_code.co_name
851 location = f
"{filename}:{lineno}::caller={caller_func}|logical context={func}"
853 location = f
"logical context={func}"
855 if OUTPUT_MODE ==
"json":
857 "ts": datetime.datetime.now().isoformat(timespec=
"milliseconds"),
863 "caller": caller_func,
868 elif OUTPUT_MODE
in (
"file",
"both"):
869 plain = f
"[{domain}][AI_TRACE][{location}] {ts}{msg}"
881 level: int |
None =
None,
882 override: bool =
False,
884 """Async variant of _dbg().
886 Terminal output is written synchronously (fast, low-latency).
887 File I/O is offloaded to a thread via asyncio.to_thread() so that
888 async callers are not blocked by disk writes.
890 The caller frame is captured *synchronously* before any await because
891 frame references become invalid once the coroutine is suspended.
896 effective_level = level
if level
is not None else 2
899 frame = sys._getframe(1)
900 filename = os.path.basename(frame.f_code.co_filename)
901 lineno = frame.f_lineno
902 caller_func = frame.f_code.co_name
905 allowed = DEBUG_LEVELS.get(domain, 0)
906 if effective_level > allowed:
914 location = f
"{filename}:{lineno}::caller={caller_func}|logical context={func}"
916 location = f
"logical context={func}"
919 line = f
"{color}[{domain}][OVERRIDE][{location}] {ts}{msg}{reset}"
921 line = f
"{color}[{domain}][L{effective_level}][{location}] {ts}{msg}{reset}"
924 if OUTPUT_MODE
in (
"terminal",
"both",
"json"):
926 ORIGINAL_STDOUT.write(line +
"\n")
927 ORIGINAL_STDOUT.flush()
932 if OUTPUT_MODE
in (
"file",
"both"):
933 plain = _ANSI_ESCAPE_RE.sub(
"", line)
934 await asyncio.to_thread(_write_to_file, plain)
935 elif OUTPUT_MODE ==
"json":
937 "ts": datetime.datetime.now().isoformat(timespec=
"milliseconds"),
939 "level": effective_level,
943 "caller": caller_func,
945 "override": override,
947 await asyncio.to_thread(_write_to_file, json.dumps(record, ensure_ascii=
False))
955 if not INSTRUMENTATION_ENABLED:
957 return DEBUG_LEVELS.get(
"INSTRUMENTATION", 0) >= 1
961 return DEBUG_LEVELS.get(
"INSTRUMENTATION", 0) >= 4
965 return DEBUG_LEVELS.get(
"INSTRUMENTATION", 0) >= 5
969 """Return True when all components should be instrumented regardless of type. (FS-13)"""
970 return INSTRUMENTATION_ALL
978 """Set the debug level for a domain at runtime (0–9).
980 Updates ``DEBUG_LEVELS`` immediately; takes effect on the next ``_dbg()``
981 call. Emits a ``UserWarning`` and returns without modifying anything if
982 ``level`` is not an integer in 0–9.
984 if not isinstance(level, int)
or level < 0
or level > 9:
986 f
"set_debug_level: invalid level {level!r} for domain {domain!r}. "
987 "Must be an integer 0-9.",
992 DEBUG_LEVELS[domain] = level
996 """Update a global diagnostic setting at runtime.
998 Valid keys: ``silent_mode``, ``enable_timestamps``, ``enable_colors``,
999 ``include_filename``, ``output_mode``. Emits a ``UserWarning`` for
1000 unknown keys and returns without modifying anything.
1002 global SILENT_MODE, ENABLE_TIMESTAMPS, ENABLE_COLORS, INCLUDE_FILENAME, OUTPUT_MODE
1004 "silent_mode":
"SILENT_MODE",
1005 "enable_timestamps":
"ENABLE_TIMESTAMPS",
1006 "enable_colors":
"ENABLE_COLORS",
1007 "include_filename":
"INCLUDE_FILENAME",
1008 "output_mode":
"OUTPUT_MODE",
1010 if key
not in _SETTABLE:
1012 f
"set_global_setting: unknown key {key!r}. "
1013 f
"Valid keys: {sorted(_SETTABLE)}",
1018 globals()[_SETTABLE[key]] = value
1026def dbg_timed(domain: str, func: str, label: str =
"", level: int = 3):
1027 """Context manager that logs ENTER and EXIT with elapsed time in milliseconds.
1031 with dbg_timed("CONTROLLER", "my_func", label="heavy_op"):
1034 tag = f
"[{label}] " if label
else ""
1035 _dbg(domain, func, f
"{tag}ENTER", level=level, _depth=2)
1036 t0 = time.perf_counter()
1040 elapsed_ms = (time.perf_counter() - t0) * 1000
1041 _dbg(domain, func, f
"{tag}EXIT ({elapsed_ms:.1f} ms)", level=level, _depth=2)
1045def dbg_scope(domain: str, func: str, label: str =
"", level: int = 2):
1046 """Context manager that logs ENTER and EXIT at the specified level without timing.
1048 Use for pure lifecycle tracing where elapsed time is not needed.
1049 For timed tracing, use :func:`dbg_timed` instead.
1053 with dbg_scope("DATA", "load_records"):
1054 result = expensive_operation()
1056 tag = f
"[{label}] " if label
else ""
1057 _dbg(domain, func, f
"{tag}ENTER", level=level, _depth=2)
1061 _dbg(domain, func, f
"{tag}EXIT", level=level, _depth=2)
1064def dbg_profile(domain: str =
"GENERAL", func: str =
"", label: str =
"", level: int = 3):
1065 """Decorator that wraps a function with :func:`dbg_timed`.
1069 @dbg_profile("CONTROLLER", label="fetch_data")
1075 tag = label
or fn.__name__
1076 @functools.wraps(fn)
1077 def wrapper(*args, **kwargs):
1078 with dbg_timed(domain, func
or fn.__name__, label=tag, level=level):
1079 return fn(*args, **kwargs)
1092 func: str |
None =
None,
1094 """Domain-aware runtime assertion, suppressed in production mode.
1099 Debug domain for the assertion message.
1101 Expression to evaluate. If falsy (and not in production mode),
1102 emits an L1 override ``_dbg`` message and raises ``AssertionError``.
1104 Descriptive failure message.
1106 Logical context name. If ``None``, uses ``"<unknown>"``.
1110 In ``PROD_MODE`` this function returns immediately on entry; no
1111 assertion check is performed, no ``_dbg`` is emitted, and no
1112 ``AssertionError`` is raised. Argument expressions (including
1113 ``condition`` and ``message``) are still evaluated by Python before
1114 the call enters this function — do not pass expensive computations
1115 as arguments. **Never** use for safety-critical invariants — see §6a
1116 and AP-16 in the specification.
1121 ctx = func
or "<unknown>"
1122 _dbg(domain, ctx, f
"ASSERT FAILED: {message}", 1, override=
True, _depth=2)
1123 raise AssertionError(message)
1131 """Decorator that emits START/END lifecycle messages and catches exceptions.
1133 Injects a ``func`` local variable set to the decorated function's
1134 ``__name__`` so that inner ``_dbg`` calls can reference it without
1135 hardcoding a string.
1140 Debug domain for lifecycle messages.
1142 Debug level for START/END messages (default 2).
1149 def process_data(data):
1150 _dbg("DATA", func, "working...", 3)
1151 return transform(data)
1154 @functools.wraps(fn)
1155 def wrapper(*args, **kwargs):
1157 _dbg(domain, func,
"START", level=level, _depth=2)
1159 result = fn(*args, **kwargs)
1160 except Exception
as exc:
1161 _dbg(domain, func, f
"EXCEPTION: {type(exc).__name__}: {exc}", level=1, override=
True, _depth=2)
1163 _dbg(domain, func,
"END", level=level, _depth=2)
1174 """Decorator that logs structured performance metrics (time + memory).
1176 Emits a JSON-style string with ``duration_ms`` and ``memory_mb`` fields
1177 suitable for observability tooling.
1182 Debug domain for the metrics message.
1184 Debug level (default 3).
1187 @functools.wraps(fn)
1188 def wrapper(*args, **kwargs):
1193 import tracemalloc
as _tm
1194 if not _tm.is_tracing():
1196 mem_before = _tm.get_traced_memory()[0]
1200 t0 = time.perf_counter()
1202 result = fn(*args, **kwargs)
1204 elapsed_ms = (time.perf_counter() - t0) * 1000
1206 if mem_before
is not None:
1208 import tracemalloc
as _tm
1209 mem_after = _tm.get_traced_memory()[0]
1210 mem_mb = (mem_after - mem_before) / (1024 * 1024)
1213 metrics = json.dumps({
1214 "duration_ms": round(elapsed_ms, 2),
1215 "memory_mb": round(mem_mb, 2),
1217 _dbg(domain, func, f
"METRICS: {metrics}", level=level, _depth=2)
1228_original_excepthook =
None
1232 tb_lines = traceback.format_exception(exc_type, exc_value, exc_traceback)
1233 tb_text =
"".join(tb_lines).rstrip()
1239 header_line = f
"{color}[PYERR][L1][UNCAUGHT] {ts}Uncaught exception:{reset}"
1240 _emit(header_line, 1)
1242 for line
in tb_text.splitlines():
1243 formatted = f
"{color}[PYERR][L1][TRACE] {ts}{line}{reset}"
1255 if _original_excepthook
is not None and _original_excepthook
is not _exception_hook:
1273_interceptor_active = threading.local()
1278 Wraps sys.stdout or sys.stderr, routing all output through _dbg().
1280 Implements the full io.TextIOBase-compatible interface so that
1281 third-party libraries (tqdm, click, rich, logging, etc.) that
1282 call isatty(), fileno(), or access .encoding on sys.stdout do not
1283 receive AttributeError after oc_diagnostics.init() is called.
1288 self.
_original = ORIGINAL_STDOUT
if stream_name ==
"stdout" else ORIGINAL_STDERR
1295 if getattr(_interceptor_active,
"value",
False):
1306 _interceptor_active.value =
True
1308 domain =
"STDOUT" if self.
stream_name ==
"stdout" else "STDERR"
1309 for line
in text.splitlines():
1310 stripped = line.rstrip()
1324 _dbg(domain,
"stream", stripped, level=2, override=
True, _depth=2)
1326 _interceptor_active.value =
False
1345 return getattr(self.
_original,
"encoding",
"utf-8")
1349 return getattr(self.
_original,
"errors",
"strict")
1365 """Always True — _StreamInterceptor does not buffer writes."""
1370 """Not available — _StreamInterceptor wraps a text stream, not bytes."""
1371 raise io.UnsupportedOperation(
"buffer")
1374 """Not supported — raises UnsupportedOperation."""
1375 raise io.UnsupportedOperation(
"detach")
1378 """Not supported — raises UnsupportedOperation."""
1379 raise io.UnsupportedOperation(
"reconfigure")
1387 """Reload debug_config.json in-place, updating all module-level globals.
1389 Preserves the existing ``DEBUG_LEVELS`` dict *object* so that downstream
1390 modules holding a reference to the dict automatically see the updated levels.
1392 Uses an update-first, prune-stale-after pattern (OI-29) to avoid the brief
1393 empty-dict window that ``clear()`` + ``update()`` would create.
1395 global COLOR_CODES, CONFIG_SOURCE, INSTRUMENTATION_ENABLED, SILENT_MODE
1396 global ADDITIONAL_STRUCTURAL_PREFIXES, ADDITIONAL_STRUCTURAL_EXACT
1397 global ENABLE_TIMESTAMPS, ENABLE_COLORS, INCLUDE_FILENAME, OUTPUT_MODE
1398 global MAX_LOG_FILES, MAX_LOG_SIZE_BYTES, HTTP_REDACT_HEADERS
1399 global INSTRUMENTATION_ALL, UI_EVENT_PROPERTIES, _CONFIG_MTIME
1400 global AI_TRACE_ENABLED, REDACTION_PATTERNS, REDACT_SECRETS
1404 DEBUG_LEVELS.update(levels)
1405 for k
in list(DEBUG_LEVELS):
1408 COLOR_CODES = colors
1409 CONFIG_SOURCE = source
1410 INSTRUMENTATION_ENABLED = settings[
"instrumentation_enabled"]
1411 SILENT_MODE = settings[
"silent_mode"]
1412 ADDITIONAL_STRUCTURAL_PREFIXES = tuple(settings.get(
"structural_id_prefixes", []))
1413 ADDITIONAL_STRUCTURAL_EXACT = frozenset(settings.get(
"structural_id_exact", []))
1414 ENABLE_TIMESTAMPS = settings[
"enable_timestamps"]
1415 ENABLE_COLORS = settings[
"enable_colors"]
1416 INCLUDE_FILENAME = settings[
"include_filename"]
1417 OUTPUT_MODE = settings[
"output_mode"]
1418 MAX_LOG_FILES = settings[
"max_log_files"]
1419 MAX_LOG_SIZE_BYTES = (
1420 int(settings[
"max_log_size_mb"] * 1024 * 1024)
1421 if settings[
"max_log_size_mb"] > 0
else 0
1423 HTTP_REDACT_HEADERS = frozenset(
1424 h.lower()
for h
in settings.get(
"http_redact_headers", [])
1426 INSTRUMENTATION_ALL = bool(settings.get(
"instrumentation_allow_all",
False))
1427 UI_EVENT_PROPERTIES = tuple(
1428 settings.get(
"ui_event_properties")
or _DEFAULT_UI_EVENT_PROPERTIES
1430 AI_TRACE_ENABLED = bool(settings.get(
"ai_trace_level",
False))
1432 for pat
in settings.get(
"redaction_patterns", []):
1434 new_patterns.append(re.compile(rf
'(?i)({re.escape(pat)})\s*[=:]\s*\S+'))
1437 REDACTION_PATTERNS = new_patterns
1438 REDACT_SECRETS = bool(settings.get(
"redact_secrets",
True))
1442 """Daemon thread: poll the config file mtime every 2 s; reload on change. (FS-09)"""
1443 global _CONFIG_MTIME
1446 if not _CONFIG_PATH:
1449 mtime = os.path.getmtime(_CONFIG_PATH)
1450 if mtime != _CONFIG_MTIME:
1451 _CONFIG_MTIME = mtime
1463_io_fallback_warned =
False
1467 intercept_streams: bool =
True,
1468 intercept_exceptions: bool =
True,
1469 watch_config: bool =
False,
1472 Initialize oc_diagnostics side effects.
1474 Must be called explicitly after importing oc_diagnostics.
1475 Importing the package does NOT activate stream interception or
1476 the custom exception hook automatically.
1480 intercept_streams : bool
1481 If True, replace sys.stdout and sys.stderr with _StreamInterceptor
1482 instances that route all print() calls through _dbg().
1483 Can also be disabled by setting the environment variable
1484 UNIFIED_DEBUG_DISABLE_STDIO_INTERCEPT=1 before calling init().
1485 Forced to False in production mode (PROD_MODE=True). Default True.
1486 intercept_exceptions : bool
1487 If True, install a custom sys.excepthook that logs uncaught
1488 exceptions through _dbg() and then delegates to the previously
1489 active hook. Can also be disabled by setting the environment
1490 variable UNIFIED_DEBUG_DISABLE_EXCEPTION_HOOK=1 before calling
1491 init(). Forced to False in production mode (PROD_MODE=True).
1494 If True, start a daemon thread that polls the loaded config file
1495 every 2 seconds and reloads it when the mtime changes. Useful
1496 during development to adjust log levels without restarting the
1497 application. Default False. (FS-09)
1501 Calling init() more than once is a no-op.
1503 global _initialized, _original_excepthook
1508 intercept_streams =
False
1509 intercept_exceptions =
False
1514 disable_hook = os.environ.get(
"UNIFIED_DEBUG_DISABLE_EXCEPTION_HOOK") ==
"1"
1515 if intercept_exceptions
and not disable_hook:
1516 _original_excepthook = sys.excepthook
1517 sys.excepthook = _exception_hook
1519 disable_intercept = os.environ.get(
"UNIFIED_DEBUG_DISABLE_STDIO_INTERCEPT") ==
"1"
1520 if intercept_streams
and not disable_intercept:
1525 effective_watch = watch_config
or _settings.get(
"watch_config",
False)
1528 if effective_watch
and _CONFIG_PATH:
1529 t = threading.Thread(target=_watch_config_loop, daemon=
True)
reconfigure(self, **_kwargs)
__init__(self, str stream_name)
str _apply_redaction(str text)
_exception_hook(exc_type, exc_value, exc_traceback)
bool instrumentation_allow_custom_components()
bool instrumentation_verbose()
_dbg_ai_trace(str domain, str func, str msg, int _depth=1)
None _watch_config_loop()
_emit(str line, int level, dict|None record=None)
None init(bool intercept_streams=True, bool intercept_exceptions=True, bool watch_config=False)
_ensure_log_dir_and_header()
dbg_scope(str domain, str func, str label="", int level=2)
None _prune_old_log_files(str log_dir, int max_count)
dbg_trace(str domain, int level=2)
str _get_color(str domain, int level)
dbg_profile(str domain="GENERAL", str func="", str label="", int level=3)
str _safe_env_path(str name)
None _dbg_assert(str domain, condition, str message, str|None func=None)
None set_global_setting(str key, value)
bool instrumentation_active()
dbg_metrics(str domain, int level=3)
bool instrumentation_allow_all()
dbg_timed(str domain, str func, str label="", int level=3)
_dbg(str domain, str func, str msg, int|str|None level=None, bool override=False, int _depth=1)
None _adbg(str domain, str func, str msg, int|None level=None, bool override=False)
None set_debug_level(str domain, int level)