From f12b4effecc21347611a38c9cf4516e4ac371d49 Mon Sep 17 00:00:00 2001 From: v314b0i <29569580+v314b0i@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:35:41 +0000 Subject: [PATCH 1/5] [ported] feat(sensor): add --log-level and --quiet console options Expose the sensor logger's console threshold on the CLI: --log-level {debug,info,warning,error} (default info) and -q/--quiet, which is shorthand for --log-level warning. The two are mutually exclusive. Nothing logs through the sensor logger yet, so output is unchanged until call sites move over. --- Sensor/adr_sensor/cli.py | 13 ++++++++++++- Sensor/tests/test_sensor_log.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sensor/adr_sensor/cli.py b/Sensor/adr_sensor/cli.py index 8a2f736..3520a31 100644 --- a/Sensor/adr_sensor/cli.py +++ b/Sensor/adr_sensor/cli.py @@ -29,7 +29,7 @@ from .exporters.delivery_checkpoint import DeliveryCheckpoint, DeliveryCheckpointError from .exporters.opentelemetry import OpenTelemetryExportError, OpenTelemetryLogExporter from .observer import AgentObserver -from .sensor_log import append_rotating_line +from .sensor_log import append_rotating_line, set_console_level def get_version(): @@ -113,8 +113,19 @@ def main(): default=None, help="JSON configuration for OTLP/HTTP log export (disabled when omitted)", ) + verbosity = parser.add_mutually_exclusive_group() + verbosity.add_argument( + "--log-level", + choices=["debug", "info", "warning", "error"], + default="info", + help="Minimum level of progress and error messages printed to the console (default: info)", + ) + verbosity.add_argument( + "-q", "--quiet", action="store_true", help="Print only warnings and errors (same as --log-level warning)" + ) args = parser.parse_args() + set_console_level("warning" if args.quiet else args.log_level) otel_config = None if args.otel_config is not None: diff --git a/Sensor/tests/test_sensor_log.py b/Sensor/tests/test_sensor_log.py index 4558d8d..6504108 100644 --- a/Sensor/tests/test_sensor_log.py +++ b/Sensor/tests/test_sensor_log.py @@ -1,10 +1,12 @@ """Tests for the leveled sensor logger.""" import logging +from unittest.mock import patch import pytest from adr_sensor import sensor_log +from adr_sensor.cli import main @pytest.fixture(autouse=True) @@ -104,3 +106,24 @@ def test_append_rotating_line_rotates_by_size_and_keeps_backups(tmp_path): def test_append_rotating_line_raises_when_the_file_cannot_be_written(tmp_path): with pytest.raises(OSError): sensor_log.append_rotating_line(tmp_path / "missing" / "resource.log", "line") + + +@pytest.mark.parametrize( + "flags, level", + [([], logging.INFO), (["--log-level", "debug"], logging.DEBUG), (["-q"], logging.WARNING)], +) +def test_cli_flags_set_the_console_level(flags, level, monkeypatch): + monkeypatch.setattr("sys.argv", ["adr-sensor", "--no-save", *flags]) + with patch("adr_sensor.cli.AgentObserver") as observer_cls: + observer_cls.return_value.ingest_all.side_effect = RuntimeError("stop after argument parsing") + observer_cls.return_value.get_diagnostic_records.return_value = [] + with pytest.raises(RuntimeError): + main() + assert {handler.level for handler in sensor_log._console_handlers()} == {level} + + +def test_cli_rejects_quiet_with_log_level(monkeypatch): + monkeypatch.setattr("sys.argv", ["adr-sensor", "-q", "--log-level", "debug"]) + with pytest.raises(SystemExit) as error: + main() + assert error.value.code == 2 From 96ea91ba2d957490920a95de1556643c5a29ecaf Mon Sep 17 00:00:00 2001 From: v314b0i <29569580+v314b0i@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:36:23 +0000 Subject: [PATCH 2/5] [ported] refactor(sensor): route observer progress and errors through the sensor logger Replace the observer's progress and error print() calls with leveled records on the adr_sensor.observer logger, tagged with a parse or save phase. At the default level the text on the console is unchanged. - progress ("Ingesting ...", "Saved session ...") is INFO on stdout - a failed source or failed session save is ERROR and now goes to stderr - recoverable cleanup and comparison problems are WARNING on stderr display_summary() is report output rather than logging and keeps print(). --- Sensor/adr_sensor/observer.py | 36 ++++++++++++++++++++--------------- Sensor/tests/test_observer.py | 28 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/Sensor/adr_sensor/observer.py b/Sensor/adr_sensor/observer.py index 2917dbe..fa54fc7 100644 --- a/Sensor/adr_sensor/observer.py +++ b/Sensor/adr_sensor/observer.py @@ -8,6 +8,7 @@ import errno import hashlib import json +import logging import os import platform import re @@ -38,6 +39,8 @@ _COLLISION_SUFFIX_PATTERN = re.compile(r"_([0-9a-f]{64})(?:_(\d+))?$") +logger = logging.getLogger(__name__) + class AgentObserver: """Main class for observing and analyzing AI agent interactions. @@ -184,9 +187,7 @@ def ingest_all(self, source_filter: str = "all") -> Tuple[List[AgentEvent], List self._diagnostics_flushed = 0 self._diagnostic_write_failed = False - print("\n" + "=" * 80) - print("ADR Sensor Starting...") - print("=" * 80 + "\n") + logger.info("\n%s\nADR Sensor Starting...\n%s\n", "=" * 80, "=" * 80) host_os = platform.system() @@ -198,7 +199,7 @@ def ingest_all(self, source_filter: str = "all") -> Tuple[List[AgentEvent], List if supported_platforms and host_os not in supported_platforms: continue - print(f"Ingesting {label} logs...") + logger.info("Ingesting %s logs...", label, extra={"phase": "parse"}) parser_instance = getattr(self, f"{source}_parser") if isinstance(parser_instance, BaseParser): parser_instance.reset_diagnostics() @@ -212,9 +213,9 @@ def ingest_all(self, source_filter: str = "all") -> Tuple[List[AgentEvent], List entries = parsed filtered = [e for e in entries if e.has_meaningful_content()] all_entries.extend(filtered) - print(f"Found {len(filtered)} entries\n") + logger.info("Found %d entries\n", len(filtered), extra={"phase": "parse"}) except Exception as e: - print(f"Error ingesting {label} logs: {e}") + logger.error("Error ingesting %s logs: %s", label, e, extra={"phase": "parse"}) parser_failed = True finally: reasons = parser_instance.get_diagnostics() if isinstance(parser_instance, BaseParser) else {} @@ -326,7 +327,7 @@ def save_to_file( for entry in entries: f.write(json.dumps(entry.get_non_null_fields(), ensure_ascii=False) + "\n") - print(f"\nAgent event logs saved to: {output_file}") + logger.info("\nAgent event logs saved to: %s", output_file, extra={"phase": "save"}) saved_files.append(output_file) if system_config_data: @@ -345,7 +346,7 @@ def save_to_file( for config in system_config_data: f.write(json.dumps(config.to_dict(), ensure_ascii=False) + "\n") - print(f"System configuration saved to: {config_file}") + logger.info("System configuration saved to: %s", config_file, extra={"phase": "save"}) saved_files.append(config_file) return saved_files @@ -403,7 +404,7 @@ def save_sessions_to_individual_files( if fresh_target is not None and fresh_target["data"].get("session_id") == entry.session_id: existing_info = self._newer_session_file(existing_info, fresh_target) if self._session_revision_regresses(entry, existing_info): - print(f"Skipped stale session: {filename}") + logger.info("Skipped stale session: %s", filename, extra={"phase": "save"}) continue entry_data = entry.get_non_null_fields() @@ -429,9 +430,9 @@ def save_sessions_to_individual_files( saved_files.append(file_path) source = entry.source if entry.source in DIAGNOSTIC_SOURCES else "sensor" save_successes[source] = save_successes.get(source, 0) + 1 - print(f"Saved session: {filename}") + logger.info("Saved session: %s", filename, extra={"phase": "save"}) except Exception as e: - print(f"Error saving session {filename}: {e}") + logger.error("Error saving session %s: %s", filename, e, extra={"phase": "save"}) source = entry.source if entry.source in DIAGNOSTIC_SOURCES else "sensor" save_failures[source] = save_failures.get(source, 0) + 1 finally: @@ -439,7 +440,12 @@ def save_sessions_to_individual_files( try: temp_path.unlink() except OSError as cleanup_error: - print(f"Error removing temporary session file {temp_path.name}: {cleanup_error}") + logger.warning( + "Error removing temporary session file %s: %s", + temp_path.name, + cleanup_error, + extra={"phase": "save"}, + ) if lock_fd is not None and lock_path is not None: self._release_session_lock(lock_fd) @@ -455,7 +461,7 @@ def save_sessions_to_individual_files( ) ) self.flush_diagnostics() - print(f"\nSaved {len(saved_files)} sessions to: {output_dir}") + logger.info("\nSaved %d sessions to: %s", len(saved_files), output_dir, extra={"phase": "save"}) return saved_files def filter_entries_by_existing_files( @@ -519,7 +525,7 @@ def _session_content_changed(self, entry: AgentEvent, existing_info: Dict[str, A current_content.pop("session_context") return current_content != existing_content except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError) as exc: - print(f"Error comparing existing session {existing_path.name}: {exc}") + logger.warning("Error comparing existing session %s: %s", existing_path.name, exc, extra={"phase": "save"}) self._emit_error( { "source": entry.source, @@ -868,7 +874,7 @@ def _remove_stale_session_files( candidate.unlink() removed_files = True except OSError as exc: - print(f"Error removing stale session {candidate.name}: {exc}") + logger.warning("Error removing stale session %s: %s", candidate.name, exc, extra={"phase": "save"}) self._emit_error( { "stage": "remove_stale_session", diff --git a/Sensor/tests/test_observer.py b/Sensor/tests/test_observer.py index 2ccdd96..cfaa727 100644 --- a/Sensor/tests/test_observer.py +++ b/Sensor/tests/test_observer.py @@ -8,6 +8,7 @@ import pytest +from adr_sensor import sensor_log from adr_sensor.observer import AgentObserver from adr_sensor.parsers.claude_parser import ClaudeParser from adr_sensor.schemas.agent_event_schema import AgentEvent, ChatMessage, ToolUsage @@ -107,6 +108,33 @@ def test_display_summary_zero_limit_hides_entries(self, tmp_path, capsys): assert "INGESTION SUMMARY" in output assert "RECENT ENTRIES" not in output + def test_ingest_progress_goes_to_stdout_and_parser_errors_to_stderr(self, tmp_path, capsys): + observer = AgentObserver(output_dir=tmp_path) + observer.SOURCES = (("claude", "Claude Code"),) + observer.claude_parser = MagicMock() + observer.claude_parser.parse_all.side_effect = RuntimeError("synthetic failure") + + observer.ingest_all("claude") + + captured = capsys.readouterr() + assert "ADR Sensor Starting..." in captured.out + assert "Ingesting Claude Code logs..." in captured.out + assert captured.err == "Error ingesting Claude Code logs: synthetic failure\n" + + def test_ingest_progress_is_hidden_at_warning_level(self, tmp_path, capsys): + observer = AgentObserver(output_dir=tmp_path) + observer.SOURCES = (("claude", "Claude Code"),) + observer.claude_parser = MagicMock() + observer.claude_parser.parse_all.return_value = [] + + sensor_log.set_console_level("warning") + try: + observer.ingest_all("claude") + finally: + sensor_log.set_console_level("info") + + assert capsys.readouterr().out == "" + def test_save_to_file_json(self, tmp_path): """Test saving entries as JSON.""" observer = AgentObserver(output_dir=tmp_path) From ca3d7c3140c509b75ac77d834a027b0b155defdc Mon Sep 17 00:00:00 2001 From: v314b0i <29569580+v314b0i@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:37:04 +0000 Subject: [PATCH 3/5] [ported] refactor(sensor): route CLI progress and errors through the sensor logger Replace the CLI's print() calls with records on the adr_sensor.cli logger. Progress and the completion line are INFO on stdout; export and checkpoint failures stay on stderr as ERROR, and the checkpoint-retry notice as WARNING. "Completed with errors" is now a WARNING, so it goes to stderr and still shows with --quiet. --- Sensor/adr_sensor/cli.py | 28 ++++++++++++++++++---------- Sensor/tests/test_diagnostics.py | 16 +++++++++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sensor/adr_sensor/cli.py b/Sensor/adr_sensor/cli.py index 3520a31..656ed0d 100644 --- a/Sensor/adr_sensor/cli.py +++ b/Sensor/adr_sensor/cli.py @@ -11,9 +11,9 @@ import argparse import json +import logging import os import platform -import sys import time from datetime import datetime, timezone from pathlib import Path @@ -31,6 +31,8 @@ from .observer import AgentObserver from .sensor_log import append_rotating_line, set_console_level +logger = logging.getLogger(__name__) + def get_version(): """Return the version number.""" @@ -174,16 +176,20 @@ def main(): otel_entries = delivery_checkpoint.pending_entries(entries) if delivery_checkpoint.load_failed: observer.record_failure("export", "checkpoint_read_error") - print("OpenTelemetry delivery checkpoint unreadable or invalid; retrying sessions.", file=sys.stderr) + logger.warning("OpenTelemetry delivery checkpoint unreadable or invalid; retrying sessions.") # Apply incremental filtering stage = "save" if args.save_sessions and entries: - print("\nSession-based incremental mode: Checking existing session files...") + logger.info("\nSession-based incremental mode: Checking existing session files...") original_count = len(entries) entries = observer.filter_entries_by_existing_files(entries, args.output_dir) filtered_count = len(entries) - print(f" -> Filtered {original_count - filtered_count} existing sessions, processing {filtered_count} new") + logger.info( + " -> Filtered %d existing sessions, processing %d new", + original_count - filtered_count, + filtered_count, + ) # Display summary observer.display_summary(entries, system_config_data, limit=args.limit) @@ -195,7 +201,9 @@ def main(): if args.save_sessions: if entries: saved_files = observer.save_sessions_to_individual_files(entries, output_dir=args.output_dir) - print(f"\nSession files saved to: {saved_files[0].parent if saved_files else 'No files saved'}") + logger.info( + "\nSession files saved to: %s", saved_files[0].parent if saved_files else "No files saved" + ) else: if args.output_dir is None: project_output_dir = Path.cwd() / "output" @@ -219,13 +227,13 @@ def main(): otel_exporter.shutdown() if delivery_checkpoint is not None: delivery_checkpoint.commit() - print(f"\nOpenTelemetry session/configuration logs sent: {exported_count}") + logger.info("\nOpenTelemetry session/configuration logs sent: %d", exported_count) success = observer.has_errors is not True if success: - print("\nADR Sensor complete!\n") + logger.info("\nADR Sensor complete!\n") else: - print("\nADR Sensor completed with errors; see diagnostics.jsonl.\n") + logger.warning("\nADR Sensor completed with errors; see diagnostics.jsonl.\n") if args.fail_on_error: raise SystemExit(1) @@ -233,14 +241,14 @@ def main(): success = False if observer is not None: observer.record_failure("export", "export_error") - print(f"OpenTelemetry export failed: {exc}", file=sys.stderr) + logger.error("OpenTelemetry export failed: %s", exc) raise SystemExit(1) except DeliveryCheckpointError as exc: success = False if observer is not None: observer.record_failure("export", "checkpoint_write_error") - print(f"OpenTelemetry checkpoint failed: {exc}", file=sys.stderr) + logger.error("OpenTelemetry checkpoint failed: %s", exc) raise SystemExit(1) except Exception: diff --git a/Sensor/tests/test_diagnostics.py b/Sensor/tests/test_diagnostics.py index 1ed3358..a664862 100644 --- a/Sensor/tests/test_diagnostics.py +++ b/Sensor/tests/test_diagnostics.py @@ -224,10 +224,24 @@ def test_cli_can_fail_after_preserving_partial_capture(tmp_path, monkeypatch, ca with patch("adr_sensor.cli.AgentObserver", return_value=observer), pytest.raises(SystemExit) as failure: main() assert failure.value.code == 1 - assert "completed with errors" in capsys.readouterr().out + assert "completed with errors" in capsys.readouterr().err assert (tmp_path / "diagnostics.jsonl").exists() +def test_quiet_cli_keeps_the_summary_and_warnings_but_hides_progress(tmp_path, monkeypatch, capsys): + observer = _observer(tmp_path, _Parser([_event()], reason="record_shape_error")) + monkeypatch.setattr("sys.argv", ["adr-sensor", "--no-save", "--quiet"]) + try: + with patch("adr_sensor.cli.AgentObserver", return_value=observer): + main() + finally: + sensor_log.set_console_level("info") + captured = capsys.readouterr() + assert "INGESTION SUMMARY" in captured.out + assert "Ingesting Claude Code logs" not in captured.out + assert "completed with errors" in captured.err + + def test_export_failure_is_recorded_locally(tmp_path, monkeypatch): observer = _observer(tmp_path, _Parser()) exporter = MagicMock() From ff35560be61c3ff3945d64ad501d8cb61d02ce3f Mon Sep 17 00:00:00 2001 From: v314b0i <29569580+v314b0i@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:37:21 +0000 Subject: [PATCH 4/5] [ported] refactor(sensor): log the diagnostics write-failure warning Emit the "Unable to write sensor diagnostics" notice as a WARNING on the adr_sensor.diagnostics logger instead of printing to stderr. It still reaches stderr once per run and still carries no exception text. --- Sensor/adr_sensor/diagnostics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sensor/adr_sensor/diagnostics.py b/Sensor/adr_sensor/diagnostics.py index 08e8ddb..cb49a09 100644 --- a/Sensor/adr_sensor/diagnostics.py +++ b/Sensor/adr_sensor/diagnostics.py @@ -2,7 +2,6 @@ import json import logging -import sys from datetime import datetime, timezone from logging.handlers import RotatingFileHandler from pathlib import Path @@ -11,6 +10,8 @@ from . import __version__ from .parsers.base_parser import BaseParser +logger = logging.getLogger(__name__) + DIAGNOSTIC_SOURCES = frozenset( {"sensor", "claude", "claude_desktop", "cursor", "cline", "codex", "copilot", "dsh", "gemini", "opencode", "warp"} ) @@ -114,7 +115,7 @@ def handle_error(record): handlers[1].handle(item) return True except Exception: - print("[ADR] Unable to write sensor diagnostics; captured session data is unaffected.", file=sys.stderr) + logger.warning("[ADR] Unable to write sensor diagnostics; captured session data is unaffected.") return False finally: for handler in handlers: From beb9f3d209b36a095cd2fdaa3bc36a0c449394fa Mon Sep 17 00:00:00 2001 From: v314b0i <29569580+v314b0i@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:37:36 +0000 Subject: [PATCH 5/5] [ported] docs(sensor): document console log levels Describe --log-level and -q/--quiet in the README CLI usage section, including where progress and errors are printed and that the ingestion summary is always shown. --- Sensor/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sensor/README.md b/Sensor/README.md index 9f90a37..ff4ac8e 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -286,11 +286,20 @@ adr-sensor --otel-config ./opentelemetry-config.json # Export to OTLP without also writing JSON files adr-sensor --no-save --otel-config ./opentelemetry-config.json + +# Print only warnings and errors (or pick a level: debug, info, warning, error) +adr-sensor --quiet +adr-sensor --log-level debug ``` Sources whose agent only runs on some operating systems are skipped automatically on other platforms — `--source all` on Linux will not attempt `claude_desktop`, for example. +Progress messages go to stdout and warnings and errors to stderr. `--log-level` +sets the minimum level shown (default `info`) and `-q/--quiet` equals +`--log-level warning`. The ingestion summary table is always printed. Library +callers can use `adr_sensor.sensor_log.set_console_level()` for the same effect. + ### Python API ```python