#!/usr/bin/env python3 """ GCP Daily Billing Analysis and Alert System (main_analyze.py) Features: 1. Analyze billing data from day before yesterday, detect cost anomalies 2. Analyze daily total cost by project dimension for anomalies 3. Analyze service costs by service dimension for anomalies 4. Detect new services and suddenly stopped services 5. Send alert notifications via Feishu webhook 6. Support environment-based (prod/nonprod) classification analysis Usage: python -m gcp.main_analyze --date 2024-01-15 # Analyze specified date python -m gcp.main_analyze --config gcp/config.json # Analyze day before yesterday python -m gcp.main_analyze --dry-run # Analyze without sending notifications python -m gcp.main_analyze --project-id tidbcloud-prod-000 # Analyze specific project """ import argparse import csv import json import statistics import sys from collections import defaultdict from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional import requests GCP_DIR = Path(__file__).resolve().parent from gcp.account_details import load_active_accounts class GCPBillingDataAnalyzer: """GCP Billing Data Analyzer""" def __init__(self, data_dir: str = "gcp_billing_exports", project_filter: Optional[str] = None, environment_config: Optional[Dict] = None, account_details_file: Optional[str] = None): data_dir_path = Path(data_dir) if not data_dir_path.is_absolute(): data_dir_path = GCP_DIR / data_dir_path self.data_dir = data_dir_path self.anomaly_detector = AnomalyDetector() self.project_filter = project_filter # 可选的项目过滤器 self.environment_config = environment_config or {} # Environment configuration config_account_details = None if isinstance(self.environment_config, dict): config_account_details = self.environment_config.get('accountDetailsFile') self.account_details_file = account_details_file or config_account_details self.active_project_ids = load_active_accounts(self.account_details_file) # 默认异常阈值配置 self.thresholds = { 'project_warning': 1.5, # Project cost exceeds baseline 150% triggers warning 'project_critical': 2.0, # Project cost exceeds baseline 200% triggers critical alert 'service_warning': 2.0, # Service cost exceeds baseline 200% triggers warning 'service_critical': 3.0, # Service cost exceeds baseline 300% triggers critical alert 'min_cost_threshold': 1.0, # Minimum cost threshold, no alert below this value 'new_service_threshold': 5.0 # New service cost exceeds this value to trigger alert } def load_billing_data(self, target_date: str, include_historical: bool = True) -> Dict[str, List[Dict]]: """Load billing data from environment directories Args: target_date: Target date (YYYY-MM-DD) include_historical: Whether to include historical month data (for baseline calculation) Returns: Dict[project_id, List[billing_records]] """ target_month = target_date[:7] # YYYY-MM project_data = {} # 确定需要加载的月份 months_to_load = [target_month] if include_historical: # 计算可能需要的历史月份(过去30天可能跨越上个月) target_date_obj = datetime.strptime(target_date, '%Y-%m-%d').date() start_date_obj = target_date_obj - timedelta(days=30) # 如果历史数据跨越了上个月,添加上个月 if start_date_obj.strftime('%Y-%m') != target_month: prev_month = start_date_obj.strftime('%Y-%m') if prev_month not in months_to_load: months_to_load.append(prev_month) # 如果是月初几天,额外确保加载上个月数据 if target_date_obj.day <= 5: prev_month_obj = target_date_obj.replace(day=1) - timedelta(days=1) prev_month = prev_month_obj.strftime('%Y-%m') if prev_month not in months_to_load: months_to_load.append(prev_month) print(f"Loading monthly data: {months_to_load}") # Load data from environment directories for env_dir in ['prod', 'nonprod']: env_path = self.data_dir / env_dir if not env_path.exists(): continue # 为每个月份加载数据 for month in months_to_load: if self.project_filter: csv_pattern = f"gcp_{self.project_filter}_{month}.csv" csv_files = list(env_path.glob(csv_pattern)) else: # 扫描所有CSV文件,找到匹配月份的数据 csv_files = list(env_path.glob(f"gcp_*_{month}.csv")) for csv_file in csv_files: try: # 从文件名提取项目ID parts = csv_file.stem.split('_') if len(parts) >= 3: # GCP项目ID可能包含连字符,需要重构项目ID project_id = '_'.join(parts[1:-1]) # 排除第一个'gcp'和最后一个时间部分 # 如果指定了项目过滤器,再次确认匹配 if self.project_filter and project_id != self.project_filter: continue # 只分析活跃账户 if (self.project_filter is None and self.active_project_ids is not None and project_id not in self.active_project_ids): continue # 如果项目数据已存在,追加数据;否则创建新的 if project_id not in project_data: project_data[project_id] = [] with open(csv_file, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) records_count = 0 for row in reader: # 转换数据类型 row['cost'] = float(row['cost']) project_data[project_id].append(row) records_count += 1 print(f"Loaded project {project_id} ({env_dir}) {month} data, {records_count} records") except Exception as e: print(f"Failed to load file {csv_file}: {e}") if self.project_filter and not project_data: print(f"Warning: No data found for project {self.project_filter}") # 统计总加载数据 total_records = sum(len(records) for records in project_data.values()) print(f"Total loaded {len(project_data)} projects, {total_records} records") return project_data def filter_data_by_date(self, project_data: Dict[str, List[Dict]], target_date: str) -> Dict[str, List[Dict]]: """Filter data by date""" filtered_data = {} for project_id, records in project_data.items(): filtered_records = [r for r in records if r['date'] == target_date] if filtered_records: filtered_data[project_id] = filtered_records return filtered_data def get_historical_data(self, project_data: Dict[str, List[Dict]], end_date: str, days: int = 30) -> Dict[str, List[Dict]]: """Get historical data for establishing baseline""" end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() start_date_obj = end_date_obj - timedelta(days=days) historical_data = {} for project_id, records in project_data.items(): historical_records = [] for record in records: record_date = datetime.strptime(record['date'], '%Y-%m-%d').date() if start_date_obj <= record_date < end_date_obj: historical_records.append(record) if historical_records: historical_data[project_id] = historical_records return historical_data def get_extended_historical_data(self, target_date: str, project_id: str, days: int = 90) -> List[Dict]: """Get extended historical data for a specific project to verify if it's truly a new project Args: target_date: Target date project_id: Project ID days: Number of historical days to check Returns: List of historical records for the project """ end_date_obj = datetime.strptime(target_date, '%Y-%m-%d').date() start_date_obj = end_date_obj - timedelta(days=days) # 计算需要检查的月份范围 months_to_check = set() current_date = start_date_obj while current_date <= end_date_obj: months_to_check.add(current_date.strftime('%Y-%m')) # 移动到下个月的第一天 if current_date.month == 12: current_date = current_date.replace(year=current_date.year + 1, month=1, day=1) else: current_date = current_date.replace(month=current_date.month + 1, day=1) historical_records = [] # Load historical data for this project from environment directories for env_dir in ['prod', 'nonprod']: env_path = self.data_dir / env_dir if not env_path.exists(): continue for month in months_to_check: csv_pattern = f"gcp_{project_id}_{month}.csv" csv_files = list(env_path.glob(csv_pattern)) for csv_file in csv_files: try: with open(csv_file, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: record_date = datetime.strptime(row['date'], '%Y-%m-%d').date() if start_date_obj <= record_date < end_date_obj: row['cost'] = float(row['cost']) historical_records.append(row) except Exception as e: # 文件不存在或读取失败,继续检查其他文件 continue return historical_records def check_service_inactive_for_days(self, project_data: Dict[str, List[Dict]], project_id: str, service: str, end_date: str, days: int = 7) -> bool: """Check if service has had no cost in the past specified days""" end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() start_date_obj = end_date_obj - timedelta(days=days-1) # 包含end_date,所以减去days-1 if project_id not in project_data: return True # 如果项目数据都没有,认为是inactive # 检查这个时间范围内该服务是否有任何费用 for record in project_data[project_id]: record_date = datetime.strptime(record['date'], '%Y-%m-%d').date() if (start_date_obj <= record_date <= end_date_obj and record['product'] == service and record['cost'] > 0): return False # 发现有费用,不是inactive return True # 在指定天数内没有发现任何费用 def analyze_project_anomalies(self, target_data: Dict[str, List[Dict]], historical_data: Dict[str, List[Dict]], target_date: str) -> List[Dict]: """Analyze anomalies at project dimension""" anomalies = [] for project_id in target_data: # 计算目标日期的总费用 target_cost = sum(record['cost'] for record in target_data[project_id]) # 获取项目名称(如果有的话) project_name = None if target_data[project_id]: project_name = target_data[project_id][0].get('project_name', project_id) # 计算历史基线 if project_id in historical_data: baseline = self.anomaly_detector.calculate_project_baseline( historical_data[project_id] ) # 检测异常 anomaly = self.anomaly_detector.detect_project_anomaly( target_cost, baseline, self.thresholds ) if anomaly: anomaly.update({ 'project_id': project_id, 'project_name': project_name, 'date': target_date, 'type': 'project_anomaly' }) anomalies.append(anomaly) else: # 新项目 - 需要进一步验证是否真的是新项目 # 检查是否在整个历史数据中存在(可能只是在过去30天内没有数据) all_historical_data = self.get_extended_historical_data( target_date, project_id, days=90 # 检查过去90天 ) if not all_historical_data: # 确实是新项目 if target_cost > self.thresholds['new_service_threshold']: anomalies.append({ 'project_id': project_id, 'project_name': project_name, 'date': target_date, 'type': 'new_project', 'severity': 'warning', 'current_cost': target_cost, 'message': f'New project incurred cost: ${target_cost:.0f}' }) else: # 项目存在但在近30天内没有数据,可能是重新激活 if target_cost > self.thresholds['new_service_threshold']: anomalies.append({ 'project_id': project_id, 'project_name': project_name, 'date': target_date, 'type': 'project_reactivated', 'severity': 'info', 'current_cost': target_cost, 'message': f'Project reactivated with cost: ${target_cost:.0f} (inactive in recent 30 days)' }) return anomalies def analyze_service_anomalies(self, target_data: Dict[str, List[Dict]], historical_data: Dict[str, List[Dict]], all_project_data: Dict[str, List[Dict]], target_date: str) -> List[Dict]: """Analyze anomalies at service dimension""" anomalies = [] for project_id in target_data: # 按服务聚合目标日期的费用 target_services = {} project_name = None for record in target_data[project_id]: service = record['product'] target_services[service] = target_services.get(service, 0) + record['cost'] if not project_name: project_name = record.get('project_name', project_id) # 计算历史服务基线 if project_id in historical_data: service_baselines = self.anomaly_detector.calculate_service_baselines( historical_data[project_id] ) # 检测服务异常 for service, cost in target_services.items(): if service in service_baselines: anomaly = self.anomaly_detector.detect_service_anomaly( cost, service_baselines[service], self.thresholds ) if anomaly: anomaly.update({ 'project_id': project_id, 'project_name': project_name, 'service': service, 'date': target_date, 'type': 'service_anomaly' }) anomalies.append(anomaly) else: # 新服务 if cost > self.thresholds['new_service_threshold']: anomalies.append({ 'project_id': project_id, 'project_name': project_name, 'service': service, 'date': target_date, 'type': 'new_service', 'severity': 'warning', 'current_cost': cost, 'message': f'New service {service} incurred cost: ${cost:.0f}' }) # 检测停用的服务(连续7天没有费用) for service, baseline in service_baselines.items(): if (baseline['avg_cost'] > self.thresholds['min_cost_threshold'] and self.check_service_inactive_for_days(all_project_data, project_id, service, target_date, 7)): anomalies.append({ 'project_id': project_id, 'project_name': project_name, 'service': service, 'date': target_date, 'type': 'service_stopped', 'severity': 'info', 'baseline_cost': baseline['avg_cost'], 'message': f'Service {service} has been inactive for 7 days (historical average: ${baseline["avg_cost"]:.0f})' }) return anomalies def get_project_environment(self, project_id: str) -> str: """Determine which environment the project belongs to""" if not self.environment_config.get('environments'): return 'unknown' for env_name, env_config in self.environment_config['environments'].items(): if project_id in env_config.get('projects', []): return env_name # If not found in production environment list, default to non-production return 'nonprod' def filter_projects_by_environment(self, project_data: Dict[str, List[Dict]], environment: str) -> Dict[str, List[Dict]]: """Filter project data by environment""" if environment == 'all': return project_data filtered_data = {} for project_id, records in project_data.items(): if self.get_project_environment(project_id) == environment: filtered_data[project_id] = records return filtered_data def analyze_date(self, target_date: str, environment: str = 'all') -> Dict: """Analyze billing data for specified date""" env_name = self.environment_config.get('environments', {}).get(environment, {}).get('name', environment) if environment != 'all' else 'All Environments' print(f"Starting analysis of GCP billing data for {target_date} ({env_name})...") # 加载数据 all_project_data = self.load_billing_data(target_date) if not all_project_data: return { 'success': False, 'message': f'No billing data found for {target_date}' } # 过滤目标日期数据 target_data = self.filter_data_by_date(all_project_data, target_date) if not target_data: return { 'success': False, 'message': f'No billing data for {target_date}' } # Filter data by environment target_data = self.filter_projects_by_environment(target_data, environment) if not target_data: env_name = self.environment_config.get('environments', {}).get(environment, {}).get('name', environment) if environment != 'all' else 'All Environments' return { 'success': False, 'message': f'No {env_name} billing data for {target_date}' } # Get historical data to establish baseline (filtered by environment) historical_data = self.get_historical_data(all_project_data, target_date) historical_data = self.filter_projects_by_environment(historical_data, environment) # 分析项目维度异常 project_anomalies = self.analyze_project_anomalies( target_data, historical_data, target_date ) # 分析服务维度异常 service_anomalies = self.analyze_service_anomalies( target_data, historical_data, all_project_data, target_date ) # 统计信息 total_cost = sum( sum(record['cost'] for record in records) for records in target_data.values() ) project_costs = { project_id: sum(record['cost'] for record in records) for project_id, records in target_data.items() } # 获取项目名称映射 project_names = {} for project_id, records in target_data.items(): if records: project_names[project_id] = records[0].get('project_name', project_id) service_costs = defaultdict(float) for records in target_data.values(): for record in records: service_costs[record['product']] += record['cost'] return { 'success': True, 'date': target_date, 'environment': environment, 'environment_name': self.environment_config.get('environments', {}).get(environment, {}).get('name', environment) if environment != 'all' else 'All Environments', 'total_cost': total_cost, 'project_count': len(target_data), 'service_count': len(service_costs), 'project_costs': project_costs, 'project_names': project_names, 'service_costs': dict(service_costs), 'project_anomalies': project_anomalies, 'service_anomalies': service_anomalies, 'total_anomalies': len(project_anomalies) + len(service_anomalies) } class AnomalyDetector: """Anomaly Detector""" def calculate_project_baseline(self, historical_records: List[Dict]) -> Dict: """Calculate project cost baseline""" # 按日期聚合费用 daily_costs = defaultdict(float) for record in historical_records: daily_costs[record['date']] += record['cost'] costs = list(daily_costs.values()) if not costs: return {'avg_cost': 0, 'median_cost': 0, 'std_cost': 0} return { 'avg_cost': statistics.mean(costs), 'median_cost': statistics.median(costs), 'std_cost': statistics.stdev(costs) if len(costs) > 1 else 0, 'max_cost': max(costs), 'min_cost': min(costs), 'sample_size': len(costs) } def calculate_service_baselines(self, historical_records: List[Dict]) -> Dict[str, Dict]: """Calculate service cost baselines""" service_daily_costs = defaultdict(lambda: defaultdict(float)) # 按服务和日期聚合费用 for record in historical_records: service = record['product'] date = record['date'] service_daily_costs[service][date] += record['cost'] baselines = {} for service, daily_costs in service_daily_costs.items(): costs = list(daily_costs.values()) if costs: baselines[service] = { 'avg_cost': statistics.mean(costs), 'median_cost': statistics.median(costs), 'std_cost': statistics.stdev(costs) if len(costs) > 1 else 0, 'max_cost': max(costs), 'min_cost': min(costs), 'sample_size': len(costs) } return baselines def detect_project_anomaly(self, current_cost: float, baseline: Dict, thresholds: Dict) -> Optional[Dict]: """Detect project cost anomaly""" if baseline['avg_cost'] == 0: return None ratio = current_cost / baseline['avg_cost'] if (ratio >= thresholds['project_critical'] and current_cost > thresholds['min_cost_threshold']): return { 'severity': 'critical', 'current_cost': current_cost, 'baseline_cost': baseline['avg_cost'], 'ratio': ratio, 'message': (f'Critical project cost anomaly: ${current_cost:.0f} ' f'(baseline: ${baseline["avg_cost"]:.0f}, ratio: {ratio:.1f}x)') } elif (ratio >= thresholds['project_warning'] and current_cost > thresholds['min_cost_threshold']): return { 'severity': 'warning', 'current_cost': current_cost, 'baseline_cost': baseline['avg_cost'], 'ratio': ratio, 'message': (f'Project cost anomaly: ${current_cost:.0f} ' f'(baseline: ${baseline["avg_cost"]:.0f}, ratio: {ratio:.1f}x)') } return None def detect_service_anomaly(self, current_cost: float, baseline: Dict, thresholds: Dict) -> Optional[Dict]: """Detect service cost anomaly""" if baseline['avg_cost'] == 0: return None ratio = current_cost / baseline['avg_cost'] if (ratio >= thresholds['service_critical'] and current_cost > thresholds['min_cost_threshold']): return { 'severity': 'critical', 'current_cost': current_cost, 'baseline_cost': baseline['avg_cost'], 'ratio': ratio, 'message': (f'Critical service cost anomaly: ${current_cost:.0f} ' f'(baseline: ${baseline["avg_cost"]:.0f}, ratio: {ratio:.1f}x)') } elif (ratio >= thresholds['service_warning'] and current_cost > thresholds['min_cost_threshold']): return { 'severity': 'warning', 'current_cost': current_cost, 'baseline_cost': baseline['avg_cost'], 'ratio': ratio, 'message': (f'Service cost anomaly: ${current_cost:.0f} ' f'(baseline: ${baseline["avg_cost"]:.0f}, ratio: {ratio:.1f}x)') } return None class FeishuNotifier: """飞书通知器""" def __init__(self, webhook_url: str, environment_name: str = "GCP"): self.webhook_url = webhook_url self.environment_name = environment_name def send_analysis_report(self, analysis_result: Dict, dry_run: bool = False) -> bool: """发送分析报告""" if not analysis_result['success']: return self.send_error_notification(analysis_result['message'], dry_run) card = self._build_analysis_card(analysis_result) if dry_run: print("=== Feishu notification content (dry-run) ===") print(json.dumps(card, indent=2, ensure_ascii=False)) return True try: response = requests.post( self.webhook_url, json=card, headers={'Content-Type': 'application/json'}, timeout=10 ) response.raise_for_status() return True except Exception as e: print(f"Failed to send Feishu notification: {e}") return False def send_error_notification(self, error_message: str, dry_run: bool = False) -> bool: """发送错误通知""" card = { "msg_type": "interactive", "card": { "config": {"wide_screen_mode": True}, "header": { "template": "red", "title": {"tag": "plain_text", "content": f"🟢 {self.environment_name} | 🚨 Billing Analysis Failed"} }, "elements": [ { "tag": "div", "text": {"tag": "plain_text", "content": error_message} } ] } } if dry_run: print("=== Feishu error notification content (dry-run) ===") print(json.dumps(card, indent=2, ensure_ascii=False)) return True try: response = requests.post( self.webhook_url, json=card, headers={'Content-Type': 'application/json'}, timeout=10 ) response.raise_for_status() return True except Exception as e: print(f"Failed to send Feishu error notification: {e}") return False def _build_analysis_card(self, result: Dict) -> Dict: """Build analysis report card""" total_anomalies = result['total_anomalies'] # Determine card color and title env_name = result.get('environment_name', 'GCP') if total_anomalies == 0: header_color = "green" title = f"🟢 {env_name} | ✅ Billing Normal" status_text = "No anomalies detected" else: # Check if there are critical anomalies has_critical = any( anomaly.get('severity') == 'critical' for anomaly in result['project_anomalies'] + result['service_anomalies'] ) if has_critical: header_color = "red" title = f"🟢 {env_name} | 🚨 Critical Billing Anomaly" status_text = f"{total_anomalies} anomalies detected, including critical ones" else: header_color = "orange" title = f"🟢 {env_name} | ⚠️ Billing Anomaly Warning" status_text = f"{total_anomalies} anomalies detected" # Build basic information elements = [ { "tag": "div", "text": { "tag": "lark_md", "content": f"📋 **{env_name} Daily Cost Report**" } }, { "tag": "div", "fields": [ {"is_short": True, "text": {"tag": "lark_md", "content": f"**Analysis Date**\n{result['date']}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**Status**\n{status_text}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**Total Cost**\n${result['total_cost']:.0f}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**Projects**\n{result['project_count']}"}} ] } ] # Add anomaly details if total_anomalies > 0: elements.append({"tag": "hr"}) # Project anomalies if result['project_anomalies']: anomaly_text = "**Project Anomalies:**\n" for anomaly in result['project_anomalies']: if anomaly['severity'] == 'critical': icon = "🔴" elif anomaly['severity'] == 'warning': icon = "🟠" else: icon = "🔵" project_display = anomaly.get('project_name', anomaly['project_id']) anomaly_text += f"{icon} Project {project_display}: {anomaly['message']}\n" elements.append({ "tag": "div", "text": {"tag": "lark_md", "content": anomaly_text.strip()} }) # Service anomalies if result['service_anomalies']: service_text = "**Service Anomalies:**\n" for anomaly in result['service_anomalies']: if anomaly['severity'] == 'critical': icon = "🔴" elif anomaly['severity'] == 'warning': icon = "🟠" else: icon = "🔵" project_display = anomaly.get('project_name', anomaly['project_id']) service_text += (f"{icon} Project {project_display} - " f"{anomaly['service']}: {anomaly['message']}\n") elements.append({ "tag": "div", "text": {"tag": "lark_md", "content": service_text.strip()} }) # Add TOP cost information elements.append({"tag": "hr"}) # TOP 5 project costs top_projects = sorted( result['project_costs'].items(), key=lambda x: x[1], reverse=True )[:5] project_text = "**TOP 5 Project Costs:**\n" project_names = result.get('project_names', {}) for project_id, cost in top_projects: project_display = project_names.get(project_id, project_id) project_text += f"• {project_display}: ${cost:.0f}\n" # TOP 5 service costs top_services = sorted( result['service_costs'].items(), key=lambda x: x[1], reverse=True )[:5] service_text = "**TOP 5 Service Costs:**\n" for service, cost in top_services: service_text += f"• {service}: ${cost:.0f}\n" elements.append({ "tag": "div", "fields": [ {"is_short": True, "text": {"tag": "lark_md", "content": project_text.strip()}}, {"is_short": True, "text": {"tag": "lark_md", "content": service_text.strip()}} ] }) return { "msg_type": "interactive", "card": { "config": {"wide_screen_mode": True}, "header": { "template": header_color, "title": {"tag": "plain_text", "content": title} }, "elements": elements } } def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser( description='GCP Daily Billing Analysis and Alert System (main_analyze.py)', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: python -m gcp.main_analyze # Analyze day before yesterday python -m gcp.main_analyze --date 2024-01-15 # Analyze specified date python -m gcp.main_analyze --dry-run # Analyze without sending notifications python -m gcp.main_analyze --config gcp/config.json # Use configuration file python -m gcp.main_analyze --project-id qa-infra-dev # Analyze only specified project python -m gcp.main_analyze --project-id qa-infra-dev --dry-run # Test single project analysis """ ) parser.add_argument( '--date', help='Analysis date (YYYY-MM-DD), defaults to day before yesterday' ) parser.add_argument( '--data-dir', default=str(GCP_DIR / 'gcp_billing_exports'), help='Billing data directory (default: gcp/gcp_billing_exports)' ) parser.add_argument( '--webhook-url', help='Feishu webhook URL' ) parser.add_argument( '--config', help='Configuration file path (JSON format)' ) parser.add_argument( '--dry-run', action='store_true', help='Analyze without sending notifications' ) parser.add_argument( '--thresholds', help='Anomaly threshold configuration (JSON string)' ) parser.add_argument( '--project-id', help='Analyze only specified project ID (for testing single project)' ) parser.add_argument( '--env', choices=['all', 'prod', 'nonprod'], default='all', help='Select environment to analyze (all: All environments, prod: Production, nonprod: Non-production)' ) return parser.parse_args() def load_config(config_path: str) -> Dict: """加载配置文件""" try: with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"加载配置文件失败: {e}") return {} def analyze_environment(analyzer, target_date_str, environment, config): """Analyze data for specified environment and return results""" # Set environment-specific thresholds env_config = config.get('environments', {}).get(environment, {}) if env_config.get('thresholds'): analyzer.thresholds.update(env_config['thresholds']) # 分析数据 result = analyzer.analyze_date(target_date_str, environment) return result, env_config def send_environment_notification(result, env_config, dry_run, cloud_webhook=None): """Send notification for specified environment or cloud-wide channel""" webhook_url = cloud_webhook or env_config.get('webhook_url') if not webhook_url: print(f"No Feishu webhook URL configured for {result.get('environment_name', result.get('environment'))}, skipping notification") return False if webhook_url == "https://open.feishu.cn/open-apis/bot/v2/hook/your-nonprod-webhook-url-here": print(f"Please update Feishu webhook URL configuration for {result.get('environment_name', result.get('environment'))}") return False notifier = FeishuNotifier(webhook_url, result.get('environment_name', 'GCP')) success = notifier.send_analysis_report(result, dry_run) if success: print(f"Feishu notification sent successfully for {result.get('environment_name', result.get('environment'))}") else: print(f"Failed to send Feishu notification for {result.get('environment_name', result.get('environment'))}") return success def main(): """主函数""" args = parse_args() # 加载配置 config = {} if args.config: config = load_config(args.config) # 确定分析日期(默认前天) if args.date: try: target_date = datetime.strptime(args.date, '%Y-%m-%d').date() except ValueError: print("Error: Invalid date format, please use YYYY-MM-DD format") sys.exit(1) else: # 默认分析前天的数据 target_date = datetime.now().date() - timedelta(days=2) target_date_str = target_date.strftime('%Y-%m-%d') # 创建分析器 analyzer = GCPBillingDataAnalyzer(args.data_dir, args.project_id, config) cloud_webhook = config.get('cloud_webhook_url') or config.get('cloudWebhookUrl') # 更新异常阈值 if args.thresholds: try: custom_thresholds = json.loads(args.thresholds) analyzer.thresholds.update(custom_thresholds) except json.JSONDecodeError: print("Error: Invalid JSON format for threshold configuration") sys.exit(1) # Ensure environment settings are configured if not config.get('environments'): print("Error: Missing environment configuration in config file, please use the new configuration format") print("Please refer to config.json.example to update the configuration file") sys.exit(1) # 分析数据 if args.env == 'all' and config.get('environments'): # Generate independent report for each environment for env_name in ['prod', 'nonprod']: if env_name in config['environments']: print(f"\n{'='*60}") result, env_config = analyze_environment(analyzer, target_date_str, env_name, config) # 打印分析结果 if result['success']: print(f"\n=== {target_date_str} {result['environment_name']} billing analysis results ===") print(f"Total cost: ${result['total_cost']:.0f}") print(f"Projects: {result['project_count']}") print(f"Services: {result['service_count']}") print(f"Anomalies: {result['total_anomalies']}") if result['total_anomalies'] > 0: print("\nAnomaly details:") for anomaly in result['project_anomalies'] + result['service_anomalies']: print(f" - {anomaly['message']}") else: print(f"Analysis failed: {result['message']}") # 发送通知 if not args.dry_run: send_environment_notification(result, env_config, False, cloud_webhook) elif args.dry_run: send_environment_notification(result, env_config, True, cloud_webhook) else: # Single environment analysis mode environment = args.env result, env_config = analyze_environment(analyzer, target_date_str, environment, config) # 打印分析结果 if result['success']: if args.project_id: print(f"\n=== {target_date_str} Project {args.project_id} billing analysis results ===") else: env_name = result.get('environment_name', 'GCP') print(f"\n=== {target_date_str} {env_name} billing analysis results ===") print(f"Total cost: ${result['total_cost']:.0f}") print(f"Projects: {result['project_count']}") print(f"Services: {result['service_count']}") print(f"Anomalies: {result['total_anomalies']}") if result['total_anomalies'] > 0: print("\nAnomaly details:") for anomaly in result['project_anomalies'] + result['service_anomalies']: print(f" - {anomaly['message']}") else: print(f"Analysis failed: {result['message']}") # 发送飞书通知 if not args.dry_run: send_environment_notification(result, env_config, False, cloud_webhook) elif args.dry_run: send_environment_notification(result, env_config, True, cloud_webhook) if __name__ == "__main__": main()