From f57f4ce94fcb143481d0f5592117c7d427db5569 Mon Sep 17 00:00:00 2001 From: mjholder Date: Mon, 18 Dec 2023 17:02:34 -0500 Subject: [PATCH] Telemetry support (#339) * Add foundation for telemetry (disabled by default) --------- Co-authored-by: Brandon Squizzato --- bonfire/bonfire.py | 8 +++- bonfire/config.py | 8 +++- bonfire/elastic_logging.py | 85 ++++++++++++++++++++++++++++++++++++++ bonfire/namespaces.py | 1 + 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 bonfire/elastic_logging.py diff --git a/bonfire/bonfire.py b/bonfire/bonfire.py index 746c9237..f85cc2b4 100755 --- a/bonfire/bonfire.py +++ b/bonfire/bonfire.py @@ -12,6 +12,7 @@ from wait_for import TimedOutError import bonfire.config as conf +from bonfire.elastic_logging import ElasticLogger from bonfire.local import get_local_apps, get_appsfile_apps from bonfire.utils import AppOrComponentSelector, RepoFile, SYNTAX_ERR from bonfire.namespaces import ( @@ -51,7 +52,9 @@ merge_app_configs, ) + log = logging.getLogger(__name__) +es_telemetry = ElasticLogger() APP_SRE_SRC = "appsre" FILE_SRC = "file" @@ -66,6 +69,7 @@ def _error(msg): + es_telemetry.send_telemetry(msg, success=False) click.echo(f"ERROR: {msg}", err=True) sys.exit(1) @@ -86,7 +90,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): try: - return f(*args, **kwargs) + result = f(*args, **kwargs) + return result except KeyboardInterrupt: _error(f"{command}: aborted by keyboard interrupt") except TimedOutError as err: @@ -1350,6 +1355,7 @@ def _err_handler(err): _err_handler(err) else: log.info("successfully deployed to namespace %s", ns) + es_telemetry.send_telemetry("successful deployment") url = get_console_url() if url: ns_url = f"{url}/k8s/cluster/projects/{ns}" diff --git a/bonfire/config.py b/bonfire/config.py index f756ec30..907ae5fc 100644 --- a/bonfire/config.py +++ b/bonfire/config.py @@ -30,6 +30,9 @@ DEFAULT_GRAPHQL_URL = "https://app-interface.apps.appsrep05ue1.zqxk.p1.openshiftapps.com/graphql" +DEFAULT_ELASTICSEARCH_HOST = "https://localhost:9200/search-bonfire/_doc" +DEFAULT_ENABLE_TELEMETRY = "false" + ENV_FILE = str(DEFAULT_ENV_PATH.absolute()) if DEFAULT_ENV_PATH.exists() else "" load_dotenv(ENV_FILE) @@ -55,7 +58,7 @@ # can be used to set name of 'requester' on namespace reservations BONFIRE_NS_REQUESTER = os.getenv("BONFIRE_NS_REQUESTER") # set to true when bonfire is running via automation using a bot acct (not an end user) -BONFIRE_BOT = os.getenv("BONFIRE_BOT") +BONFIRE_BOT = os.getenv("BONFIRE_BOT", "false").lower() == "true" BONFIRE_DEFAULT_PREFER = str(os.getenv("BONFIRE_DEFAULT_PREFER", "ENV_NAME=frontends")).split(",") BONFIRE_DEFAULT_REF_ENV = str(os.getenv("BONFIRE_DEFAULT_REF_ENV", "insights-stage")) @@ -63,6 +66,9 @@ os.getenv("BONFIRE_DEFAULT_FALLBACK_REF_ENV", "insights-stage") ) +ELASTICSEARCH_HOST = os.getenv("ELASTICSEARCH_HOST", DEFAULT_ELASTICSEARCH_HOST) +ELASTICSEARCH_APIKEY = os.getenv("ELASTICSEARCH_APIKEY") +ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", DEFAULT_ENABLE_TELEMETRY).lower() == "true" DEFAULT_FRONTEND_DEPENDENCIES = ( "chrome-service", diff --git a/bonfire/elastic_logging.py b/bonfire/elastic_logging.py new file mode 100644 index 00000000..5835e771 --- /dev/null +++ b/bonfire/elastic_logging.py @@ -0,0 +1,85 @@ +from datetime import datetime as dt +import logging +import json +import requests +import sys +import uuid +from concurrent.futures import ThreadPoolExecutor + +import bonfire.config as conf + + +log = logging.getLogger(__name__) + + +class ElasticLogger: + def __init__(self): + self.es_telemetry = logging.getLogger("elasicsearch") + + # prevent duplicate handlers + self.es_handler = next( + (h for h in self.es_telemetry.handlers if type(h) is AsyncElasticsearchHandler), None + ) + if not self.es_handler: + self.es_handler = AsyncElasticsearchHandler(conf.ELASTICSEARCH_HOST) + self.es_telemetry.addHandler(self.es_handler) + + def send_telemetry(self, log_message, success=True): + self.es_handler.set_success_status(success) + + self.es_telemetry.info(log_message) + + +class AsyncElasticsearchHandler(logging.Handler): + def __init__(self, es_url): + super().__init__() + self.es_url = es_url + self.executor = ThreadPoolExecutor(max_workers=10) + self.start_time = dt.now() + self.metadata = { + "uuid": str(uuid.uuid4()), + "start_time": self.start_time.isoformat(), + "bot": conf.BONFIRE_BOT, + "command": self._mask_parameter_values(sys.argv[1:]), + } + + def emit(self, record): + self.metadata["@timestamp"] = dt.now().isoformat() + self.metadata["elapsed_sec"] = (dt.now() - self.start_time).total_seconds() + + log_entry = {"log": self.format(record), "metadata": self.metadata} + if conf.ENABLE_TELEMETRY: + self.executor.submit(self.send_to_es, json.dumps(log_entry)) + + def set_success_status(self, run_status): + self.metadata["succeeded"] = run_status + + def send_to_es(self, log_entry): + # Convert log_entry to JSON and send to Elasticsearch + try: + headers = { + "Authorization": conf.ELASTICSEARCH_APIKEY, + "Content-Type": "application/json", + } + + response = requests.post(self.es_url, headers=headers, data=log_entry, timeout=0.1) + response.raise_for_status() + except Exception as e: + # Handle exceptions (e.g., network issues, Elasticsearch down) + log.error("Error sending data to elasticsearch: %s", e) + + @staticmethod + def _mask_parameter_values(cli_args): + masked_list = [] + + is_parameter = False + for arg in cli_args: + if is_parameter: + masked_arg = f"{arg.split('=')[0]}=*******" + masked_list.append(masked_arg) + is_parameter = False + else: + masked_list.append(arg) + is_parameter = arg == "-p" or arg == "--set-parameter" + + return masked_list diff --git a/bonfire/namespaces.py b/bonfire/namespaces.py index 2a4add34..0d1194d7 100644 --- a/bonfire/namespaces.py +++ b/bonfire/namespaces.py @@ -19,6 +19,7 @@ log = logging.getLogger(__name__) + TIME_FMT = "%Y-%m-%dT%H:%M:%SZ"