import sys import os import argparse from datetime import datetime, timedelta, timezone from pathlib import Path from objstore.objstore import pick_object_store, ObjectStore from typing import List import requests # Import logger_utils first as it doesn't have any dependencies from app.logcli.utils.logger_utils import log_info, log_error # Lazy import for modules that might cause circular imports def import_slowlog_detector(): from slowlogs.detector import slowlog_detector return slowlog_detector # Add the project root to the Python path project_root = str(Path(__file__).parent.parent) if project_root not in sys.path: sys.path.append(project_root) # Import custom modules # Define valid choices for log levels VALID_LOG_LEVELS = ['ERROR', 'WARN', 'INFO', 'DEBUG'] DEFAULT_COMPONENTS = [ "pd", "tikv", "tidb", "tiflash", ] def get_resource_path(relative_path: str) -> str: """ Determines the correct path for resources, handling PyInstaller bundling. """ if hasattr(sys, '_MEIPASS'): # If the program is bundled by PyInstaller, _MEIPASS is the path to the temporary directory. return os.path.join(sys._MEIPASS, relative_path) # Otherwise, use the current working directory. return os.path.join(os.path.abspath("."), relative_path) # Time format for logs-related operations (still uses YYYYMMDDHH string) TIME_FORMAT = "%Y%m%d%H" # Supported datetime formats for human-readable input DATETIME_FORMATS = [ # With timezone offset (with colon) "%Y-%m-%d %H:%M:%S%z", # 2025-03-27 11:06:00+08:00 "%Y-%m-%dT%H:%M:%S%z", # 2025-03-27T11:06:00+08:00 "%Y-%m-%d %H:%M:%S.%f%z", # 2025-03-27 11:06:00.285005+08:00 "%Y-%m-%dT%H:%M:%S.%f%z", # 2025-03-27T11:06:00.285005+08:00 # With timezone offset (without colon) "%Y-%m-%d %H:%M:%S%z", # 2025-03-27 11:06:00+0800 "%Y-%m-%dT%H:%M:%S%z", # 2025-03-27T11:06:00+0800 "%Y-%m-%d %H:%M:%S.%f%z", # 2025-03-27 11:06:00.285005+0800 "%Y-%m-%dT%H:%M:%S.%f%z", # 2025-03-27T11:06:00.285005+0800 # Without timezone (assume UTC) "%Y-%m-%d %H:%M:%S", # 2025-03-27 11:06:00 "%Y-%m-%d %H:%M", # 2025-03-27 11:06 "%Y-%m-%d %H:%M:%S.%f" # 2025-03-27 11:06:00.285005 ] def find_common_ts_prefixes_limit_1h(begin: int, end: int) -> List[str]: """ Generate minimal S3 prefixes to cover the time range. For timestamps, we use the first few digits as prefixes to minimize the number of S3 list operations. For example, for timestamps 1754791200 to 1754877600 (Aug 10-11, 2025): - Use '17547' and '17548' as prefixes Args: begin: Start timestamp in seconds end: End timestamp in seconds Returns: List of string prefixes for S3 listing """ # Convert timestamps to strings for prefix comparison begin_str = str(begin) end_str = str(end) # Find the first position where timestamps differ i = 0 while i < min(len(begin_str), len(end_str)) and begin_str[i] == end_str[i]: i += 1 # If timestamps are identical, return the full timestamp as prefix if i == len(begin_str) == len(end_str): return [begin_str] # Get the common prefix common_prefix = begin_str[:i] # If no common prefix (shouldn't happen with timestamps), return full range if not common_prefix: return [str(ts) for ts in range(begin, end + 1)] # Generate prefixes by incrementing the first differing digit start_digit = int(begin_str[i]) if i < len(begin_str) else 0 end_digit = int(end_str[i]) if i < len(end_str) else 9 prefixes = [] for d in range(start_digit, end_digit + 1): prefix = common_prefix + str(d) # Add the prefix if it's within our time range if int(prefix + '0' * max(0, 10 - len(prefix))) <= end and \ int(prefix + '9' * max(0, 10 - len(prefix))) >= begin: prefixes.append(prefix) return prefixes def find_matched_keys(store: ObjectStore, bucket: str, path: str, begin: int, end: int) -> List[str]: prefixes = [f"{path}/{p}" for p in find_common_ts_prefixes_limit_1h(begin - 1800, end + 300)] # Assume 5 minutes max upload latency log_info(f"Finding matched keys for prefixes: {prefixes}") keys = store.lists(bucket, prefixes) matched_keys = [] for k in keys: filename = os.path.basename(k) upload_ts = int(filename[:10]) if upload_ts < begin - 1800 or upload_ts > end + 300: # Assume 5 minutes max upload latency continue matched_keys.append(k) return matched_keys def parse_datetime(datetime_str: str) -> int: """ Parse a datetime string into a Unix timestamp (seconds since epoch). Supports multiple datetime formats with and without timezone information. Handles timezone offsets with or without colons, and escaped spaces. Also handles YYYYMMDD format specifically. """ if not datetime_str: raise ValueError("Empty datetime string") # Check for YYYYMMDD format first if len(datetime_str) == 10 and datetime_str.isdigit(): try: dt = datetime.strptime(datetime_str, "%Y%m%d%H") dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp()) except ValueError: pass # Fall through to other formats # If it's all digits and not YYYYMMDD format, assume it's a timestamp if datetime_str.isdigit(): return int(datetime_str) # Handle escaped spaces (from command line) and normalize normalized_str = datetime_str.replace('\\ ', ' ').strip() # Try parsing with dateutil.parser first as it's more lenient with formats try: from dateutil import parser dt = parser.parse(normalized_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp()) except (ImportError, ValueError): # Fall back to strptime if dateutil is not available or parsing fails pass # Try different timezone offset formats timezone_formats = [ (r'(\+\d{2}):(\d{2})$', '\1\2'), # +08:00 -> +0800 (r'(\+\d{2})(\d{2})$', '\1:\2'), # +0800 -> +08:00 (r'([+-]\d{2})$', '\100') # +08 -> +0800 ] for pattern, replacement in timezone_formats: import re if re.search(pattern[:-1], normalized_str): normalized_str = re.sub(pattern, replacement, normalized_str) break # Try parsing with different formats for fmt in DATETIME_FORMATS: try: dt = datetime.strptime(normalized_str, fmt) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp()) except ValueError: continue # If all else fails, try parsing without timezone and assume UTC try: dt = datetime.strptime(normalized_str.split('+')[0].split('-')[0].strip(), '%Y-%m-%d %H:%M:%S') dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp()) except ValueError: pass raise ValueError(f"Could not parse datetime string: {datetime_str}") def validate_datetime_format(date_string: str) -> str: """ Validates if a given string matches the specified datetime format. Raises argparse.ArgumentTypeError if the format is invalid. This is kept for logs subcommand which uses string format. """ try: datetime.strptime(date_string, TIME_FORMAT) return date_string except ValueError: raise argparse.ArgumentTypeError( f"Invalid date format: {date_string}. Expected YYYYMMDDHH (e.g., 2025052206)." ) def main(): """ Main entry point of the program, responsible for parsing command-line arguments and executing the corresponding operations. """ parser = argparse.ArgumentParser( description='A command-line tool for observability data (e.g., logs, metrics).' ) # Create subparsers to distinguish between different data types (currently only 'logs') subparsers = parser.add_subparsers( dest='data_type', required=True, help='Type of data to process (e.g., logs, slowlog)' ) # --- Logs Data Subparser --- logs_parser = subparsers.add_parser( 'logs', help='Process logs data.' ) # Create further subparsers for logs to distinguish between specific actions (download, stats, compare) logs_subparsers = logs_parser.add_subparsers( dest='action', required=True, help='Action to perform on logs data (download, stats, or compare)' ) # --- Define Common Arguments for Logs --- common_logs_args_parser = argparse.ArgumentParser(add_help=False) common_logs_args_parser.add_argument( '--cluster', type=str, required=True, help='The TiDB Cluster ID.' ) common_logs_args_parser.add_argument( '--vendor', type=str, required=True, help='The vendor of the cluster (e.g., aws).' ) common_logs_args_parser.add_argument( '--region', type=str, required=True, help='The region of the cluster (e.g., us-west-2).' ) common_logs_args_parser.add_argument( '--components', nargs='*', # Allows zero or more values default=DEFAULT_COMPONENTS, help='Filter data by components (e.g., tidb tikv pd).' ) common_logs_args_parser.add_argument( '--dest', type=str, required=False, default='./downloads', help='The destination path to save downloaded logs.' ) common_logs_args_parser.add_argument( '--start', type=validate_datetime_format, # Use the validator for string format required=False, help='Start time in YYYYMMDDHH format (e.g., 2025052205).' ) common_logs_args_parser.add_argument( '--end', type=validate_datetime_format, # Use the validator for string format required=False, help='End time in YYYYMMDDHH format (e.g., 2025052206).' ) # --- Logs Download Subcommand --- logs_subparsers.add_parser( 'download', parents=[common_logs_args_parser], # Inherits common arguments help='Download logs data.' ) # --- Logs Statistics Subcommand --- logs_stats_parser = logs_subparsers.add_parser( 'stats', parents=[common_logs_args_parser], # Inherits common arguments help='Get statistics for logs.' ) logs_stats_parser.add_argument( '--levels', nargs='*', choices=VALID_LOG_LEVELS, # Restrict to valid choices default=VALID_LOG_LEVELS, help=f'Filter logs by levels (valid choices: {", ".join(VALID_LOG_LEVELS)}).' ) # --- Logs Compare Subcommand --- logs_compare_parser = logs_subparsers.add_parser( 'compare', parents=[common_logs_args_parser], # Inherits common arguments help='Compare log statistics between two time periods.' ) logs_compare_parser.add_argument( '--comparison-start', type=validate_datetime_format, required=True, help='Start time for the comparison period in YYYYMMDDHH format (e.g., 2025052305).' ) logs_compare_parser.add_argument( '--comparison-end', type=validate_datetime_format, required=True, help='End time for the comparison period in YYYYMMDDHH format (e.g., 2025052306).' ) logs_compare_parser.add_argument( '--levels', nargs='*', choices=VALID_LOG_LEVELS, default=VALID_LOG_LEVELS, help=f'Filter logs by levels (valid choices: {", ".join(VALID_LOG_LEVELS)}).' ) # --- Doctor Diagnostics Subparser --- doctor_parser = subparsers.add_parser( 'doctor', help='Run diagnostic rules located in the diagnostics directory.' ) doctor_parser.add_argument( '--report', action='store_true', help='Show detailed report after running diagnostics' ) statement_parser = subparsers.add_parser( 'statement', help='Process statement data.' ) statement_subparsers = statement_parser.add_subparsers( dest='action', required=True, help='Action to perform on statement data (download, stats, or compare)' ) common_statement_args_parser = argparse.ArgumentParser(add_help=False) common_statement_args_parser.add_argument( '--cluster', type=str, required=True, help='The TiDB Cluster ID.' ) common_statement_args_parser.add_argument( '--tenant', type=str, required=True, help='The Tenant ID.' ) common_statement_args_parser.add_argument( '--vendor', type=str, required=True, help='The vendor of the cluster (e.g., aws).' ) common_statement_args_parser.add_argument( '--region', type=str, required=True, help='The region of the cluster (e.g., us-west-2).' ) common_statement_args_parser.add_argument( '--dest', type=str, required=False, default='./downloads', help='The destination path to save downloaded logs.' ) common_statement_args_parser.add_argument( '--start', type=validate_datetime_format, # Use the validator for string format required=False, help='Start time in YYYYMMDDHH format (e.g., 2025052205).' ) common_statement_args_parser.add_argument( '--end', type=validate_datetime_format, # Use the validator for string format required=False, help='End time in YYYYMMDDHH format (e.g., 2025052206).' ) statement_subparsers.add_parser( 'download', parents=[common_statement_args_parser], # Inherits common arguments help='Download statement data.' ) # --- Common Slowlog Arguments --- common_slowlog_args = argparse.ArgumentParser(add_help=False) common_slowlog_args.add_argument( '--cluster', type=str, required=False, help='The TiDB Cluster ID.' ) common_slowlog_args.add_argument( '--tenant', type=str, required=False, help='The Tenant ID.' ) common_slowlog_args.add_argument( '--vendor', type=str, required=True, choices=['aws', 'gcp', 'azure', 'local'], help='The vendor of the cluster (e.g., aws).' ) common_slowlog_args.add_argument( '--region', type=str, required=True, help='The region of the cluster (e.g., us-west-2). when vendor is local, this is the path to the delta lake.' ) # --- Slowlog Data Subparser --- slowlog_parser = subparsers.add_parser( 'slowlog', help='Process slowlog data.' ) slowlog_subparsers = slowlog_parser.add_subparsers( dest='action', required=True, help='Action to perform on slowlog data (detect, sql)' ) # --- Slowlog SQL Parser --- slowlog_sql_parser = slowlog_subparsers.add_parser( 'sql', help='Interactive SQL shell for querying slowlog data', parents=[common_slowlog_args] ) slowlog_sql_parser.add_argument( '--query', type=str, required=False, help='SQL query to execute (if not provided, will enter interactive mode)' ) # --- Slowlog Detect Parser --- slowlog_detect_parser = slowlog_subparsers.add_parser( 'detect', help='Detect anomaly slowlog data.' ) # Add detect subparsers detect_subparsers = slowlog_detect_parser.add_subparsers( dest='detect_action', required=False, help='Detect action to perform (run, list)' ) # Add list subcommand under detect (without requiring cluster/vendor/region) detect_list_parser = detect_subparsers.add_parser( 'list', help='List all available slowlog detection rules', add_help=False # We'll add help manually to avoid parent's required args ) # Add help manually detect_list_parser.add_argument( '-h', '--help', action='help', help='Show this help message and exit.' ) # Add rules directory parameter to list command detect_list_parser.add_argument( '--rules-dir', type=str, required=False, default=None, help='Directory containing custom rule modules to include in the list.' ) # Add run subcommand under detect (with common slowlog args) detect_run_parser = detect_subparsers.add_parser( 'run', help='Run slowlog detection with specified rules.', parents=[common_slowlog_args] ) # Add rules directory parameter detect_run_parser.add_argument( '--rules-dir', type=str, required=False, default=None, help='Directory containing custom rule modules to load.' ) # Add rules filter parameter detect_run_parser.add_argument( '--rules', type=str, required=False, default=None, help='Comma-separated list of rule names to run. If not specified, all rules will run.' ) # Time arguments group time_group = detect_run_parser.add_argument_group('time arguments') # Timestamp arguments (kept for backward compatibility) time_group.add_argument( '--start-ts', type=int, required=False, default=None, help='Start time in Unix timestamp (seconds since epoch). Use --start-time for human-readable format.' ) time_group.add_argument( '--end-ts', type=int, required=False, default=None, help='End time in Unix timestamp (seconds since epoch). Use --end-time for human-readable format.' ) time_group.add_argument( '--comparison-start-ts', type=int, required=False, default=None, help='Start time for comparison window in Unix timestamp. Use --comparison-start-time for human-readable format.' ) time_group.add_argument( '--comparison-end-ts', type=int, required=False, default=None, help='End time for comparison window in Unix timestamp. Use --comparison-end-time for human-readable format.' ) # Human-readable datetime arguments time_group.add_argument( '--start-time', type=str, required=False, default=None, help='Start time in human-readable format (e.g., 2025-03-27 11:06:00 or 2025-03-27T11:06:00+08:00)' ) time_group.add_argument( '--end-time', type=str, required=False, default=None, help='End time in human-readable format (e.g., 2025-03-27 12:06:00 or 2025-03-27T12:06:00+08:00)' ) time_group.add_argument( '--comparison-start-time', type=str, required=False, default=None, help='Start time for comparison window in human-readable format' ) time_group.add_argument( '--comparison-end-time', type=str, required=False, default=None, help='End time for comparison window in human-readable format' ) detect_run_parser.add_argument( '--dest', type=str, required=False, default='local', help='Destination for output files. Defaults to "local".' ) detect_run_parser.add_argument( '--feishu-webhook', type=str, required=False, default=None, help='Feishu webhook URL for sending notifications.' ) detect_run_parser.add_argument( '--issue-history-days', type=int, required=False, default=30, help='Number of days to consider for issue history. Defaults to 30.' ) # These arguments are now defined under detect_run_parser # Parse command-line arguments args = parser.parse_args() # Get current UTC time for default values if not provided now_utc = datetime.now(timezone.utc) # Execute the corresponding action based on parsed arguments if args.data_type == 'logs': if args.action == 'download': # Handle default start/end times for 'download' action start_time = args.start if start_time is None: # Default to 2 hours ago log_info("No start time provided, defaulting to 2 hours ago.") start_dt = now_utc - timedelta(hours=2) start_time = start_dt.strftime(TIME_FORMAT) end_time = args.end if end_time is None: end_time = now_utc.strftime(TIME_FORMAT) # Lazy import log utilities from logs.download import download_merged_logs log_info("Starting log download cluster: {} vendor: {} region: {} components: {} start_time: {} end_time: {} destination_path: {}".format(args.cluster, args.vendor, args.region, args.components, start_time, end_time, args.dest)) download_merged_logs( cluster_id=args.cluster, vendor=args.vendor, region=args.region, components=args.components, start_time=start_time, end_time=end_time, destination_path=args.dest ) log_info("Log download completed.") elif args.action == 'stats': # Lazy import log utilities from logs.stats import download_stats_and_merge # Handle default start/end times for 'stats' action start_time = args.start if start_time is None: # Default to 2 hours ago start_dt = now_utc - timedelta(hours=2) start_time = start_dt.strftime(TIME_FORMAT) end_time = args.end if end_time is None: end_time = now_utc.strftime(TIME_FORMAT) print("Starting log statistics generation...") download_stats_and_merge( cluster_id=args.cluster, vendor=args.vendor, region=args.region, components=args.components, levels=args.levels, start_time=start_time, end_time=end_time, destination_path=args.dest ) log_info("Log statistics generation completed.") elif args.action == 'compare': # Lazy import log utilities from logs.compare import download_stats_and_compare log_info("Starting log statistics comparison...") # Note: For 'compare', start/end are for the second period, comparison-start/end for the first download_stats_and_compare( cluster_id=args.cluster, vendor=args.vendor, region=args.region, components=args.components, levels=args.levels, start_time_1=args.comparison_start, end_time_1=args.comparison_end, start_time_2=args.start, # This is the main period for logs compare end_time_2=args.end, # This is the main period for logs compare destination_path=args.dest ) log_info("Log statistics comparison completed.") elif args.data_type == 'statement': if args.action == 'download': # Handle default start/end times for 'download' action start_time = args.start if start_time is None: # Default to 2 hours ago log_info("No start time provided, defaulting to 2 hours ago.") start_dt = now_utc - timedelta(hours=2) start_time = start_dt.strftime(TIME_FORMAT) end_time = args.end if end_time is None: end_time = now_utc.strftime(TIME_FORMAT) from auth.client_auth import get_credentials creds = get_credentials() auth0_token = creds.get('auth0_token') url = "http://www.gs.us-west-2.aws.observability.tidbcloud.com/api/v1/clusters/" url += f"?bizType=tidbcloud&vendor={args.vendor}®ion={args.region}&tenantID={args.tenant}&applicationID={args.cluster}" headers = {'authorization': f'Bearer {auth0_token}'} log_info(f"Querying cluster info from: {url}") resp = requests.get(url, headers=headers) resp.raise_for_status() clusters = resp.json()["data"]["clusters"] if not clusters: raise ValueError( f"No cluster found with ID {args.cluster} in tenant {args.tenant}") # Filter out system clusters using the same logic as detector exclude_application_ids = ['k8s-infra', 'base', 'seed'] exclude_project_ids = ['seed', "base"] filtered_clusters = [ c for c in clusters if not (c.get('applicationID') in exclude_application_ids or c.get('projectID') in exclude_project_ids) ] if not filtered_clusters: raise ValueError( f"No valid clusters found after filtering for ID {args.cluster}") cluster_info = filtered_clusters[0] store = pick_object_store(args.vendor) bucket = f"o11y-prod-shared-{args.region}" matched_keys = find_matched_keys(store, bucket, f"0/{cluster_info.get('tenantID')}/{cluster_info.get('projectID')}/{cluster_info.get('applicationID')}/logs/statementlog", parse_datetime(start_time), parse_datetime(end_time)) log_info(f"Found {len(matched_keys)} matched keys for {start_time} to {end_time}") local_base_dest = f"{args.dest}/statementlog/{cluster_info.get('applicationID')}/" merged_file = os.path.join(local_base_dest, f"statementlog_{start_time}_{end_time}.log") for matched_key in matched_keys: log_info(f"Downloading {matched_key} from S3 to {local_base_dest}") # Ensure the local directory exists before attempting to download. os.makedirs(os.path.dirname(local_base_dest), exist_ok=True) # Check if the file already exists locally to avoid re-downloading. local_file_path = os.path.join(local_base_dest, os.path.basename(matched_key)) if local_file_path.endswith(".gz"): local_file_path = local_file_path[:-3] if os.path.exists(local_file_path): log_info(f"File already exists locally: {local_file_path}. Skipping download.") else: # Attempt to download the file. if store.download_file(bucket, matched_key, local_file_path): with open(merged_file, 'a') as merged_file_writer: with open(local_file_path, 'r') as f: merged_file_writer.write(f.read()) os.remove(local_file_path) else: log_info(f"Failed to download {matched_key}") continue log_info("statement download completed.") elif args.data_type == 'slowlog': if args.action == 'detect': if args.detect_action == 'list': from slowlogs.detector import list_slowlog_rules # Pass the rules_dir parameter if provided rules_dir = getattr(args, 'rules_dir', None) list_slowlog_rules(custom_rules_dir=rules_dir) return # Default to run if no subcommand is provided if args.detect_action is None or args.detect_action == 'run': # Get rules directory from arguments rules_dir = getattr(args, 'rules_dir', None) # Parse time arguments, using human-readable format if provided, falling back to timestamps try: # Parse end time (defaults to now) if args.end_time: detect_end_ts = parse_datetime(args.end_time) else: detect_end_ts = args.end_ts if args.end_ts is not None else int( now_utc.timestamp()) # Parse start time (defaults to 1 hour before end time) if args.start_time: detect_start_ts = parse_datetime(args.start_time) else: detect_start_ts = args.start_ts if args.start_ts is not None else int( (now_utc - timedelta(hours=1)).timestamp()) # Parse comparison end time (defaults to start time) if args.comparison_end_time: comparison_end_ts = parse_datetime( args.comparison_end_time) else: comparison_end_ts = args.comparison_end_ts if args.comparison_end_ts is not None else detect_start_ts # Parse comparison start time (defaults to 24 hours before comparison end time) if args.comparison_start_time: comparison_start_ts = parse_datetime( args.comparison_start_time) else: comparison_start_ts_dt_base = datetime.fromtimestamp( comparison_end_ts, tz=timezone.utc) comparison_start_ts = args.comparison_start_ts if args.comparison_start_ts is not None \ else int((comparison_start_ts_dt_base - timedelta(days=1)).timestamp()) # Validate time ranges if detect_start_ts >= detect_end_ts: raise ValueError("Start time must be before end time") if comparison_start_ts >= comparison_end_ts: raise ValueError( "Comparison start time must be before comparison end time") except ValueError as e: log_error(f"Error parsing time arguments: {e}") return log_info("Starting slowlog detecting...") if rules_dir: log_info(f"Loading custom rules from: {rules_dir}") # Lazy import slowlog_detector detector = import_slowlog_detector() detector( vendor=args.vendor, region=args.region, tenant_id=args.tenant, cluster_id=args.cluster, start_ts=detect_start_ts, end_ts=detect_end_ts, comparison_start_ts=comparison_start_ts, comparison_end_ts=comparison_end_ts, destination_path=args.dest, feishu_webhook=args.feishu_webhook, custom_rules_dir=rules_dir, rules_filter=args.rules, issue_history_days=args.issue_history_days ) log_info("Slowlog detect command dispatched.") elif args.action == 'sql': from slowlogs.sql_client import SlowlogSQLClient client = SlowlogSQLClient( vendor=args.vendor, region=args.region, tenant_id=args.tenant, cluster_id=args.cluster ) if args.query: # Execute single query client.execute_query(args.query) else: # Start interactive mode client.interactive() elif args.data_type == 'doctor': from app.logcli.diagnostic_runner import run_all_rules import asyncio import json # Run diagnostics results, report_file = asyncio.run(run_all_rules()) # If report file exists and --report flag is provided, show its content if report_file and args.report: try: with open(report_file, 'r', encoding='utf-8') as f: report = json.load(f) print("\nDetailed Report:") print(json.dumps(report, indent=2, ensure_ascii=False)) except Exception as e: print(f"\nError loading report: {e}") return else: log_error(f"Unsupported data type: {args.data_type}") if __name__ == "__main__": main()