From eb30450f34c0954f32651d2145ce90a6dcad8fc6 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 2 Jun 2026 14:21:17 +0100 Subject: [PATCH] feat: add telemetry foundation --- RUN_LOG.md | 1 + TASKS.md | 2 +- src/rdb_discovery/telemetry.py | 78 +++++++++++++++++++ tests/test_telemetry.py | 133 +++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/rdb_discovery/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/RUN_LOG.md b/RUN_LOG.md index 310ee7d..e8f7638 100644 --- a/RUN_LOG.md +++ b/RUN_LOG.md @@ -11,3 +11,4 @@ TASK-002 marked Done — agent prompt generation complete. | 2026-06-02T10:01:31 | Task completed | TASK-004 | 28 tests passed | | 2026-06-02T10:02:18 | Task completed | TASK-004 | rdb ask-more implemented and tested | | 2026-06-02T12:51:20 | Task completed | TASK-007 | enhanced prompts | +| 2026-06-02T13:45:00 | Task completed | TASK-012 | telemetry foundation — writer, JSONL storage, event reader, 12 tests passed | diff --git a/TASKS.md b/TASKS.md index 7e6c23a..39b8b98 100644 --- a/TASKS.md +++ b/TASKS.md @@ -132,7 +132,7 @@ Acceptance Criteria: ## TASK-012 — Telemetry foundation -Status: Todo +Status: Done Goal: Create a minimal telemetry system that can record structured agent activity for future guardrail and analysis features. diff --git a/src/rdb_discovery/telemetry.py b/src/rdb_discovery/telemetry.py new file mode 100644 index 0000000..01203e5 --- /dev/null +++ b/src/rdb_discovery/telemetry.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass +class TelemetryEvent: + """A single structured telemetry event.""" + timestamp: str + event_type: str + target: str + details: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return asdict(self) + + +def _session_log_path(root: Path) -> Path: + """Return the path to the session-log.jsonl file under .rdb/.""" + rdb_dir = root / ".rdb" + rdb_dir.mkdir(parents=True, exist_ok=True) + return rdb_dir / "session-log.jsonl" + + +def record_event(root: Path, event_type: str, target: str, details: dict | None = None) -> TelemetryEvent: + """Record a telemetry event to the session log. + + Creates the .rdb/session-log.jsonl file if it does not exist. + Each call appends one JSON line to the file. + + Args: + root: Project root directory. + event_type: Category of event, e.g. 'command', 'task', 'guardrail'. + target: The entity the event relates to, e.g. 'rdb prompt'. + details: Optional extra key-value pairs for context. + + Returns: + The TelemetryEvent that was recorded. + """ + event = TelemetryEvent( + timestamp=datetime.now(timezone.utc).isoformat(), + event_type=event_type, + target=target, + details=details or {}, + ) + + log_path = _session_log_path(root) + with open(log_path, "a", encoding="utf-8") as f: + f.write(json.dumps(event.to_dict()) + "\n") + + return event + + +def read_events(root: Path) -> list[TelemetryEvent]: + """Read all events from the session log. + + Returns an empty list if the log does not yet exist or is empty. + Malformed lines are silently skipped. + """ + log_path = _session_log_path(root) + if not log_path.exists(): + return [] + + events: list[TelemetryEvent] = [] + for line in log_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + events.append(TelemetryEvent(**data)) + except (json.JSONDecodeError, TypeError): + pass + + return events diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..f462763 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from rdb_discovery.telemetry import TelemetryEvent, record_event, read_events + + +# -- TelemetryEvent -- + +def test_telemetry_event_to_dict() -> None: + event = TelemetryEvent( + timestamp="2026-06-02T12:00:00+00:00", + event_type="command", + target="rdb prompt", + details={"flag": True}, + ) + d = event.to_dict() + assert d["event_type"] == "command" + assert d["target"] == "rdb prompt" + assert d["details"]["flag"] is True + + +def test_telemetry_event_defaults_to_empty_details() -> None: + event = TelemetryEvent( + timestamp="2026-06-02T12:00:00+00:00", + event_type="command", + target="rdb prompt", + ) + assert event.details == {} + + +def test_telemetry_event_required_fields() -> None: + event = TelemetryEvent( + timestamp="2026-06-02T12:00:00+00:00", + event_type="command", + target="rdb prompt", + ) + assert event.timestamp == "2026-06-02T12:00:00+00:00" + assert event.event_type == "command" + assert event.target == "rdb prompt" + + +# -- record_event (file creation) -- + +def test_record_event_creates_session_log(tmp_path: Path) -> None: + log = tmp_path / ".rdb" / "session-log.jsonl" + assert not log.exists() + record_event(tmp_path, "command", "rdb prompt") + assert log.exists() + + +# -- record_event (appending) -- + +def test_record_event_appends_jsonl_line(tmp_path: Path) -> None: + record_event(tmp_path, "command", "rdb prompt") + record_event(tmp_path, "task", "TASK-012") + lines = log_lines(tmp_path) + assert len(lines) == 2 + + +def test_record_event_writes_valid_json_per_line(tmp_path: Path) -> None: + record_event(tmp_path, "command", "rdb prompt", {"arg": "value"}) + record_event(tmp_path, "task", "TASK-012") + for line in log_lines(tmp_path): + data = json.loads(line) + assert data["event_type"] in ("command", "task") + + +def test_record_event_has_required_keys(tmp_path: Path) -> None: + record_event(tmp_path, "command", "rdb prompt") + event = TelemetryEvent(**json.loads(log_lines(tmp_path)[0])) + assert hasattr(event, "timestamp") + assert hasattr(event, "event_type") + assert hasattr(event, "target") + assert hasattr(event, "details") + + +def test_record_event_details_default_empty_dict(tmp_path: Path) -> None: + record_event(tmp_path, "command", "rdb prompt") + event = TelemetryEvent(**json.loads(log_lines(tmp_path)[0])) + assert event.details == {} + + +# -- read_events (no file) -- + +def test_read_events_returns_empty_when_no_log(tmp_path: Path) -> None: + events = read_events(tmp_path) + assert events == [] + + +# -- read_events (round-trip) -- + +def test_read_events_returns_recorded_events(tmp_path: Path) -> None: + record_event(tmp_path, "command", "rdb prompt", {"count": 42}) + events = read_events(tmp_path) + assert len(events) == 1 + assert events[0].event_type == "command" + assert events[0].target == "rdb prompt" + assert events[0].details["count"] == 42 + + +def test_read_events_returns_multiple_events(tmp_path: Path) -> None: + record_event(tmp_path, "task", "TASK-001") + record_event(tmp_path, "task", "TASK-002") + record_event(tmp_path, "command", "rdb status") + events = read_events(tmp_path) + assert len(events) == 3 + + +# -- read_events (malformed lines) -- + +def test_read_events_skips_malformed_lines(tmp_path: Path) -> None: + log_path = tmp_path / ".rdb" / "session-log.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text( + '{"timestamp":"2026-01-01T00:00:00+00:00","event_type":"ok","target":"x","details":{}}\n' + 'this is not json\n' + '{"timestamp":"2026-01-01T00:00:00+00:00","event_type":"also_ok","target":"y","details":{}}\n', + encoding="utf-8", + ) + events = read_events(tmp_path) + assert len(events) == 2 + assert events[0].event_type == "ok" + assert events[1].event_type == "also_ok" + + +# -- Helpers -- + +def log_lines(root: Path) -> list[str]: + return (root / ".rdb" / "session-log.jsonl").read_text(encoding="utf-8").strip().splitlines()