Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sensor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 30 additions & 11 deletions Sensor/adr_sensor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +29,9 @@
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

logger = logging.getLogger(__name__)


def get_version():
Expand Down Expand Up @@ -113,8 +115,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:
Expand Down Expand Up @@ -163,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)
Expand All @@ -184,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"
Expand All @@ -208,28 +227,28 @@ 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)

except OpenTelemetryExportError as exc:
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:
Expand Down
5 changes: 3 additions & 2 deletions Sensor/adr_sensor/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import json
import logging
import sys
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from pathlib import Path
Expand All @@ -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"}
)
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 21 additions & 15 deletions Sensor/adr_sensor/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import errno
import hashlib
import json
import logging
import os
import platform
import re
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand All @@ -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 {}
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -429,17 +430,22 @@ 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:
if temp_path is not None and temp_path.exists():
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)

Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion Sensor/tests/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions Sensor/tests/test_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions Sensor/tests/test_sensor_log.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Loading