probe_scraper/probe_expiry_alert.py (360 lines of code) (raw):

# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import argparse import datetime import os import re import sys import tempfile from collections import defaultdict from dataclasses import dataclass from typing import Dict, List, Tuple, Union import requests from probe_scraper import emailer from probe_scraper.parsers.events import EventsParser from probe_scraper.parsers.histograms import HistogramsParser from probe_scraper.parsers.scalars import ScalarsParser from probe_scraper.parsers.utils import HTTP_HEADERS, get_major_version FROM_EMAIL = "telemetry-alerts@mozilla.com" DEFAULT_TO_EMAIL = "glean-team@mozilla.com" BUGZILLA_BUG_URL = "https://bugzilla.mozilla.org/rest/bug" BUGZILLA_USER_URL = "https://bugzilla.mozilla.org/rest/user" BUGZILLA_BUG_LINK_TEMPLATE = "https://bugzilla.mozilla.org/show_bug.cgi?id={bug_id}" BASE_URI = ( "https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/components/telemetry/" ) HISTOGRAMS_FILE = "Histograms.json" SCALARS_FILE = "Scalars.yaml" EVENTS_FILE = "Events.yaml" BUG_DEFAULT_PRODUCT = "Firefox" BUG_DEFAULT_COMPONENT = "General" BUG_WHITEBOARD_TAG = "[probe-expiry-alert]" BUG_SUMMARY_TEMPLATE = ( "Remove or update probes expiring at the end of Firefox {version}: {probe}" ) # Regex for version and probe names requires bug description to have a certain structure # This template should be modified with care BUG_DESCRIPTION_TEMPLATE = """ The following Firefox probes will expire at the end of Firefox nightly release: version {version} [1]. ``` {probes} ``` {notes} What to do about this: 1. If one, some, or all of the metrics are no longer needed, please remove them from their definitions files (Histograms.json, Scalars.yaml, Events.yaml). 2. If one, some, or all of the metrics are still required, please submit a patch to extend their expiry. If you have any problems, please ask for help on the #data-help Slack channel or the #telemetry Matrix room at https://chat.mozilla.org/#/room/#telemetry:mozilla.org. We'll give you a hand. Your Friendly, Neighborhood Telemetry Team [1] https://wiki.mozilla.org/Release_Management/Calendar This is an automated message sent from probe-scraper. See https://github.com/mozilla/probe-scraper for details. """ # noqa BUG_LINK_LIST_TEMPLATE = """The following bugs were filed for the above probes: {bug_links} """ # This text is compared to a json blob, where quotes are escaped NEEDINFO_BLOCKED_TEXT = 'is not currently accepting \\"needinfo\\" requests.' NEEDINFO_USER_INACTIVE = "You can't ask Not active!" @dataclass class ProbeDetails: name: str product: str component: str emails: List[str] # int will be put in the "see also" field, string will be in description due to permissions previous_bug: Union[int, str, None] def bugzilla_request_header(api_key: str) -> Dict[str, str]: return {"X-BUGZILLA-API-KEY": api_key} | HTTP_HEADERS def get_bug_component( bug_id: int, api_key: str ) -> Tuple[Union[str, None], Union[str, None]]: response = requests.get( BUGZILLA_BUG_URL + "/" + str(bug_id), headers=bugzilla_request_header(api_key) ) try: response.raise_for_status() except requests.exceptions.HTTPError as e: print(f"Error getting component for bug {bug_id}: {e}") if ( e.response.status_code == 401 ): # Some confidential security bugs are not accessible return None, None else: raise bug = response.json()["bugs"][0] return bug["product"], bug["component"] def find_existing_bugs( version: str, api_key: str, whiteboard_tag: str ) -> Dict[str, int]: """Find bugs filed for the version and return mappings of probe name to bug id.""" search_query_params = { "whiteboard": whiteboard_tag, "include_fields": "description,summary,id", } response = requests.get( BUGZILLA_BUG_URL, params=search_query_params, headers=bugzilla_request_header(api_key), ) response.raise_for_status() found_bugs = response.json()["bugs"] probes_with_bugs = {} for bug in found_bugs: version_re = re.search(r"release: \[?version (\d+)", bug["description"]) probes_re = re.search(r"```(.*)```", bug["description"], re.DOTALL) # if version or a list of probes is not found in the description then the bug is skipped if version_re is None or probes_re is None or version_re.group(1) != version: continue probes_in_bug = probes_re.group(1).split() for probe_name in probes_in_bug: probes_with_bugs[probe_name] = bug["id"] return probes_with_bugs def get_longest_prefix(values: List[str], tolerance: int = 0) -> str: """ Return the longest matching prefix among the list of strings. If a prefix is less than 4 characters, return the first string. Tolerance allows some characters to not match and returns the highest occurring prefix. """ if tolerance < 0: raise ValueError("tolerance must be >= 0") if len(values) == 1: return values[0] if len(values) == 0: return "" if tolerance > 0: longest_value_length = max(len(v) for v in values) values = [v.ljust(longest_value_length) for v in values] prefix_length = 0 for c in zip(*values): if len(set(c)) > min([1 + tolerance, len(values) - 1]): break prefix_length += 1 if prefix_length < 4: return values[0] if tolerance == 0: return values[0][:prefix_length] prefix_count = defaultdict(int) for value in values: prefix_count[value[:prefix_length]] += 1 return ( sorted(prefix_count.items(), key=lambda item: item[1], reverse=True)[0][0] + "*" ) def create_bug( probes: List[ProbeDetails], version: str, whiteboard_tag: str, summary_template: str, description_template: str, api_key: str, needinfo: bool = True, ) -> int: probe_names = [probe.name for probe in probes] probe_prefix = get_longest_prefix(probe_names, tolerance=1) see_also_bugs = list( set( [ probe.previous_bug for probe in probes if isinstance(probe.previous_bug, int) ] ) ) see_also_bugs_str = list( set( [ probe.previous_bug for probe in probes if isinstance(probe.previous_bug, str) ] ) ) if len(see_also_bugs_str) == 0: notes = "" else: notes = ( "The following bugs are associated with the above " f"probe{'s' if len(probes) > 0 else ''}: " f"{', '.join([f'bug {bug_num}' for bug_num in see_also_bugs_str])}" ) create_params = { "product": probes[0].product, "component": probes[0].component, "summary": summary_template.format(version=version, probe=probe_prefix), "description": description_template.format( version=version, probes="\n".join(probe_names), notes=notes ), "version": "unspecified", "type": "task", "whiteboard": whiteboard_tag, "see_also": see_also_bugs, "flags": [ {"name": "needinfo", "type_id": 800, "status": "?", "requestee": email} for email in probes[0].emails if needinfo ], } create_response = requests.post( BUGZILLA_BUG_URL, json=create_params, headers=bugzilla_request_header(api_key) ) try: create_response.raise_for_status() except requests.exceptions.HTTPError: print(f"Failed to create bugs with arguments: {create_params}", file=sys.stderr) print(f"Error response: {create_response.text}", file=sys.stderr) # If filing a bug failed, try again without the needinfo. Chances are the requestee # accounts are inactive, disabled or no longer existing. Instead of playing whackamole # with the different errors, just retry without needinfo. if needinfo: print( "Needinfo request failed, retrying request without needinfo", file=sys.stderr, ) return create_bug( probes, version, whiteboard_tag, summary_template, description_template, api_key, needinfo=False, ) else: raise print(f"Created bug {str(create_response.json())} for {probe_prefix}") return create_response.json()["id"] def check_bugzilla_user_exists(email: str, api_key: str): user_response = requests.get( BUGZILLA_USER_URL + "?names=" + email, headers=bugzilla_request_header(api_key) ) try: user_response.raise_for_status() except requests.exceptions.HTTPError as e: # 400 is raised if user does not exist if e.response.status_code == 400 and e.response.json()["code"] == 51: return False raise # As of Sept 2020, api seems to be returning 200 response with an unknown # error code when user isn't found if user_response.json().get("error"): return False return user_response.json()["users"][0]["can_login"] def get_latest_nightly_version(): versions = requests.get( "https://product-details.mozilla.org/1.0/firefox_versions.json" ).json() return get_major_version(versions["FIREFOX_NIGHTLY"]) def download_file(url: str, output_filepath: str): content = requests.get(url, headers=HTTP_HEADERS).text with open(output_filepath, "w") as output_file: output_file.write(content) def find_expiring_probes( probes: dict, target_version: str, bugzilla_api_key: str ) -> List[ProbeDetails]: """ Find probes expiring in the target version """ expiring_probes = [] for name, details in probes.items(): expiry_version = details["expiry_version"] if expiry_version == target_version: if len(details["bug_numbers"]) == 0: last_bug_number = None product = BUG_DEFAULT_PRODUCT component = BUG_DEFAULT_COMPONENT else: last_bug_number = max(details["bug_numbers"]) product, component = get_bug_component( last_bug_number, bugzilla_api_key ) if product is None and component is None: last_bug_number = str(last_bug_number) product = BUG_DEFAULT_PRODUCT component = BUG_DEFAULT_COMPONENT expiring_probes.append( ProbeDetails( name, product, component, details.get("notification_emails", []), last_bug_number, ) ) return expiring_probes def send_emails( probes_by_email: Dict[str, List[str]], probe_to_bug_id: Dict[str, int], version: str, dryrun: bool = True, ): # send all probes to glean-team for debugging probes_by_email[DEFAULT_TO_EMAIL] = list(set(sum(probes_by_email.values(), []))) email_count = 0 for email, probe_names in probes_by_email.items(): # No probes found -> nothing to do if not probe_names: continue bug_links = { BUGZILLA_BUG_LINK_TEMPLATE.format(bug_id=probe_to_bug_id[probe]) for probe in probe_names if probe in probe_to_bug_id.keys() } if len(bug_links) == 0 and email != DEFAULT_TO_EMAIL: continue # no bug links means bugs were already created and emails sent email_body = BUG_DESCRIPTION_TEMPLATE.format( version=version, probes="\n".join(probe_names), notes=BUG_LINK_LIST_TEMPLATE.format(bug_links="\n".join(bug_links)), ) emailer.send_ses( FROM_EMAIL, "Telemetry Probe Expiry", body=email_body, recipients=email, dryrun=dryrun, ) email_count += 1 print(f"Sent emails to {email_count} recipients") def file_bugs( probes: List[ProbeDetails], version: str, bugzilla_api_key: str, dryrun: bool = True, whiteboard_tag: str = BUG_WHITEBOARD_TAG, summary_template: str = BUG_SUMMARY_TEMPLATE, description_template: str = BUG_DESCRIPTION_TEMPLATE, ) -> Dict[str, int]: """ Search for bugs that have already been created by probe_expiry_alerts for probes in the current version. For each probe/bug: - if a bug exists for a probe in the given list, do nothing - if no bug exists for a given probe, create a bug Return mapping of probe names to bug id for any newly created bugs """ existing_bugs = find_existing_bugs(version, bugzilla_api_key, whiteboard_tag) new_expiring_probes = [ probe for probe in probes if probe.name not in existing_bugs.keys() ] print( f"Found previously created bugs for {len(probes) - len(new_expiring_probes)}" f" probes for version {version}" ) # group by component and email probes_by_component_by_email_set = defaultdict(list) for probe in new_expiring_probes: probes_by_component_by_email_set[ (probe.product, probe.component, ",".join(sorted(probe.emails))) ].append(probe) print(f"creating {len(probes_by_component_by_email_set)} new bugs") probe_to_bug_id_map = existing_bugs for grouping, probe_group in probes_by_component_by_email_set.items(): if not dryrun: bug_id = create_bug( probe_group, version, whiteboard_tag, summary_template, description_template, bugzilla_api_key, ) for probe in probe_group: probe_to_bug_id_map[probe.name] = bug_id return probe_to_bug_id_map def main(current_date: datetime.date, dryrun: bool, bugzilla_api_key: str): # Only send create bugs on Wednesdays, run the rest for debugging/error detection dryrun = dryrun or current_date.weekday() != 2 current_version = str(int(get_latest_nightly_version())) next_version = str(int(current_version) + 1) with tempfile.TemporaryDirectory() as tempdir: events_file_path = os.path.join(tempdir, EVENTS_FILE) download_file(BASE_URI + EVENTS_FILE, events_file_path) events = EventsParser().parse([events_file_path]) histograms_file_path = os.path.join(tempdir, HISTOGRAMS_FILE) download_file(BASE_URI + HISTOGRAMS_FILE, histograms_file_path) histograms = HistogramsParser().parse( [histograms_file_path], version=next_version ) scalars_file_path = os.path.join(tempdir, SCALARS_FILE) download_file(BASE_URI + SCALARS_FILE, scalars_file_path) scalars = ScalarsParser().parse([scalars_file_path]) all_probes = events.copy() all_probes.update(histograms) all_probes.update(scalars) expiring_probes = find_expiring_probes(all_probes, next_version, bugzilla_api_key) print(f"Found {len(expiring_probes)} probes expiring in nightly {next_version}") print([probe.name for probe in expiring_probes]) # find emails with no bugzilla account emails_wo_accounts = defaultdict(list) for probe_name, email_list in [ (probe.name, probe.emails) for probe in expiring_probes ]: for i, email in enumerate(email_list.copy()): if email in emails_wo_accounts.keys() or not check_bugzilla_user_exists( email, bugzilla_api_key ): emails_wo_accounts[email].append(probe_name) email_list.remove(email) probe_to_bug_id = file_bugs( expiring_probes, current_version, bugzilla_api_key, dryrun=dryrun ) send_emails(emails_wo_accounts, probe_to_bug_id, current_version, dryrun=dryrun) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--date", type=datetime.date.fromisoformat, required=True) parser.add_argument( "--dry-run", help="Whether emails should be sent", action="store_true" ) parser.add_argument("--bugzilla-api-key", type=str, required=True) return parser.parse_args() if __name__ == "__main__": args = parse_args() main(args.date, args.dry_run, args.bugzilla_api_key)