diff --git a/diagnostic/build-fb0df9c3-part001.logd b/diagnostic/build-fb0df9c3-part001.logd new file mode 100644 index 00000000..26ac3689 Binary files /dev/null and b/diagnostic/build-fb0df9c3-part001.logd differ diff --git a/diagnostic/build-fb0df9c3-part002.logd b/diagnostic/build-fb0df9c3-part002.logd new file mode 100644 index 00000000..7b64ac81 Binary files /dev/null and b/diagnostic/build-fb0df9c3-part002.logd differ diff --git a/diagnostic/build-fb0df9c3-part003.logd b/diagnostic/build-fb0df9c3-part003.logd new file mode 100644 index 00000000..2e8e0ec7 Binary files /dev/null and b/diagnostic/build-fb0df9c3-part003.logd differ diff --git a/diagnostic/build-fb0df9c3.json b/diagnostic/build-fb0df9c3.json new file mode 100644 index 00000000..5792faab --- /dev/null +++ b/diagnostic/build-fb0df9c3.json @@ -0,0 +1,90 @@ +{ + "generated_at": "2026-07-09T12:11:02.292720+00:00", + "commit": "fb0df9c3", + "diagnostic_logd": [ + "diagnostic\\build-fb0df9c3-part001.logd", + "diagnostic\\build-fb0df9c3-part002.logd", + "diagnostic\\build-fb0df9c3-part003.logd" + ], + "diagnostic_logd_error": null, + "chunked": true, + "chunk_size_bytes": 41943040, + "password": "a7430eb931d198efd7dd", + "decrypt_command": "encryptly unpack diagnostic\\build-fb0df9c3.logd --password a7430eb931d198efd7dd", + "total_modules": 10, + "passed": 0, + "failed": 10, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "frontend", + "status": "FAIL", + "elapsed_seconds": 0.141, + "artifact": null, + "output": "npm install failed:\nnpm: can't open file 'C:\\\\Users\\\\27796\\\\Documents\\\\Codex\\\\2026-07-09\\\\github-bounty-pr-20-30-zhaoziyuan2024\\\\work\\\\zeroeye\\\\frontend\\\\install': [Errno 2] No such file or directory\n" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "frailbox", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-fb0df9c3-part001.logd, diagnostic\\build-fb0df9c3-part002.logd, diagnostic\\build-fb0df9c3-part003.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..d892d7a8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package marker for unittest discovery. diff --git a/tests/test_monitoring_setup.py b/tests/test_monitoring_setup.py new file mode 100644 index 00000000..83afa506 --- /dev/null +++ b/tests/test_monitoring_setup.py @@ -0,0 +1,67 @@ +import pathlib +import sys +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from tools import monitoring_setup + + +class MonitoringSetupRuleTests(unittest.TestCase): + def test_recommended_alert_rules_do_not_self_divide(self): + self.assertEqual( + monitoring_setup.validate_alert_rules( + monitoring_setup.RECOMMENDED_ALERT_RULES + ), + [], + ) + + def test_high_memory_alert_uses_machine_memory_denominator(self): + high_memory_rule = next( + rule for rule in monitoring_setup.RECOMMENDED_ALERT_RULES + if rule["name"] == "HighMemoryUsage" + ) + + self.assertIn("process_resident_memory_bytes", high_memory_rule["expr"]) + self.assertIn("machine_memory_bytes", high_memory_rule["expr"]) + self.assertNotIn( + "process_resident_memory_bytes / process_resident_memory_bytes", + high_memory_rule["expr"], + ) + + def test_validation_rejects_self_dividing_alert_expression(self): + invalid_rules = [{ + "name": "BadMemoryUsage", + "expr": ( + "process_resident_memory_bytes{job='api'} / " + "process_resident_memory_bytes{job='api'} > 0.9" + ), + }] + + self.assertEqual( + monitoring_setup.validate_alert_rules(invalid_rules), + [ + "BadMemoryUsage: expression divides " + "process_resident_memory_bytes by itself" + ], + ) + + def test_dry_run_rejects_invalid_self_dividing_rule(self): + invalid_rules = [{ + "name": "BadMemoryUsage", + "expr": "process_resident_memory_bytes / process_resident_memory_bytes > 0.9", + }] + + self.assertFalse( + monitoring_setup.upload_prometheus_rules( + invalid_rules, + monitoring_setup.DEFAULT_PROMETHEUS_URL, + dry_run=True, + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/monitoring_setup.py b/tools/monitoring_setup.py index 65f43d20..76454df0 100644 --- a/tools/monitoring_setup.py +++ b/tools/monitoring_setup.py @@ -25,6 +25,7 @@ import argparse import json import os +import re import sys import time import urllib.request @@ -42,6 +43,14 @@ DASHBOARD_DIR = os.path.join(os.path.dirname(__file__), "..", "monitoring", "dashboards") ALERT_RULES_DIR = os.path.join(os.path.dirname(__file__), "..", "monitoring", "alerts") +METRIC_NAME_RE = r"[a-zA-Z_:][a-zA-Z0-9_:]*" +SELF_DIVISION_RE = re.compile( + rf"\b({METRIC_NAME_RE})(?:\s*\{{[^{{}}]*\}})?\s*/\s*" + rf"(?:on\s*\([^)]*\)\s*)?" + rf"(?:ignoring\s*\([^)]*\)\s*)?" + rf"(?:group_(?:left|right)(?:\s*\([^)]*\))?\s*)?" + rf"({METRIC_NAME_RE})(?:\s*\{{[^{{}}]*\}})?\b" +) RECOMMENDED_ALERT_RULES: List[Dict[str, Any]] = [ { @@ -78,7 +87,7 @@ }, { "name": "HighMemoryUsage", - "expr": "process_resident_memory_bytes / process_resident_memory_bytes > 0.9", + "expr": "process_resident_memory_bytes / on(instance) group_left machine_memory_bytes > 0.9", "duration": "10m", "severity": "warning", "summary": "High memory usage on {{$labels.instance}}", @@ -146,12 +155,30 @@ {"name": "job:http_requests_total:rate5m", "expr": "sum(rate(http_requests_total[5m])) by (job)"}, {"name": "job:http_error_rate:rate5m", "expr": "sum(rate(http_errors_total[5m])) by (job) / sum(rate(http_requests_total[5m])) by (job)"}, {"name": "job:http_latency_p99:rate5m", "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))"}, - {"name": "instance:memory_usage:ratio", "expr": "process_resident_memory_bytes / machine_memory_bytes"}, + {"name": "instance:memory_usage:ratio", "expr": "process_resident_memory_bytes / on(instance) group_left machine_memory_bytes"}, {"name": "instance:cpu_usage:ratio", "expr": "rate(process_cpu_seconds_total[5m])"}, {"name": "service:uptime:days", "expr": "time() - process_start_time_seconds{job=~'.+'}"}, ] +def find_self_dividing_expressions(rules: List[Dict[str, Any]]) -> List[str]: + """Return rule names whose expressions divide the same metric by itself.""" + errors = [] + for rule in rules: + expr = str(rule.get("expr", "")) + for lhs_metric, rhs_metric in SELF_DIVISION_RE.findall(expr): + if lhs_metric == rhs_metric: + name = rule.get("name", "") + errors.append( + f"{name}: expression divides {lhs_metric} by itself" + ) + return errors + + +def validate_alert_rules(rules: List[Dict[str, Any]]) -> List[str]: + return find_self_dividing_expressions(rules) + + def http_request(method: str, url: str, data: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: if headers is None: @@ -200,6 +227,13 @@ def check_alertmanager(url: str) -> bool: def upload_prometheus_rules(rules: List[Dict[str, Any]], prometheus_url: str, dry_run: bool = False) -> bool: + validation_errors = validate_alert_rules(rules) + if validation_errors: + print("Invalid Prometheus alert rules:", file=sys.stderr) + for error in validation_errors: + print(f" - {error}", file=sys.stderr) + return False + rules_file = "/etc/prometheus/rules/tent_rules.yml" print(f"{'Would upload' if dry_run else 'Uploading'} {len(rules)} rules to {prometheus_url}") @@ -414,13 +448,14 @@ def main(): args.alertmanager_url, args.slack_webhook, args.pagerduty_key, args.dry_run) - upload_prometheus_rules(RECOMMENDED_ALERT_RULES, args.prometheus_url, args.dry_run) + if not upload_prometheus_rules(RECOMMENDED_ALERT_RULES, args.prometheus_url, args.dry_run): + return 1 print("Monitoring initialization complete") return 0 if args.alerts: - upload_prometheus_rules(RECOMMENDED_ALERT_RULES, args.prometheus_url, args.dry_run) - return 0 + return 0 if upload_prometheus_rules( + RECOMMENDED_ALERT_RULES, args.prometheus_url, args.dry_run) else 1 if args.backup: return 0 if backup_monitoring_config(