# Copyright 2024, The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import datetime import getpass import logging import os import platform import sys import tempfile import uuid from atest.metrics import clearcut_client from atest.proto import clientanalytics_pb2 from proto import tool_event_pb2 LOG_SOURCE = 2395 class ToolEventLogger: """Logs tool events to Sawmill through Clearcut.""" def __init__( self, tool_tag: str, invocation_id: str, user_name: str, host_name: str, source_root: str, platform_version: str, python_version: str, client: clearcut_client.Clearcut, ): self.tool_tag = tool_tag self.invocation_id = invocation_id self.user_name = user_name self.host_name = host_name self.source_root = source_root self.platform_version = platform_version self.python_version = python_version self._clearcut_client = client @classmethod def create(cls, tool_tag: str): return ToolEventLogger( tool_tag=tool_tag, invocation_id=str(uuid.uuid4()), user_name=getpass.getuser(), host_name=platform.node(), source_root=os.environ.get('ANDROID_BUILD_TOP', ''), platform_version=platform.platform(), python_version=platform.python_version(), client=clearcut_client.Clearcut(LOG_SOURCE), ) def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.flush() def log_invocation_started(self, event_time: datetime, command_args: str): """Creates an event log with invocation started info.""" event = self._create_tool_event() event.invocation_started.CopyFrom( tool_event_pb2.ToolEvent.InvocationStarted( command_args=command_args, os=f'{self.platform_version}:{self.python_version}', ) ) logging.debug('Log invocation_started: %s', event) self._log_clearcut_event(event, event_time) def log_invocation_stopped( self, event_time: datetime, exit_code: int, exit_log: str, ): """Creates an event log with invocation stopped info.""" event = self._create_tool_event() event.invocation_stopped.CopyFrom( tool_event_pb2.ToolEvent.InvocationStopped( exit_code=exit_code, exit_log=exit_log, ) ) logging.debug('Log invocation_stopped: %s', event) self._log_clearcut_event(event, event_time) def flush(self): """Sends all batched events to Clearcut.""" logging.debug('Sending events to Clearcut.') self._clearcut_client.flush_events() def _create_tool_event(self): return tool_event_pb2.ToolEvent( tool_tag=self.tool_tag, invocation_id=self.invocation_id, user_name=self.user_name, host_name=self.host_name, source_root=self.source_root, ) def _log_clearcut_event( self, tool_event: tool_event_pb2.ToolEvent, event_time: datetime ): log_event = clientanalytics_pb2.LogEvent( event_time_ms=int(event_time.timestamp() * 1000), source_extension=tool_event.SerializeToString(), ) self._clearcut_client.log(log_event) class ArgumentParserWithLogging(argparse.ArgumentParser): def error(self, message): logging.error('Failed to parse args with error: %s', message) super().error(message) def create_arg_parser(): """Creates an instance of the default ToolEventLogger arg parser.""" parser = ArgumentParserWithLogging( description='Build and upload logs for Android dev tools', add_help=True, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( '--tool_tag', type=str, required=True, help='Name of the tool.', ) parser.add_argument( '--start_timestamp', type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), required=True, help=( 'Timestamp when the tool starts. The timestamp should have the format' '%s.%N which represents the seconds elapses since epoch.' ), ) parser.add_argument( '--end_timestamp', type=lambda ts: datetime.datetime.fromtimestamp(float(ts)), required=True, help=( 'Timestamp when the tool exits. The timestamp should have the format' '%s.%N which represents the seconds elapses since epoch.' ), ) parser.add_argument( '--tool_args', type=str, help='Parameters that are passed to the tool.', ) parser.add_argument( '--exit_code', type=int, required=True, help='Tool exit code.', ) parser.add_argument( '--exit_log', type=str, help='Logs when tool exits.', ) parser.add_argument( '--dry_run', action='store_true', help='Dry run the tool event logger if set.', ) return parser def configure_logging(): root_logging_dir = tempfile.mkdtemp(prefix='tool_event_logger_') log_fmt = '%(asctime)s %(filename)s:%(lineno)s:%(levelname)s: %(message)s' date_fmt = '%Y-%m-%d %H:%M:%S' _, log_path = tempfile.mkstemp(dir=root_logging_dir, suffix='.log') logging.basicConfig( filename=log_path, level=logging.DEBUG, format=log_fmt, datefmt=date_fmt ) def main(argv: list[str]): args = create_arg_parser().parse_args(argv[1:]) if args.dry_run: logging.debug('This is a dry run.') return try: with ToolEventLogger.create(args.tool_tag) as logger: logger.log_invocation_started(args.start_timestamp, args.tool_args) logger.log_invocation_stopped( args.end_timestamp, args.exit_code, args.exit_log ) except Exception as e: logging.error('Log failed with unexpected error: %s', e) raise if __name__ == '__main__': configure_logging() main(sys.argv)