1# Copyright 2024, The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15 16import argparse 17import datetime 18import getpass 19import logging 20import os 21import platform 22import sys 23import tempfile 24import uuid 25 26from atest.metrics import clearcut_client 27from atest.proto import clientanalytics_pb2 28from proto import tool_event_pb2 29 30LOG_SOURCE = 2395 31 32 33class ToolEventLogger: 34 """Logs tool events to Sawmill through Clearcut.""" 35 36 def __init__( 37 self, 38 tool_tag: str, 39 invocation_id: str, 40 user_name: str, 41 source_root: str, 42 platform_version: str, 43 python_version: str, 44 client: clearcut_client.Clearcut, 45 ): 46 self.tool_tag = tool_tag 47 self.invocation_id = invocation_id 48 self.user_name = user_name 49 self.source_root = source_root 50 self.platform_version = platform_version 51 self.python_version = python_version 52 self._clearcut_client = client 53 54 @classmethod 55 def create(cls, tool_tag: str): 56 return ToolEventLogger( 57 tool_tag=tool_tag, 58 invocation_id=str(uuid.uuid4()), 59 user_name=getpass.getuser(), 60 source_root=os.environ.get('ANDROID_BUILD_TOP', ''), 61 platform_version=platform.platform(), 62 python_version=platform.python_version(), 63 client=clearcut_client.Clearcut(LOG_SOURCE), 64 ) 65 66 def __enter__(self): 67 return self 68 69 def __exit__(self, exc_type, exc_val, exc_tb): 70 self.flush() 71 72 def log_invocation_started(self, event_time: datetime, command_args: str): 73 """Creates an event log with invocation started info.""" 74 event = self._create_tool_event() 75 event.invocation_started.CopyFrom( 76 tool_event_pb2.ToolEvent.InvocationStarted( 77 command_args=command_args, 78 os=f'{self.platform_version}:{self.python_version}', 79 ) 80 ) 81 82 logging.debug('Log invocation_started: %s', event) 83 self._log_clearcut_event(event, event_time) 84 85 def log_invocation_stopped( 86 self, 87 event_time: datetime, 88 exit_code: int, 89 exit_log: str, 90 ): 91 """Creates an event log with invocation stopped info.""" 92 event = self._create_tool_event() 93 event.invocation_stopped.CopyFrom( 94 tool_event_pb2.ToolEvent.InvocationStopped( 95 exit_code=exit_code, 96 exit_log=exit_log, 97 ) 98 ) 99 100 logging.debug('Log invocation_stopped: %s', event) 101 self._log_clearcut_event(event, event_time) 102 103 def flush(self): 104 """Sends all batched events to Clearcut.""" 105 logging.debug('Sending events to Clearcut.') 106 self._clearcut_client.flush_events() 107 108 def _create_tool_event(self): 109 return tool_event_pb2.ToolEvent( 110 tool_tag=self.tool_tag, 111 invocation_id=self.invocation_id, 112 user_name=self.user_name, 113 source_root=self.source_root, 114 ) 115 116 def _log_clearcut_event( 117 self, tool_event: tool_event_pb2.ToolEvent, event_time: datetime 118 ): 119 log_event = clientanalytics_pb2.LogEvent( 120 event_time_ms=int(event_time.timestamp() * 1000), 121 source_extension=tool_event.SerializeToString(), 122 ) 123 self._clearcut_client.log(log_event) 124 125 126class ArgumentParserWithLogging(argparse.ArgumentParser): 127 128 def error(self, message): 129 logging.error('Failed to parse args with error: %s', message) 130 super().error(message) 131 132 133def create_arg_parser(): 134 """Creates an instance of the default ToolEventLogger arg parser.""" 135 136 parser = ArgumentParserWithLogging( 137 description='Build and upload logs for Android dev tools', 138 add_help=True, 139 formatter_class=argparse.RawDescriptionHelpFormatter, 140 ) 141 142 parser.add_argument( 143 '--tool_tag', 144 type=str, 145 required=True, 146 help='Name of the tool.', 147 ) 148 149 parser.add_argument( 150 '--start_timestamp', 151 type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), 152 required=True, 153 help=( 154 'Timestamp when the tool starts. The timestamp should have the format' 155 '%s.%N which represents the seconds elapses since epoch.' 156 ), 157 ) 158 159 parser.add_argument( 160 '--end_timestamp', 161 type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), 162 required=True, 163 help=( 164 'Timestamp when the tool exits. The timestamp should have the format' 165 '%s.%N which represents the seconds elapses since epoch.' 166 ), 167 ) 168 169 parser.add_argument( 170 '--tool_args', 171 type=str, 172 help='Parameters that are passed to the tool.', 173 ) 174 175 parser.add_argument( 176 '--exit_code', 177 type=int, 178 required=True, 179 help='Tool exit code.', 180 ) 181 182 parser.add_argument( 183 '--exit_log', 184 type=str, 185 help='Logs when tool exits.', 186 ) 187 188 parser.add_argument( 189 '--dry_run', 190 action='store_true', 191 help='Dry run the tool event logger if set.', 192 ) 193 194 return parser 195 196 197def configure_logging(): 198 root_logging_dir = tempfile.mkdtemp(prefix='tool_event_logger_') 199 200 log_fmt = '%(asctime)s %(filename)s:%(lineno)s:%(levelname)s: %(message)s' 201 date_fmt = '%Y-%m-%d %H:%M:%S' 202 _, log_path = tempfile.mkstemp(dir=root_logging_dir, suffix='.log') 203 204 logging.basicConfig( 205 filename=log_path, level=logging.DEBUG, format=log_fmt, datefmt=date_fmt 206 ) 207 208 209def main(argv: list[str]): 210 args = create_arg_parser().parse_args(argv[1:]) 211 212 if args.dry_run: 213 logging.debug('This is a dry run.') 214 return 215 216 try: 217 with ToolEventLogger.create(args.tool_tag) as logger: 218 logger.log_invocation_started(args.start_timestamp, args.tool_args) 219 logger.log_invocation_stopped( 220 args.end_timestamp, args.exit_code, args.exit_log 221 ) 222 except Exception as e: 223 logging.error('Log failed with unexpected error: %s', e) 224 raise 225 226 227if __name__ == '__main__': 228 configure_logging() 229 main(sys.argv) 230