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 host_name: str, 42 source_root: str, 43 platform_version: str, 44 python_version: str, 45 client: clearcut_client.Clearcut, 46 ): 47 self.tool_tag = tool_tag 48 self.invocation_id = invocation_id 49 self.user_name = user_name 50 self.host_name = host_name 51 self.source_root = source_root 52 self.platform_version = platform_version 53 self.python_version = python_version 54 self._clearcut_client = client 55 56 @classmethod 57 def create(cls, tool_tag: str): 58 return ToolEventLogger( 59 tool_tag=tool_tag, 60 invocation_id=str(uuid.uuid4()), 61 user_name=getpass.getuser(), 62 host_name=platform.node(), 63 source_root=os.environ.get('ANDROID_BUILD_TOP', ''), 64 platform_version=platform.platform(), 65 python_version=platform.python_version(), 66 client=clearcut_client.Clearcut(LOG_SOURCE), 67 ) 68 69 def __enter__(self): 70 return self 71 72 def __exit__(self, exc_type, exc_val, exc_tb): 73 self.flush() 74 75 def log_invocation_started(self, event_time: datetime, command_args: str): 76 """Creates an event log with invocation started info.""" 77 event = self._create_tool_event() 78 event.invocation_started.CopyFrom( 79 tool_event_pb2.ToolEvent.InvocationStarted( 80 command_args=command_args, 81 os=f'{self.platform_version}:{self.python_version}', 82 ) 83 ) 84 85 logging.debug('Log invocation_started: %s', event) 86 self._log_clearcut_event(event, event_time) 87 88 def log_invocation_stopped( 89 self, 90 event_time: datetime, 91 exit_code: int, 92 exit_log: str, 93 ): 94 """Creates an event log with invocation stopped info.""" 95 event = self._create_tool_event() 96 event.invocation_stopped.CopyFrom( 97 tool_event_pb2.ToolEvent.InvocationStopped( 98 exit_code=exit_code, 99 exit_log=exit_log, 100 ) 101 ) 102 103 logging.debug('Log invocation_stopped: %s', event) 104 self._log_clearcut_event(event, event_time) 105 106 def flush(self): 107 """Sends all batched events to Clearcut.""" 108 logging.debug('Sending events to Clearcut.') 109 self._clearcut_client.flush_events() 110 111 def _create_tool_event(self): 112 return tool_event_pb2.ToolEvent( 113 tool_tag=self.tool_tag, 114 invocation_id=self.invocation_id, 115 user_name=self.user_name, 116 host_name=self.host_name, 117 source_root=self.source_root, 118 ) 119 120 def _log_clearcut_event( 121 self, tool_event: tool_event_pb2.ToolEvent, event_time: datetime 122 ): 123 log_event = clientanalytics_pb2.LogEvent( 124 event_time_ms=int(event_time.timestamp() * 1000), 125 source_extension=tool_event.SerializeToString(), 126 ) 127 self._clearcut_client.log(log_event) 128 129 130class ArgumentParserWithLogging(argparse.ArgumentParser): 131 132 def error(self, message): 133 logging.error('Failed to parse args with error: %s', message) 134 super().error(message) 135 136 137def create_arg_parser(): 138 """Creates an instance of the default ToolEventLogger arg parser.""" 139 140 parser = ArgumentParserWithLogging( 141 description='Build and upload logs for Android dev tools', 142 add_help=True, 143 formatter_class=argparse.RawDescriptionHelpFormatter, 144 ) 145 146 parser.add_argument( 147 '--tool_tag', 148 type=str, 149 required=True, 150 help='Name of the tool.', 151 ) 152 153 parser.add_argument( 154 '--start_timestamp', 155 type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), 156 required=True, 157 help=( 158 'Timestamp when the tool starts. The timestamp should have the format' 159 '%s.%N which represents the seconds elapses since epoch.' 160 ), 161 ) 162 163 parser.add_argument( 164 '--end_timestamp', 165 type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), 166 required=True, 167 help=( 168 'Timestamp when the tool exits. The timestamp should have the format' 169 '%s.%N which represents the seconds elapses since epoch.' 170 ), 171 ) 172 173 parser.add_argument( 174 '--tool_args', 175 type=str, 176 help='Parameters that are passed to the tool.', 177 ) 178 179 parser.add_argument( 180 '--exit_code', 181 type=int, 182 required=True, 183 help='Tool exit code.', 184 ) 185 186 parser.add_argument( 187 '--exit_log', 188 type=str, 189 help='Logs when tool exits.', 190 ) 191 192 parser.add_argument( 193 '--dry_run', 194 action='store_true', 195 help='Dry run the tool event logger if set.', 196 ) 197 198 return parser 199 200 201def configure_logging(): 202 root_logging_dir = tempfile.mkdtemp(prefix='tool_event_logger_') 203 204 log_fmt = '%(asctime)s %(filename)s:%(lineno)s:%(levelname)s: %(message)s' 205 date_fmt = '%Y-%m-%d %H:%M:%S' 206 _, log_path = tempfile.mkstemp(dir=root_logging_dir, suffix='.log') 207 208 logging.basicConfig( 209 filename=log_path, level=logging.DEBUG, format=log_fmt, datefmt=date_fmt 210 ) 211 212 213def main(argv: list[str]): 214 args = create_arg_parser().parse_args(argv[1:]) 215 216 if args.dry_run: 217 logging.debug('This is a dry run.') 218 return 219 220 try: 221 with ToolEventLogger.create(args.tool_tag) as logger: 222 logger.log_invocation_started(args.start_timestamp, args.tool_args) 223 logger.log_invocation_stopped( 224 args.end_timestamp, args.exit_code, args.exit_log 225 ) 226 except Exception as e: 227 logging.error('Log failed with unexpected error: %s', e) 228 raise 229 230 231if __name__ == '__main__': 232 configure_logging() 233 main(sys.argv) 234