1+ # Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+ #
3+ # Copyright (C) 2026 Tencent. All rights reserved.
4+ #
5+ # tRPC-Agent-Python is licensed under Apache-2.0.
6+ """Monitoring and audit layer for the code review agent.
7+
8+ Records per-review telemetry including:
9+ - Total duration and sandbox execution duration
10+ - Tool call and filter intercept counts
11+ - Finding count and severity distribution
12+ - Exception type distribution
13+
14+ All metrics are persisted to the monitor_summary table in the database.
15+ """
16+
17+ from __future__ import annotations
18+
19+ import json
20+ import time
21+ import traceback
22+ from collections import Counter
23+ from dataclasses import dataclass , field
24+ from typing import Optional
25+
26+ from .db .storage import StorageABC
27+ from .models import Finding , MonitorSummary , ReviewTask
28+
29+
30+ # ──────────────────────────────────────────────
31+ # Data structures
32+ # ──────────────────────────────────────────────
33+
34+
35+ @dataclass
36+ class ReviewMetrics :
37+ """Collected metrics for a single review session."""
38+
39+ # Timing
40+ total_duration_ms : float = 0.0
41+ sandbox_duration_ms : float = 0.0
42+ parse_duration_ms : float = 0.0
43+ filter_duration_ms : float = 0.0
44+
45+ # Counters
46+ tool_call_count : int = 0
47+ intercept_count : int = 0
48+ finding_count : int = 0
49+
50+ # Distributions
51+ severity_counts : dict [str , int ] = field (default_factory = dict )
52+ exception_types : list [str ] = field (default_factory = list )
53+
54+ # Errors
55+ errors : list [str ] = field (default_factory = list )
56+
57+ def to_monitor_summary (self , task_id : str ) -> MonitorSummary :
58+ """Convert metrics to a MonitorSummary model for DB persistence."""
59+ return MonitorSummary (
60+ task_id = task_id ,
61+ total_duration_ms = self .total_duration_ms ,
62+ sandbox_duration_ms = self .sandbox_duration_ms ,
63+ tool_call_count = self .tool_call_count ,
64+ intercept_count = self .intercept_count ,
65+ finding_count = self .finding_count ,
66+ severity_distribution = json .dumps (self .severity_counts , ensure_ascii = False ),
67+ exception_types = json .dumps (self .exception_types , ensure_ascii = False ),
68+ )
69+
70+
71+ # ──────────────────────────────────────────────
72+ # 7.1 + 7.2: Monitor
73+ # ──────────────────────────────────────────────
74+
75+
76+ class ReviewMonitor :
77+ """Collects and persists review telemetry.
78+
79+ Usage:
80+ monitor = ReviewMonitor(storage, task_id)
81+ monitor.start()
82+ # ... run review ...
83+ monitor.record_sandbox(duration_ms=1500)
84+ monitor.record_tool_call()
85+ monitor.record_findings(findings)
86+ monitor.record_exception(e)
87+ monitor.finish() # saves to DB
88+ """
89+
90+ def __init__ (self , storage : StorageABC , task_id : str ):
91+ self .storage = storage
92+ self .task_id = task_id
93+ self .metrics = ReviewMetrics ()
94+ self ._start_time : Optional [float ] = None
95+
96+ # ── Timing ──
97+
98+ def start (self ) -> None :
99+ """Start the review timer."""
100+ self ._start_time = time .monotonic ()
101+
102+ def finish (self ) -> MonitorSummary :
103+ """Stop the timer and persist metrics to the database.
104+
105+ Returns:
106+ The saved MonitorSummary.
107+ """
108+ if self ._start_time is not None :
109+ self .metrics .total_duration_ms = (time .monotonic () - self ._start_time ) * 1000
110+
111+ summary = self .metrics .to_monitor_summary (self .task_id )
112+ self .storage .save_monitor_summary (summary )
113+ return summary
114+
115+ # ── Individual recorders ──
116+
117+ def record_sandbox_duration (self , duration_ms : float ) -> None :
118+ """Record sandbox execution duration."""
119+ self .metrics .sandbox_duration_ms += duration_ms
120+
121+ def record_parse_duration (self , duration_ms : float ) -> None :
122+ """Record diff parsing duration."""
123+ self .metrics .parse_duration_ms += duration_ms
124+
125+ def record_filter_duration (self , duration_ms : float ) -> None :
126+ """Record filter evaluation duration."""
127+ self .metrics .filter_duration_ms += duration_ms
128+
129+ def record_tool_call (self ) -> None :
130+ """Increment the tool call counter."""
131+ self .metrics .tool_call_count += 1
132+
133+ def record_intercept (self ) -> None :
134+ """Increment the filter intercept counter."""
135+ self .metrics .intercept_count += 1
136+
137+ def record_findings (
138+ self ,
139+ findings : list [Finding ],
140+ warnings : Optional [list [Finding ]] = None ,
141+ needs_review : Optional [list [Finding ]] = None ,
142+ ) -> None :
143+ """Record findings and compute severity distribution.
144+
145+ Args:
146+ findings: High-confidence findings.
147+ warnings: Medium-confidence findings (optional).
148+ needs_review: Low-confidence findings (optional).
149+ """
150+ all_findings = list (findings )
151+ if warnings :
152+ all_findings .extend (warnings )
153+ if needs_review :
154+ all_findings .extend (needs_review )
155+
156+ self .metrics .finding_count = len (all_findings )
157+
158+ # Severity distribution
159+ severity_counts : Counter = Counter ()
160+ for f in all_findings :
161+ severity_counts [f .severity .value ] += 1
162+ self .metrics .severity_counts = dict (severity_counts )
163+
164+ def record_exception (self , exception : Exception ) -> None :
165+ """Record an exception type for the audit log.
166+
167+ Args:
168+ exception: The exception that occurred.
169+ """
170+ exc_type = type (exception ).__name__
171+ if exc_type not in self .metrics .exception_types :
172+ self .metrics .exception_types .append (exc_type )
173+ self .metrics .errors .append (f"{ exc_type } : { str (exception )[:200 ]} " )
174+
175+ # ── Batch / convenience ──
176+
177+ def update_from_task (self , task : ReviewTask ) -> None :
178+ """Update metrics from a completed ReviewTask."""
179+ if task .total_duration_ms is not None :
180+ self .metrics .total_duration_ms = task .total_duration_ms
181+ if task .error_message :
182+ self .metrics .errors .append (task .error_message )
183+
184+
185+ # ──────────────────────────────────────────────
186+ # 7.3: DB persistence helper
187+ # ──────────────────────────────────────────────
188+
189+
190+ def save_monitor_summary (
191+ storage : StorageABC ,
192+ task_id : str ,
193+ total_duration_ms : float = 0.0 ,
194+ sandbox_duration_ms : float = 0.0 ,
195+ tool_call_count : int = 0 ,
196+ intercept_count : int = 0 ,
197+ finding_count : int = 0 ,
198+ severity_distribution : Optional [dict [str , int ]] = None ,
199+ exception_types : Optional [list [str ]] = None ,
200+ ) -> MonitorSummary :
201+ """Create and save a MonitorSummary to the database.
202+
203+ This is a convenience function for one-off saves without creating
204+ a full ReviewMonitor instance.
205+
206+ Args:
207+ storage: Storage backend.
208+ task_id: Review task ID.
209+ total_duration_ms: Total review duration in ms.
210+ sandbox_duration_ms: Total sandbox duration in ms.
211+ tool_call_count: Number of tool calls.
212+ intercept_count: Number of filter intercepts.
213+ finding_count: Total number of findings.
214+ severity_distribution: Dict of severity → count.
215+ exception_types: List of exception type names.
216+
217+ Returns:
218+ The saved MonitorSummary.
219+ """
220+ summary = MonitorSummary (
221+ task_id = task_id ,
222+ total_duration_ms = total_duration_ms ,
223+ sandbox_duration_ms = sandbox_duration_ms ,
224+ tool_call_count = tool_call_count ,
225+ intercept_count = intercept_count ,
226+ finding_count = finding_count ,
227+ severity_distribution = json .dumps (severity_distribution or {}, ensure_ascii = False ),
228+ exception_types = json .dumps (exception_types or [], ensure_ascii = False ),
229+ )
230+ return storage .save_monitor_summary (summary )
231+
232+
233+ def get_monitor_summary (storage : StorageABC , task_id : str ) -> Optional [MonitorSummary ]:
234+ """Retrieve a MonitorSummary from the database."""
235+ return storage .get_monitor_summary (task_id )
0 commit comments