Option C oc_diagnostics
Loading...
Searching...
No Matches
unified_debug.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_diagnostics/unified_debug.py
4
5"""
6Unified Debugging System
7========================
8
9Purpose
10-------
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.
14
15Responsibilities
16----------------
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
25 recursion guard.
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.
30
31Diagnostics
32-----------
33Domain: GENERAL
34Levels:
35 L2 — lifecycle
36 L3 — semantic details
37 L4 — deep tracing
38
39Contracts
40---------
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.
46
47Configuration
48-------------
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.
52
53Each project supplies its own `diagnostics/debug_config.json` with
54project-specific domains. This keeps the library project-independent.
55
56Environment Variables
57---------------------
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.
63
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.
67
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.
73
74Log Format
75----------
76When filename inclusion is enabled:
77
78 [DOMAIN][L2][file.py:123::caller=foo|logical context=bar] 14:03:12.345 Message...
79
80When disabled:
81
82 [DOMAIN][L2][logical context=bar] 14:03:12.345 Message...
83"""
84
85from __future__ import annotations
86
87import asyncio
88import datetime
89import functools
90import io
91import json
92import os
93import re
94import sys
95import threading
96import time
97import traceback
98import warnings
99from contextlib import contextmanager
100
101
102# ============================================================
103# COLOR NAME → ANSI CODE MAPPING
104# ============================================================
105
106_COLOR_NAME_MAP = {
107 "red": "\033[91m",
108 "green": "\033[92m",
109 "yellow": "\033[93m",
110 "blue": "\033[94m",
111 "magenta": "\033[95m",
112 "cyan": "\033[96m",
113 "white": "\033[97m",
114 "gray": "\033[90m",
115 "default": "\033[0m",
116}
117
118
119# ============================================================
120# CONFIG SCHEMA (FS-18: optional jsonschema validation)
121# ============================================================
122
123_DOMAIN_SCHEMA = {
124 "type": "object",
125 "additionalProperties": {
126 "oneOf": [
127 {"type": "integer", "minimum": 0, "maximum": 9},
128 {
129 "type": "object",
130 "properties": {
131 "level": {"type": "integer", "minimum": 0, "maximum": 9},
132 "color": {"type": "string"},
133 },
134 "required": ["level"],
135 },
136 ]
137 },
138}
139
140_CONFIG_SCHEMA = {
141 "type": "object",
142 "properties": {
143 # V2.0 spec key
144 "DEBUG_LEVELS": _DOMAIN_SCHEMA,
145 # Legacy key (backwards compat)
146 "domains": _DOMAIN_SCHEMA,
147 "global_settings": {"type": "object"},
148 },
149}
150
151# FS-09: config path and mtime tracked so the hot-reload watcher can detect changes.
152_CONFIG_PATH: str = ""
153_CONFIG_MTIME: float = 0.0
154
155# OI-515: hardening for ``OC_DIAGNOSTICS_*`` env vars. The config loader and
156# log-directory resolver accept operator-supplied paths, so we apply three
157# containment rules before trusting them:
158# * length cap (``_MAX_ENV_PATH_LEN``) — refuses absurd strings that
159# likely originate from shell-splice accidents.
160# * NUL byte rejection — POSIX paths cannot embed ``\0``.
161# * size cap (``_MAX_CONFIG_BYTES``) — a 10 MiB ceiling on the config
162# file so a compromised or runaway env var cannot blow memory.
163_MAX_ENV_PATH_LEN: int = 4096
164_MAX_CONFIG_BYTES: int = 10 * 1024 * 1024
165
166
167def _safe_env_path(name: str) -> str:
168 """Return ``os.environ[name]`` if it passes OI-515 sanity checks.
169
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.
173 """
174 raw = os.environ.get(name, "")
175 value = raw.strip()
176 if not value:
177 return ""
178 if "\x00" in value:
179 warnings.warn(
180 f"oc_diagnostics: {name} contains NUL byte — ignored (OI-515)",
181 UserWarning,
182 stacklevel=2,
183 )
184 return ""
185 if len(value) > _MAX_ENV_PATH_LEN:
186 warnings.warn(
187 f"oc_diagnostics: {name} exceeds {_MAX_ENV_PATH_LEN} chars — "
188 "ignored (OI-515)",
189 UserWarning,
190 stacklevel=2,
191 )
192 return ""
193 return value
194
195
196# ============================================================
197# CONFIG LOADER
198# ============================================================
199
201 """Load debug_config.json from the project diagnostics/ directory if present,
202 otherwise fall back to debug_config.json next to this module.
203
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.
207 """
208 global _CONFIG_PATH, _CONFIG_MTIME
209
210 # Resolution order:
211 # 1. OC_DIAGNOSTICS_CONFIG env var (absolute path)
212 # 2. <cwd>/diagnostics/debug_config.json (project root convention)
213 # 3. debug_config.json next to this module (bundled fallback / tests)
214 env_config_path = _safe_env_path("OC_DIAGNOSTICS_CONFIG")
215 project_config_path = os.path.join(
216 os.getcwd(), "diagnostics", "debug_config.json"
217 )
218 # Fallback: next to this module (useful for tests)
219 module_config_path = os.path.join(os.path.dirname(__file__), "debug_config.json")
220
221 if env_config_path:
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
227 else:
228 config_path = module_config_path
229 config_source = f"DEFAULTS (project config not found at: {project_config_path})"
230
231 # FS-09: track resolved config path for hot-reload watcher
232 _CONFIG_PATH = config_path
233 try:
234 _CONFIG_MTIME = os.path.getmtime(config_path)
235 except OSError:
236 _CONFIG_MTIME = 0.0
237
238 # Fallback defaults
239 default_levels = {"GENERAL": 3}
240 default_colors = {"DEFAULT": "\033[0m", "ERROR": "\033[91m"}
241 default_settings = {
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": [],
250 "max_log_files": 0,
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"],
255 "mode": "",
256 "prod_max_level": 2,
257 }
258
259 # OI-515: refuse oversized config files (10 MiB cap) so a runaway
260 # or malicious env var cannot blow memory at import time.
261 try:
262 size = os.path.getsize(config_path)
263 if size > _MAX_CONFIG_BYTES:
264 warnings.warn(
265 f"oc_diagnostics: config {config_path} exceeds "
266 f"{_MAX_CONFIG_BYTES} bytes ({size}) — using defaults (OI-515)",
267 UserWarning,
268 stacklevel=2,
269 )
270 config_source = f"DEFAULTS (config > {_MAX_CONFIG_BYTES} bytes)"
271 return default_levels, default_colors, default_settings, config_source
272 except OSError:
273 # Missing/unreadable falls through to json.load which raises
274 # FileNotFoundError below; keep the original control flow.
275 pass
276
277 try:
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
283
284 # FS-18: optional jsonschema validation (silently skipped if not installed)
285 try:
286 import jsonschema as _jsonschema
287 try:
288 _jsonschema.validate(config, _CONFIG_SCHEMA)
289 except _jsonschema.ValidationError as _ve:
290 warnings.warn(
291 f"oc_diagnostics: debug_config.json validation error: {_ve.message}",
292 UserWarning,
293 stacklevel=2,
294 )
295 except ImportError:
296 pass
297
298 # Parse domains → DEBUG_LEVELS and COLOR_CODES
299 # V2.0 schema: accept both "DEBUG_LEVELS" (spec) and "domains" (legacy).
300 # Prefer "DEBUG_LEVELS" if both are present.
301 domains = config.get("DEBUG_LEVELS") or config.get("domains", {})
302 levels = {}
303 colors = {"DEFAULT": "\033[0m", "ERROR": "\033[91m"}
304
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):
311 # Simple format: "DOMAIN": 3
312 levels[domain_name] = domain_def
313 colors[domain_name] = "\033[0m"
314
315 # Parse global settings
316 raw_settings = config.get("global_settings", {})
317
318 # V2.0 schema: accept nested log_rotation object alongside flat keys.
319 log_rotation = raw_settings.get("log_rotation", {})
320 max_log_files = int(
321 log_rotation.get("max_files", 0)
322 or raw_settings.get("max_log_files", 0)
323 )
324 max_log_size_mb = float(
325 log_rotation.get("max_size_mb", 0)
326 or raw_settings.get("max_log_size_mb", 0)
327 )
328
329 # V2.0 schema: log_to_file / log_file_path / log_format
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)
333
334 # Resolve output_mode from V2.0 keys if present, otherwise use legacy key
335 output_mode = raw_settings.get("output_mode", "both")
336 if log_format == "jsonl":
337 output_mode = "json"
338 elif log_to_file is False:
339 output_mode = "terminal"
340 elif log_to_file is True and output_mode == "both":
341 pass # keep "both"
342
343 settings = {
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),
350 # FS-02 / OI-17: project-defined extensions to the structural-ID lists.
351 "structural_id_prefixes": list(raw_settings.get("structural_id_prefixes", [])),
352 "structural_id_exact": list(raw_settings.get("structural_id_exact", [])),
353 # FS-06: log rotation (supports nested V2.0 and flat legacy keys)
354 "max_log_files": max_log_files,
355 "max_log_size_mb": max_log_size_mb,
356 # FS-13: allow instrumenting all components regardless of type
357 "instrumentation_allow_all": bool(raw_settings.get("instrumentation_allow_all", False)),
358 # FS-10: configurable UI event properties
359 "ui_event_properties": list(raw_settings.get("ui_event_properties", [])),
360 # FS-17: headers to redact in HTTP L4 logging
361 "http_redact_headers": list(raw_settings.get("http_redact_headers", [
362 "authorization", "cookie", "set-cookie", "x-api-key"
363 ])),
364 # FS-15: deployment mode ("prod" / "production" / "safe" suppresses interception)
365 "mode": raw_settings.get("mode", "").strip().lower(),
366 # OI-407: fail-safe ceiling for _dbg() levels in production mode.
367 "prod_max_level": raw_settings.get("prod_max_level", 2),
368 # V2.0: global debug level override (None = disabled)
369 "global_debug_level": raw_settings.get("global_debug_level", None),
370 # V2.0: hot-reload from config (runtime-only watch_config also accepted)
371 "watch_config": bool(raw_settings.get("watch_config", False)),
372 # V2.0: AI-Trace shadow logging
373 "ai_trace_level": bool(raw_settings.get("ai_trace_level", False)),
374 # V2.0: production-mode redaction patterns
375 "redaction_patterns": list(raw_settings.get("redaction_patterns", [])),
376 # OI-506: master toggle for built-in secret/URL/token redaction
377 "redact_secrets": bool(raw_settings.get("redact_secrets", True)),
378 # V2.0: log file path override from config
379 "log_file_path": log_file_path,
380 }
381
382 # V2.0: global_debug_level overrides all domain levels
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
387
388 return levels, colors, settings, config_source
389
390
391# ============================================================
392# GLOBAL SETTINGS (loaded from config)
393# ============================================================
394
395_levels, _colors, _settings, _config_source = _load_debug_config()
396
397DEBUG_LEVELS = _levels
398COLOR_CODES = _colors
399
400CONFIG_SOURCE = _config_source
401INSTRUMENTATION_ENABLED = _settings["instrumentation_enabled"]
402SILENT_MODE = _settings["silent_mode"]
403
404# FS-02 / OI-17: project-defined structural ID extensions loaded from config.
405# id_instrumentation.is_structural_id() merges these with the built-in lists.
406ADDITIONAL_STRUCTURAL_PREFIXES: tuple[str, ...] = tuple(
407 _settings.get("structural_id_prefixes", [])
408)
409ADDITIONAL_STRUCTURAL_EXACT: frozenset[str] = frozenset(
410 _settings.get("structural_id_exact", [])
411)
412ENABLE_TIMESTAMPS = _settings["enable_timestamps"]
413ENABLE_COLORS = _settings["enable_colors"]
414INCLUDE_FILENAME = _settings["include_filename"]
415OUTPUT_MODE = _settings["output_mode"]
416
417# FS-06: log rotation limits (0 = disabled)
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
422)
423
424# FS-17: header names to redact in HTTP L4 logging (always lowercase for comparison)
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"])
428)
429
430# FS-13: when True, instrument_ids() bypasses the _is_dash_component() guard
431INSTRUMENTATION_ALL: bool = _settings.get("instrumentation_allow_all", False)
432
433# FS-10: UI event properties to watch; falls back to built-in defaults if not configured
434_DEFAULT_UI_EVENT_PROPERTIES: tuple[str, ...] = (
435 "n_clicks", "value", "data", "figure", "selectedData"
436)
437UI_EVENT_PROPERTIES: tuple[str, ...] = tuple(
438 _settings.get("ui_event_properties") or _DEFAULT_UI_EVENT_PROPERTIES
439)
440
441# FS-15: production mode — env var takes priority, config "mode" key is the fallback.
442# In prod mode, init() skips stream interception and the exception hook.
443PROD_MODE: bool = (
444 os.environ.get("OC_DIAGNOSTICS_MODE", "").strip().lower() in ("prod", "production", "safe")
445 or _settings.get("mode", "") in ("prod", "production", "safe")
446)
447
448# OI-407: Production-mode fail-safe ceiling for _dbg() levels.
449# In PROD_MODE, any _dbg() call with a level above PROD_MAX_LEVEL is
450# suppressed regardless of per-domain settings. A one-shot warning is
451# emitted per (domain, level) key to make the downgrade visible without
452# log spam. Protects against accidental L3/L4 verbosity in production.
453try:
454 PROD_MAX_LEVEL: int = int(_settings.get("prod_max_level", 2))
455except (TypeError, ValueError):
456 PROD_MAX_LEVEL = 2
457_prod_downgrade_warned: set[str] = set()
458
459# AI-Trace: shadow logging level for AI agent monitoring.
460# When enabled, _dbg() calls with level="ai_trace" write to file/JSONL but
461# skip console output. Loaded from config global_settings.ai_trace_level.
462AI_TRACE_ENABLED: bool = bool(_settings.get("ai_trace_level", False))
463
464# V2.0: OC_DIAGNOSTICS_LEVEL env var overrides all domain levels at load time.
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
470
471# V2.0: redaction patterns for production-mode output scrubbing.
472# Each pattern is matched case-insensitively against key=value or key: value
473# pairs in log output; matched values are replaced with [REDACTED].
474REDACTION_PATTERNS: list[re.Pattern] = []
475for _pat in _settings.get("redaction_patterns", []):
476 try:
477 REDACTION_PATTERNS.append(
478 re.compile(
479 rf'(?i)({re.escape(_pat)})\s*[=:]\s*(?:"[^"]*"|\'[^\']*\'|\S+)',
480 )
481 )
482 except re.error:
483 pass
484
485# OI-506 / FS-506: master toggle for built-in secret/URL/token redaction.
486# Applies to the three always-on regexes below (git creds, token prefixes,
487# key/value secrets). User-defined ``REDACTION_PATTERNS`` run regardless.
488REDACT_SECRETS: bool = bool(_settings.get("redact_secrets", True))
489
490# OI-506: http(s) URLs carrying ``user[:token]@host`` credentials.
491# Mirrors oct.core.git._CRED_URL_RE so the same invariant applies inside
492# oc_diagnostics without a cross-package import.
493_GIT_URL_RE: re.Pattern[str] = re.compile(
494 r"(?P<scheme>https?://)[^/@\s]+@",
495)
496
497# OI-506: common opaque-secret prefixes (GitHub PATs, Slack bots, OpenAI
498# keys, JWTs). Matches the prefix plus the token body up to whitespace.
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,}",
501)
502
503# OI-506: ``secret=value`` / ``secret: value`` style key-value pairs.
504# Case-insensitive; the value may be quoted or a bare non-whitespace run.
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+)",
509)
510
511# Logs go into ./logs relative to the current working directory,
512# or into OC_DIAGNOSTICS_LOG_DIR if that env var is set.
513# OI-515: env-provided paths run through _safe_env_path so oversized or
514# NUL-bearing values fall back to the default instead of propagating.
515LOG_DIR = _safe_env_path("OC_DIAGNOSTICS_LOG_DIR") or os.path.join(os.getcwd(), "logs")
516
517# V2.0: OC_DIAGNOSTICS_LOG_FILE env var overrides log file path entirely.
518# When set, LOG_FILE is fixed to this value (not lazily computed).
519# Also check config log_file_path as fallback.
520_env_log_file = _safe_env_path("OC_DIAGNOSTICS_LOG_FILE")
521_config_log_file_path = _settings.get("log_file_path")
522
523# LOG_FILE is computed lazily on the first write so that pre-fork server models
524# (e.g. Gunicorn with multiple workers) each resolve their own unique path after
525# fork(), preventing workers from sharing a file and interleaving output.
526LOG_FILE: str | None = _env_log_file or _config_log_file_path or None
527
528ORIGINAL_STDOUT = sys.stdout
529ORIGINAL_STDERR = sys.stderr
530
531_header_written = False
532_log_lock = threading.Lock()
533
534
535def _get_log_file() -> str:
536 """Return the LOG_FILE path, computing it on the first call.
537
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.
541 """
542 global LOG_FILE
543 if LOG_FILE is None:
544 LOG_FILE = os.path.join(
545 LOG_DIR,
546 f"unified_debug_logs_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt",
547 )
548 return LOG_FILE
549
550# Precompiled regex that strips all ANSI/VT100 escape sequences from log output.
551# Covers:
552# \033[...letter — CSI sequences (SGR colors, cursor movement, clear-screen, etc.)
553# \033]...BEL — OSC sequences (terminal hyperlinks, window titles)
554# \033]...ESC\ — OSC sequences with ST (String Terminator) instead of BEL
555# \033. — Two-character escapes (ESC c, ESC M, ESC = / > etc.)
556_ANSI_ESCAPE_RE = re.compile(
557 r'\033(?:\‍[[^A-Za-z]*[A-Za-z]|\‍][^\007\033]*(?:\007|\033\\)|.)'
558)
559
560
561# ============================================================
562# INTERNAL HELPERS
563# ============================================================
564
565def _get_timestamp() -> str:
566 if not ENABLE_TIMESTAMPS:
567 return ""
568 ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
569 return f"{ts} "
570
571
572def _get_color(domain: str, level: int) -> str:
573 if not ENABLE_COLORS:
574 return ""
575 if level == 1:
576 return COLOR_CODES.get("ERROR", "\033[91m")
577 return COLOR_CODES.get(domain, COLOR_CODES.get("DEFAULT", "\033[0m"))
578
579
580def _reset_color() -> str:
581 return COLOR_CODES.get("DEFAULT", "\033[0m") if ENABLE_COLORS else ""
582
583
584def _prune_old_log_files(log_dir: str, max_count: int) -> None:
585 """Delete the oldest log files when the count exceeds max_count. (FS-06)"""
586 try:
587 import glob as _glob
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]:
591 try:
592 os.remove(path)
593 except OSError:
594 pass
595 except Exception:
596 pass
597
598
600 global _header_written
601 with _log_lock:
602 if _header_written:
603 return
604
605 try:
606 log_file = _get_log_file() # resolve path lazily, under the lock (OI-22)
607 os.makedirs(LOG_DIR, exist_ok=True)
608 # FS-06: prune excess log files on each new file creation
609 if MAX_LOG_FILES > 0:
610 _prune_old_log_files(LOG_DIR, MAX_LOG_FILES)
611 is_new_file = not os.path.exists(log_file)
612
613 with open(log_file, "a", encoding="utf-8") as f:
614 if is_new_file:
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")
632
633 _header_written = True
634 except Exception:
635 pass
636
637
638def _write_to_file(line: str):
639 global LOG_FILE, _header_written, OUTPUT_MODE, _io_fallback_warned
640 try:
641 # FS-06: size-based rotation — reset lazily before header guard so the
642 # next _ensure_log_dir_and_header() call creates a fresh timestamped file.
643 if MAX_LOG_SIZE_BYTES > 0:
644 with _log_lock:
645 lf = _get_log_file()
646 try:
647 if os.path.exists(lf) and os.path.getsize(lf) > MAX_LOG_SIZE_BYTES:
648 LOG_FILE = None
649 _header_written = False
650 except OSError:
651 pass
653 with _log_lock: # OI-21: serialize concurrent writes
654 with open(_get_log_file(), "a", encoding="utf-8") as f:
655 f.write(line + "\n")
656 except OSError as _exc:
657 # FS-25: one-time warning; fall back to terminal-only mode so console
658 # output is preserved. Use ORIGINAL_STDOUT directly to avoid recursion.
659 if not _io_fallback_warned:
660 _io_fallback_warned = True
661 try:
662 ORIGINAL_STDOUT.write(
663 f"\n[oc_diagnostics] WARNING: file log write failed "
664 f"({_exc}); switching OUTPUT_MODE to 'terminal'.\n"
665 )
666 ORIGINAL_STDOUT.flush()
667 except Exception:
668 pass
669 OUTPUT_MODE = "terminal"
670 except Exception:
671 pass
672
673
674def _apply_redaction(text: str) -> str:
675 """Apply configured redaction patterns to log output (production mode only).
676
677 OI-506 / FS-506 layer order (production mode):
678
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.
683
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.
688 """
689 if not PROD_MODE:
690 return text
691 if REDACT_SECRETS:
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)
697 return text
698
699
700def _emit(line: str, level: int, record: dict | None = None):
701 if SILENT_MODE:
702 return
703
704 line = _apply_redaction(line)
705
706 # Terminal output: KEEP ANSI COLORS (shown for all modes including "json")
707 if OUTPUT_MODE in ("terminal", "both", "json"):
708 try:
709 ORIGINAL_STDOUT.write(line + "\n")
710 ORIGINAL_STDOUT.flush()
711 except Exception:
712 pass
713
714 # File output: JSONL when json mode, plain text otherwise
715 if OUTPUT_MODE == "json":
716 if record is not None:
717 _write_to_file(json.dumps(record, ensure_ascii=False))
718 elif OUTPUT_MODE in ("file", "both"):
719 plain = _ANSI_ESCAPE_RE.sub("", line)
720 _write_to_file(plain)
721
722
723# ============================================================
724# PUBLIC DEBUG FUNCTION
725# ============================================================
726
727def _dbg(domain: str, func: str, msg: str, level: int | str | None = None, override: bool = False, _depth: int = 1):
728 """
729 Unified debug logger.
730
731 Parameters
732 ----------
733 domain : str
734 Debug domain (e.g., "CONTROLLER", "AHK", "MODE").
735 func : str
736 Logical context name provided by the caller.
737 msg : str
738 Message text.
739 level : int | str | None
740 Debug level (0-9), or ``"ai_trace"`` for AI-Trace shadow logging.
741 Defaults to 2.
742 override : bool
743 If True, bypasses domain-level filtering.
744
745 Behavior
746 --------
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.
752 """
753 if SILENT_MODE:
754 return
755
756 # AI-Trace: shadow logging — file/JSONL only, never console
757 if level == "ai_trace":
758 if not AI_TRACE_ENABLED:
759 return
760 _dbg_ai_trace(domain, func, msg, _depth=_depth + 1)
761 return
762
763 # OI-13 / FS-05: emit a one-shot RuntimeWarning when _dbg() is called
764 # before init() so that misconfiguration (forgotten init() call) surfaces
765 # immediately instead of silently producing no log-file output.
766 global _init_warned
767 if not _initialized and not _init_warned:
768 _init_warned = True
769 warnings.warn(
770 "_dbg() called before init() — log file output and stream interception "
771 "are not yet active. Call oc_diagnostics.init() at application startup.",
772 RuntimeWarning,
773 stacklevel=_depth + 1,
774 )
775
776 effective_level = level if level is not None else 2
777
778 # OI-407: production-mode fail-safe. Suppress any level above PROD_MAX_LEVEL
779 # regardless of per-domain filtering or override, and emit a one-shot warning
780 # per (domain, level) key on first occurrence so operators notice the downgrade.
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)
785 _emit(
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.",
788 1,
789 )
790 return
791
792 # Capture caller info via sys._getframe — O(1), no full-stack allocation
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
797
798 ts = _get_timestamp()
799 color = _get_color(domain, effective_level)
800 reset = _reset_color()
801
802 # Build location string
803 if INCLUDE_FILENAME:
804 location = (
805 f"{filename}:{lineno}::caller={caller_func}|logical context={func}"
806 )
807 else:
808 location = f"logical context={func}"
809
810 # FS-07: build JSON record when json output mode is active
811 record: dict | None = None
812 if OUTPUT_MODE == "json":
813 record = {
814 "ts": datetime.datetime.now().isoformat(timespec="milliseconds"),
815 "domain": domain,
816 "level": effective_level,
817 "func": func,
818 "file": filename,
819 "line": lineno,
820 "caller": caller_func,
821 "msg": msg,
822 "override": override,
823 }
824
825 if override:
826 line = f"{color}[{domain}][OVERRIDE][{location}] {ts}{msg}{reset}"
827 _emit(line, effective_level, record=record)
828 return
829
830 allowed = DEBUG_LEVELS.get(domain, 0)
831 if effective_level > allowed:
832 return
833
834 line = f"{color}[{domain}][L{effective_level}][{location}] {ts}{msg}{reset}"
835 _emit(line, effective_level, record=record)
836
837
838def _dbg_ai_trace(domain: str, func: str, msg: str, _depth: int = 1):
839 """Write an AI-Trace message to file/JSONL only (never console).
840
841 Called internally by ``_dbg()`` when ``level="ai_trace"``.
842 """
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
847
848 ts = _get_timestamp()
849
850 if INCLUDE_FILENAME:
851 location = f"{filename}:{lineno}::caller={caller_func}|logical context={func}"
852 else:
853 location = f"logical context={func}"
854
855 if OUTPUT_MODE == "json":
856 record = {
857 "ts": datetime.datetime.now().isoformat(timespec="milliseconds"),
858 "domain": domain,
859 "level": "ai_trace",
860 "func": func,
861 "file": filename,
862 "line": lineno,
863 "caller": caller_func,
864 "msg": msg,
865 "override": False,
866 }
867 _write_to_file(json.dumps(record, ensure_ascii=False))
868 elif OUTPUT_MODE in ("file", "both"):
869 plain = f"[{domain}][AI_TRACE][{location}] {ts}{msg}"
870 _write_to_file(plain)
871
872
873# ============================================================
874# ASYNC DEBUG FUNCTION (FS-16)
875# ============================================================
876
877async def _adbg(
878 domain: str,
879 func: str,
880 msg: str,
881 level: int | None = None,
882 override: bool = False,
883) -> None:
884 """Async variant of _dbg().
885
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.
889
890 The caller frame is captured *synchronously* before any await because
891 frame references become invalid once the coroutine is suspended.
892 """
893 if SILENT_MODE:
894 return
895
896 effective_level = level if level is not None else 2
897
898 # Capture caller frame synchronously — must happen before any await
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
903
904 if not override:
905 allowed = DEBUG_LEVELS.get(domain, 0)
906 if effective_level > allowed:
907 return
908
909 ts = _get_timestamp()
910 color = _get_color(domain, effective_level)
911 reset = _reset_color()
912
913 if INCLUDE_FILENAME:
914 location = f"{filename}:{lineno}::caller={caller_func}|logical context={func}"
915 else:
916 location = f"logical context={func}"
917
918 if override:
919 line = f"{color}[{domain}][OVERRIDE][{location}] {ts}{msg}{reset}"
920 else:
921 line = f"{color}[{domain}][L{effective_level}][{location}] {ts}{msg}{reset}"
922
923 # Terminal: synchronous (fast path)
924 if OUTPUT_MODE in ("terminal", "both", "json"):
925 try:
926 ORIGINAL_STDOUT.write(line + "\n")
927 ORIGINAL_STDOUT.flush()
928 except Exception:
929 pass
930
931 # File I/O: offload to thread (non-blocking for async callers)
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":
936 record = {
937 "ts": datetime.datetime.now().isoformat(timespec="milliseconds"),
938 "domain": domain,
939 "level": effective_level,
940 "func": func,
941 "file": filename,
942 "line": lineno,
943 "caller": caller_func,
944 "msg": msg,
945 "override": override,
946 }
947 await asyncio.to_thread(_write_to_file, json.dumps(record, ensure_ascii=False))
948
949
950# ============================================================
951# INSTRUMENTATION HELPERS
952# ============================================================
953
955 if not INSTRUMENTATION_ENABLED:
956 return False
957 return DEBUG_LEVELS.get("INSTRUMENTATION", 0) >= 1
958
959
961 return DEBUG_LEVELS.get("INSTRUMENTATION", 0) >= 4
962
963
965 return DEBUG_LEVELS.get("INSTRUMENTATION", 0) >= 5
966
967
969 """Return True when all components should be instrumented regardless of type. (FS-13)"""
970 return INSTRUMENTATION_ALL
971
972
973# ============================================================
974# RUNTIME API (FS-03)
975# ============================================================
976
977def set_debug_level(domain: str, level: int) -> None:
978 """Set the debug level for a domain at runtime (0–9).
979
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.
983 """
984 if not isinstance(level, int) or level < 0 or level > 9:
985 warnings.warn(
986 f"set_debug_level: invalid level {level!r} for domain {domain!r}. "
987 "Must be an integer 0-9.",
988 UserWarning,
989 stacklevel=2,
990 )
991 return
992 DEBUG_LEVELS[domain] = level
993
994
995def set_global_setting(key: str, value) -> None:
996 """Update a global diagnostic setting at runtime.
997
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.
1001 """
1002 global SILENT_MODE, ENABLE_TIMESTAMPS, ENABLE_COLORS, INCLUDE_FILENAME, OUTPUT_MODE
1003 _SETTABLE = {
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",
1009 }
1010 if key not in _SETTABLE:
1011 warnings.warn(
1012 f"set_global_setting: unknown key {key!r}. "
1013 f"Valid keys: {sorted(_SETTABLE)}",
1014 UserWarning,
1015 stacklevel=2,
1016 )
1017 return
1018 globals()[_SETTABLE[key]] = value
1019
1020
1021# ============================================================
1022# PROFILING / TIMING HELPERS (FS-12)
1023# ============================================================
1024
1025@contextmanager
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.
1028
1029 Example::
1030
1031 with dbg_timed("CONTROLLER", "my_func", label="heavy_op"):
1032 do_work()
1033 """
1034 tag = f"[{label}] " if label else ""
1035 _dbg(domain, func, f"{tag}ENTER", level=level, _depth=2)
1036 t0 = time.perf_counter()
1037 try:
1038 yield
1039 finally:
1040 elapsed_ms = (time.perf_counter() - t0) * 1000
1041 _dbg(domain, func, f"{tag}EXIT ({elapsed_ms:.1f} ms)", level=level, _depth=2)
1042
1043
1044@contextmanager
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.
1047
1048 Use for pure lifecycle tracing where elapsed time is not needed.
1049 For timed tracing, use :func:`dbg_timed` instead.
1050
1051 Example::
1052
1053 with dbg_scope("DATA", "load_records"):
1054 result = expensive_operation()
1055 """
1056 tag = f"[{label}] " if label else ""
1057 _dbg(domain, func, f"{tag}ENTER", level=level, _depth=2)
1058 try:
1059 yield
1060 finally:
1061 _dbg(domain, func, f"{tag}EXIT", level=level, _depth=2)
1062
1063
1064def dbg_profile(domain: str = "GENERAL", func: str = "", label: str = "", level: int = 3):
1065 """Decorator that wraps a function with :func:`dbg_timed`.
1066
1067 Example::
1068
1069 @dbg_profile("CONTROLLER", label="fetch_data")
1070 def fetch_data():
1071 ...
1072 """
1073 def decorator(fn):
1074 import functools
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)
1080 return wrapper
1081 return decorator
1082
1083
1084# ============================================================
1085# RUNTIME CONTRACT ASSERTIONS
1086# ============================================================
1087
1089 domain: str,
1090 condition,
1091 message: str,
1092 func: str | None = None,
1093) -> None:
1094 """Domain-aware runtime assertion, suppressed in production mode.
1095
1096 Parameters
1097 ----------
1098 domain : str
1099 Debug domain for the assertion message.
1100 condition
1101 Expression to evaluate. If falsy (and not in production mode),
1102 emits an L1 override ``_dbg`` message and raises ``AssertionError``.
1103 message : str
1104 Descriptive failure message.
1105 func : str | None
1106 Logical context name. If ``None``, uses ``"<unknown>"``.
1107
1108 Notes
1109 -----
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.
1117 """
1118 if PROD_MODE:
1119 return
1120 if not condition:
1121 ctx = func or "<unknown>"
1122 _dbg(domain, ctx, f"ASSERT FAILED: {message}", 1, override=True, _depth=2)
1123 raise AssertionError(message)
1124
1125
1126# ============================================================
1127# dbg_trace DECORATOR
1128# ============================================================
1129
1130def dbg_trace(domain: str, level: int = 2):
1131 """Decorator that emits START/END lifecycle messages and catches exceptions.
1132
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.
1136
1137 Parameters
1138 ----------
1139 domain : str
1140 Debug domain for lifecycle messages.
1141 level : int
1142 Debug level for START/END messages (default 2).
1143
1144 Example
1145 -------
1146 ::
1147
1148 @dbg_trace("DATA")
1149 def process_data(data):
1150 _dbg("DATA", func, "working...", 3)
1151 return transform(data)
1152 """
1153 def decorator(fn):
1154 @functools.wraps(fn)
1155 def wrapper(*args, **kwargs):
1156 func = fn.__name__
1157 _dbg(domain, func, "START", level=level, _depth=2)
1158 try:
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)
1162 raise
1163 _dbg(domain, func, "END", level=level, _depth=2)
1164 return result
1165 return wrapper
1166 return decorator
1167
1168
1169# ============================================================
1170# dbg_metrics DECORATOR
1171# ============================================================
1172
1173def dbg_metrics(domain: str, level: int = 3):
1174 """Decorator that logs structured performance metrics (time + memory).
1175
1176 Emits a JSON-style string with ``duration_ms`` and ``memory_mb`` fields
1177 suitable for observability tooling.
1178
1179 Parameters
1180 ----------
1181 domain : str
1182 Debug domain for the metrics message.
1183 level : int
1184 Debug level (default 3).
1185 """
1186 def decorator(fn):
1187 @functools.wraps(fn)
1188 def wrapper(*args, **kwargs):
1189 func = fn.__name__
1190 # Memory tracking via tracemalloc if available; falls back gracefully
1191 mem_before = None
1192 try:
1193 import tracemalloc as _tm
1194 if not _tm.is_tracing():
1195 _tm.start()
1196 mem_before = _tm.get_traced_memory()[0]
1197 except Exception:
1198 pass
1199
1200 t0 = time.perf_counter()
1201 try:
1202 result = fn(*args, **kwargs)
1203 finally:
1204 elapsed_ms = (time.perf_counter() - t0) * 1000
1205 mem_mb = 0.0
1206 if mem_before is not None:
1207 try:
1208 import tracemalloc as _tm
1209 mem_after = _tm.get_traced_memory()[0]
1210 mem_mb = (mem_after - mem_before) / (1024 * 1024)
1211 except Exception:
1212 pass
1213 metrics = json.dumps({
1214 "duration_ms": round(elapsed_ms, 2),
1215 "memory_mb": round(mem_mb, 2),
1216 })
1217 _dbg(domain, func, f"METRICS: {metrics}", level=level, _depth=2)
1218 return result
1219 return wrapper
1220 return decorator
1221
1222
1223# ============================================================
1224# EXCEPTION HOOK
1225# ============================================================
1226
1227# Saved at init() time so _exception_hook can delegate to it.
1228_original_excepthook = None
1229
1230
1231def _exception_hook(exc_type, exc_value, exc_traceback):
1232 tb_lines = traceback.format_exception(exc_type, exc_value, exc_traceback)
1233 tb_text = "".join(tb_lines).rstrip()
1234
1235 ts = _get_timestamp()
1236 color = _get_color("PYERR", 1)
1237 reset = _reset_color()
1238
1239 header_line = f"{color}[PYERR][L1][UNCAUGHT] {ts}Uncaught exception:{reset}"
1240 _emit(header_line, 1)
1241
1242 for line in tb_text.splitlines():
1243 formatted = f"{color}[PYERR][L1][TRACE] {ts}{line}{reset}"
1244 _emit(formatted, 1)
1245
1246 # Delegate to the hook that was active before init() installed ours so
1247 # that debuggers, pytest, rich.traceback, and ASGI frameworks are not
1248 # silently bypassed.
1249 #
1250 # OI-24 (intentional behaviour): if sys.stderr is currently intercepted by
1251 # _StreamInterceptor, the original hook's write to stderr will appear a
1252 # second time in the log — once from the _emit calls above, and once routed
1253 # through the interceptor. The re-entrant guard prevents infinite recursion
1254 # but not this cosmetic duplication. This is documented and accepted.
1255 if _original_excepthook is not None and _original_excepthook is not _exception_hook:
1256 try:
1257 _original_excepthook(exc_type, exc_value, exc_traceback)
1258 except Exception:
1259 pass
1260
1261
1262# ============================================================
1263# STDOUT / STDERR REDIRECTION
1264# ============================================================
1265
1266# Thread-local flag that prevents re-entrant calls from spiralling into
1267# infinite recursion. If _StreamInterceptor.write() is called while it is
1268# already running on the same thread (e.g. because _dbg's own formatting
1269# code triggers a print(), or because an exception handler tries to report
1270# an error via sys.stdout after a BaseException propagates out of _dbg),
1271# the second call bypasses processing and writes directly to the original
1272# stream instead of looping back through _dbg again.
1273_interceptor_active = threading.local()
1274
1275
1277 """
1278 Wraps sys.stdout or sys.stderr, routing all output through _dbg().
1279
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.
1284 """
1285
1286 def __init__(self, stream_name: str):
1287 self.stream_name = stream_name
1288 self._original = ORIGINAL_STDOUT if stream_name == "stdout" else ORIGINAL_STDERR
1289
1290 def write(self, text: str):
1291 # Re-entrant-call guard: if this thread is already inside write()
1292 # (e.g. a BaseException propagated out of _dbg and an exception
1293 # handler is printing via sys.stdout), fall back to the original
1294 # stream to avoid infinite recursion.
1295 if getattr(_interceptor_active, "value", False):
1296 try:
1297 self._original.write(text)
1298 self._original.flush()
1299 except Exception:
1300 pass
1301 return
1302
1303 if not text:
1304 return
1305
1306 _interceptor_active.value = True
1307 try:
1308 domain = "STDOUT" if self.stream_name == "stdout" else "STDERR"
1309 for line in text.splitlines():
1310 stripped = line.rstrip()
1311 if not stripped:
1312 # Pass blank lines through to the original stream so that
1313 # formatted output (tables, separators, blank print() calls)
1314 # is not silently discarded.
1315 try:
1316 self._original.write("\n")
1317 self._original.flush()
1318 except Exception:
1319 pass
1320 continue
1321
1322 # _depth=2 skips this write() frame so _dbg reports the code
1323 # that called print() as the call site, not unified_debug.py.
1324 _dbg(domain, "stream", stripped, level=2, override=True, _depth=2)
1325 finally:
1326 _interceptor_active.value = False
1327
1328 def flush(self):
1329 try:
1330 self._original.flush()
1331 except Exception:
1332 pass
1333
1334 def isatty(self) -> bool:
1335 try:
1336 return self._original.isatty()
1337 except Exception:
1338 return False
1339
1340 def fileno(self) -> int:
1341 return self._original.fileno()
1342
1343 @property
1344 def encoding(self) -> str:
1345 return getattr(self._original, "encoding", "utf-8")
1346
1347 @property
1348 def errors(self) -> str:
1349 return getattr(self._original, "errors", "strict")
1350
1351 def readable(self) -> bool:
1352 return False
1353
1354 def writable(self) -> bool:
1355 return True
1356
1357 def seekable(self) -> bool:
1358 return False
1359
1360 # OI-16: stubs for advanced TextIOBase methods accessed by some libraries
1361 # (colorama on Windows, some logging handlers, etc.).
1362
1363 @property
1364 def write_through(self) -> bool:
1365 """Always True — _StreamInterceptor does not buffer writes."""
1366 return True
1367
1368 @property
1369 def buffer(self):
1370 """Not available — _StreamInterceptor wraps a text stream, not bytes."""
1371 raise io.UnsupportedOperation("buffer")
1372
1373 def detach(self):
1374 """Not supported — raises UnsupportedOperation."""
1375 raise io.UnsupportedOperation("detach")
1376
1377 def reconfigure(self, **_kwargs):
1378 """Not supported — raises UnsupportedOperation."""
1379 raise io.UnsupportedOperation("reconfigure")
1380
1381
1382# ============================================================
1383# HOT-RELOAD (FS-09)
1384# ============================================================
1385
1386def _reload_config() -> None:
1387 """Reload debug_config.json in-place, updating all module-level globals.
1388
1389 Preserves the existing ``DEBUG_LEVELS`` dict *object* so that downstream
1390 modules holding a reference to the dict automatically see the updated levels.
1391
1392 Uses an update-first, prune-stale-after pattern (OI-29) to avoid the brief
1393 empty-dict window that ``clear()`` + ``update()`` would create.
1394 """
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
1401
1402 levels, colors, settings, source = _load_debug_config()
1403 # OI-29: update first so the dict is never empty, then remove stale keys.
1404 DEBUG_LEVELS.update(levels)
1405 for k in list(DEBUG_LEVELS):
1406 if k not in levels:
1407 del DEBUG_LEVELS[k]
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
1422 )
1423 HTTP_REDACT_HEADERS = frozenset(
1424 h.lower() for h in settings.get("http_redact_headers", [])
1425 )
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
1429 )
1430 AI_TRACE_ENABLED = bool(settings.get("ai_trace_level", False))
1431 new_patterns = []
1432 for pat in settings.get("redaction_patterns", []):
1433 try:
1434 new_patterns.append(re.compile(rf'(?i)({re.escape(pat)})\s*[=:]\s*\S+'))
1435 except re.error:
1436 pass
1437 REDACTION_PATTERNS = new_patterns
1438 REDACT_SECRETS = bool(settings.get("redact_secrets", True))
1439
1440
1442 """Daemon thread: poll the config file mtime every 2 s; reload on change. (FS-09)"""
1443 global _CONFIG_MTIME
1444 while True:
1445 time.sleep(2)
1446 if not _CONFIG_PATH:
1447 continue
1448 try:
1449 mtime = os.path.getmtime(_CONFIG_PATH)
1450 if mtime != _CONFIG_MTIME:
1451 _CONFIG_MTIME = mtime
1453 except Exception:
1454 pass
1455
1456
1457# ============================================================
1458# PUBLIC INITIALIZATION
1459# ============================================================
1460
1461_initialized = False
1462_init_warned = False # OI-13/FS-05: one-shot flag for the pre-init RuntimeWarning
1463_io_fallback_warned = False # FS-25: one-shot flag for the disk-I/O fallback warning
1464
1465
1467 intercept_streams: bool = True,
1468 intercept_exceptions: bool = True,
1469 watch_config: bool = False,
1470) -> None:
1471 """
1472 Initialize oc_diagnostics side effects.
1473
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.
1477
1478 Parameters
1479 ----------
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).
1492 Default True.
1493 watch_config : bool
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)
1498
1499 Notes
1500 -----
1501 Calling init() more than once is a no-op.
1502 """
1503 global _initialized, _original_excepthook
1504
1505 # FS-15: production mode disables all side effects that could interfere
1506 # with prod environments (stream interception, exception hook).
1507 if PROD_MODE:
1508 intercept_streams = False
1509 intercept_exceptions = False
1510
1511 if _initialized:
1512 return
1513
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
1518
1519 disable_intercept = os.environ.get("UNIFIED_DEBUG_DISABLE_STDIO_INTERCEPT") == "1"
1520 if intercept_streams and not disable_intercept:
1521 sys.stdout = _StreamInterceptor("stdout")
1522 sys.stderr = _StreamInterceptor("stderr")
1523
1524 # V2.0: honour watch_config from config file as well as the parameter
1525 effective_watch = watch_config or _settings.get("watch_config", False)
1526
1527 # FS-09: start hot-reload watcher thread if requested
1528 if effective_watch and _CONFIG_PATH:
1529 t = threading.Thread(target=_watch_config_loop, daemon=True)
1530 t.start()
1531
1532 _initialized = True
_exception_hook(exc_type, exc_value, exc_traceback)
_dbg_ai_trace(str domain, str func, str msg, int _depth=1)
_emit(str line, int level, dict|None record=None)
None init(bool intercept_streams=True, bool intercept_exceptions=True, bool watch_config=False)
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)
None _dbg_assert(str domain, condition, str message, str|None func=None)
None set_global_setting(str key, value)
dbg_metrics(str domain, int level=3)
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)